Files
beads/internal/beads/beads_test.go
Steve Yegge 8a6fd9c0ff feat: add .beads/redirect file support for workspace redirection
Adds a lightweight redirect mechanism that allows a stub .beads directory
to point to the actual beads location. This solves the workspace problem
where an AI agent runs in one directory but needs to operate on beads
stored elsewhere.

The redirect file is a simple text file containing a path (relative or
absolute) to the target .beads directory. Comments (lines starting with #)
are supported. Redirect chains are prevented - only one level is followed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 19:23:30 -08:00

812 lines
22 KiB
Go

package beads
import (
"os"
"path/filepath"
"testing"
)
func TestFindDatabasePathEnvVar(t *testing.T) {
// Save original env var
originalEnv := os.Getenv("BEADS_DB")
defer func() {
if originalEnv != "" {
_ = os.Setenv("BEADS_DB", originalEnv)
} else {
_ = os.Unsetenv("BEADS_DB")
}
}()
// Set env var to a test path (platform-agnostic)
testPath := filepath.Join("test", "path", "test.db")
_ = os.Setenv("BEADS_DB", testPath)
result := FindDatabasePath()
// FindDatabasePath canonicalizes to absolute path
expectedPath, _ := filepath.Abs(testPath)
if result != expectedPath {
t.Errorf("Expected '%s', got '%s'", expectedPath, result)
}
}
func TestFindDatabasePathInTree(t *testing.T) {
// Save original env var and working directory
originalEnv := os.Getenv("BEADS_DB")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DB", originalEnv)
} else {
os.Unsetenv("BEADS_DB")
}
os.Chdir(originalWd)
}()
// Clear env var
os.Unsetenv("BEADS_DB")
// Create temporary directory structure
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create .beads directory with a database file
beadsDir := filepath.Join(tmpDir, ".beads")
err = os.MkdirAll(beadsDir, 0o750)
if err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
f, err := os.Create(dbPath)
if err != nil {
t.Fatalf("Failed to create db file: %v", err)
}
f.Close()
// Create a subdirectory and change to it
subDir := filepath.Join(tmpDir, "sub", "nested")
err = os.MkdirAll(subDir, 0o750)
if err != nil {
t.Fatalf("Failed to create subdirectory: %v", err)
}
err = os.Chdir(subDir)
if err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
// Should find the database in the parent directory tree
result := FindDatabasePath()
// Resolve symlinks for both paths (macOS uses /private/var symlinked to /var)
expectedPath, err := filepath.EvalSymlinks(dbPath)
if err != nil {
expectedPath = dbPath
}
resultPath, err := filepath.EvalSymlinks(result)
if err != nil {
resultPath = result
}
if resultPath != expectedPath {
t.Errorf("Expected '%s', got '%s'", expectedPath, resultPath)
}
}
func TestFindDatabasePathNotFound(t *testing.T) {
// Save original env var and working directory
originalEnv := os.Getenv("BEADS_DB")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DB", originalEnv)
} else {
os.Unsetenv("BEADS_DB")
}
os.Chdir(originalWd)
}()
// Clear env var
os.Unsetenv("BEADS_DB")
// Create temporary directory without .beads
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
err = os.Chdir(tmpDir)
if err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
// Should return empty string (no database found)
result := FindDatabasePath()
// Result might be the home directory default if it exists, or empty string
// Just verify it doesn't error
_ = result
}
func TestFindJSONLPathWithExistingFile(t *testing.T) {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a .jsonl file
jsonlPath := filepath.Join(tmpDir, "custom.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create jsonl file: %v", err)
}
f.Close()
// Create a fake database path in the same directory
dbPath := filepath.Join(tmpDir, "test.db")
// Should find the existing .jsonl file
result := FindJSONLPath(dbPath)
if result != jsonlPath {
t.Errorf("Expected '%s', got '%s'", jsonlPath, result)
}
}
func TestFindJSONLPathDefault(t *testing.T) {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a fake database path (no .jsonl files exist)
dbPath := filepath.Join(tmpDir, "test.db")
// bd-6xd: Should return default issues.jsonl (canonical name)
result := FindJSONLPath(dbPath)
expected := filepath.Join(tmpDir, "issues.jsonl")
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestFindJSONLPathEmpty(t *testing.T) {
// Empty database path should return empty string
result := FindJSONLPath("")
if result != "" {
t.Errorf("Expected empty string for empty db path, got '%s'", result)
}
}
func TestFindJSONLPathMultipleFiles(t *testing.T) {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create multiple .jsonl files
jsonlFiles := []string{"issues.jsonl", "backup.jsonl", "archive.jsonl"}
for _, filename := range jsonlFiles {
f, err := os.Create(filepath.Join(tmpDir, filename))
if err != nil {
t.Fatalf("Failed to create jsonl file: %v", err)
}
f.Close()
}
// Create a fake database path
dbPath := filepath.Join(tmpDir, "test.db")
// Should return the first .jsonl file found (lexicographically sorted by Glob)
result := FindJSONLPath(dbPath)
// Verify it's one of the .jsonl files we created
found := false
for _, filename := range jsonlFiles {
if result == filepath.Join(tmpDir, filename) {
found = true
break
}
}
if !found {
t.Errorf("Expected one of the created .jsonl files, got '%s'", result)
}
}
// TestFindJSONLPathSkipsDeletions verifies that FindJSONLPath skips deletions.jsonl
// and merge artifacts to prevent corruption (bd-tqo fix)
func TestFindJSONLPathSkipsDeletions(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "prefers issues.jsonl over deletions.jsonl",
files: []string{"deletions.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "skips deletions.jsonl when only option",
files: []string{"deletions.jsonl"},
expected: "issues.jsonl", // Falls back to default
},
{
name: "skips merge artifacts",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "prefers issues over beads",
files: []string{"beads.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "uses beads.jsonl as legacy fallback",
files: []string{"beads.jsonl", "deletions.jsonl"},
expected: "beads.jsonl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "beads-jsonl-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
for _, file := range tt.files {
path := filepath.Join(tmpDir, file)
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}
dbPath := filepath.Join(tmpDir, "test.db")
result := FindJSONLPath(dbPath)
expected := filepath.Join(tmpDir, tt.expected)
if result != expected {
t.Errorf("FindJSONLPath() = %q, want %q", result, expected)
}
})
}
}
// TestHasBeadsProjectFiles verifies that hasBeadsProjectFiles correctly
// distinguishes between project directories and daemon-only directories (bd-420)
func TestHasBeadsProjectFiles(t *testing.T) {
tests := []struct {
name string
files []string
expected bool
}{
{
name: "empty directory",
files: []string{},
expected: false,
},
{
name: "daemon registry only",
files: []string{"registry.json", "registry.lock"},
expected: false,
},
{
name: "has database",
files: []string{"beads.db"},
expected: true,
},
{
name: "has issues.jsonl",
files: []string{"issues.jsonl"},
expected: true,
},
{
name: "has metadata.json",
files: []string{"metadata.json"},
expected: true,
},
{
name: "has config.yaml",
files: []string{"config.yaml"},
expected: true,
},
{
name: "ignores backup db",
files: []string{"beads.backup.db"},
expected: false,
},
{
name: "ignores vc.db",
files: []string{"vc.db"},
expected: false,
},
{
name: "real db with backup",
files: []string{"beads.db", "beads.backup.db"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "beads-project-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
for _, file := range tt.files {
path := filepath.Join(tmpDir, file)
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}
result := hasBeadsProjectFiles(tmpDir)
if result != tt.expected {
t.Errorf("hasBeadsProjectFiles() = %v, want %v", result, tt.expected)
}
})
}
}
// TestFindBeadsDirSkipsDaemonRegistry verifies that FindBeadsDir skips
// directories containing only daemon registry files (bd-420)
func TestFindBeadsDirSkipsDaemonRegistry(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
os.Chdir(originalWd)
}()
os.Unsetenv("BEADS_DIR")
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "beads-daemon-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create .beads with only daemon registry files (should be skipped)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "registry.json"), []byte("[]"), 0644); err != nil {
t.Fatal(err)
}
// Change to temp dir
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
// Should NOT find the daemon-only directory
result := FindBeadsDir()
if result != "" {
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
beadsDirResolved, _ := filepath.EvalSymlinks(beadsDir)
if resultResolved == beadsDirResolved {
t.Errorf("FindBeadsDir() should skip daemon-only directory, got %q", result)
}
}
}
// TestFindBeadsDirValidatesBeadsDirEnv verifies that BEADS_DIR env var
// is validated for project files (bd-420)
func TestFindBeadsDirValidatesBeadsDirEnv(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
// Create temp directory with only daemon registry files
tmpDir, err := os.MkdirTemp("", "beads-env-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "registry.json"), []byte("[]"), 0644); err != nil {
t.Fatal(err)
}
// Set BEADS_DIR to daemon-only directory
os.Setenv("BEADS_DIR", tmpDir)
// Should NOT return the daemon-only directory
result := FindBeadsDir()
if result != "" {
resultResolved, _ := filepath.EvalSymlinks(result)
tmpDirResolved, _ := filepath.EvalSymlinks(tmpDir)
if resultResolved == tmpDirResolved {
t.Errorf("FindBeadsDir() should skip BEADS_DIR with only daemon files, got %q", result)
}
}
// Now add a project file
if err := os.WriteFile(filepath.Join(tmpDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Should now return the directory
result = FindBeadsDir()
if result == "" {
t.Error("FindBeadsDir() should return BEADS_DIR with project files")
}
}
func TestFindDatabasePathHomeDefault(t *testing.T) {
// This test verifies that if no database is found, it falls back to home directory
// We can't reliably test this without modifying the home directory, so we'll skip
// creating the file and just verify the function doesn't crash
originalEnv := os.Getenv("BEADS_DB")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DB", originalEnv)
} else {
os.Unsetenv("BEADS_DB")
}
os.Chdir(originalWd)
}()
os.Unsetenv("BEADS_DB")
// Create an empty temp directory and cd to it
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
err = os.Chdir(tmpDir)
if err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
// Call FindDatabasePath - it might return home dir default or empty string
result := FindDatabasePath()
// If result is not empty, verify it contains .beads
if result != "" && !filepath.IsAbs(result) {
t.Errorf("Expected absolute path or empty string, got '%s'", result)
}
}
// TestFollowRedirect tests the redirect file functionality
func TestFollowRedirect(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tmpDir string) (stubDir, targetDir string)
expectRedirect bool
}{
{
name: "no redirect file - returns original",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
return beadsDir, ""
},
expectRedirect: false,
},
{
name: "relative path redirect",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
// Create stub .beads with redirect
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Create target .beads directory
targetDir := filepath.Join(tmpDir, "actual", ".beads")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Write redirect file with relative path
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
return stubDir, targetDir
},
expectRedirect: true,
},
{
name: "absolute path redirect",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
// Create stub .beads with redirect
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Create target .beads directory
targetDir := filepath.Join(tmpDir, "actual", ".beads")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Write redirect file with absolute path
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte(targetDir+"\n"), 0644); err != nil {
t.Fatal(err)
}
return stubDir, targetDir
},
expectRedirect: true,
},
{
name: "redirect with comments",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
// Create stub .beads with redirect
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Create target .beads directory
targetDir := filepath.Join(tmpDir, "actual", ".beads")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Write redirect file with comments
redirectPath := filepath.Join(stubDir, "redirect")
content := "# Redirect to actual beads location\n# This is a workspace redirect\n" + targetDir + "\n"
if err := os.WriteFile(redirectPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return stubDir, targetDir
},
expectRedirect: true,
},
{
name: "redirect to non-existent directory - returns original",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Write redirect to non-existent path
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("/nonexistent/path/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
return stubDir, ""
},
expectRedirect: false, // Should fall back to original
},
{
name: "empty redirect file - returns original",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Write empty redirect file
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte(""), 0644); err != nil {
t.Fatal(err)
}
return stubDir, ""
},
expectRedirect: false,
},
{
name: "redirect file with only comments - returns original",
setupFunc: func(t *testing.T, tmpDir string) (string, string) {
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Write redirect file with only comments
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("# Just a comment\n# Another comment\n"), 0644); err != nil {
t.Fatal(err)
}
return stubDir, ""
},
expectRedirect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
stubDir, targetDir := tt.setupFunc(t, tmpDir)
result := followRedirect(stubDir)
// Resolve symlinks for comparison (macOS uses /private/var)
resultResolved, _ := filepath.EvalSymlinks(result)
stubResolved, _ := filepath.EvalSymlinks(stubDir)
if tt.expectRedirect {
targetResolved, _ := filepath.EvalSymlinks(targetDir)
if resultResolved != targetResolved {
t.Errorf("followRedirect() = %q, want %q", result, targetDir)
}
} else {
if resultResolved != stubResolved {
t.Errorf("followRedirect() = %q, want original %q", result, stubDir)
}
}
})
}
}
// TestFindDatabasePathWithRedirect tests that FindDatabasePath follows redirects
func TestFindDatabasePathWithRedirect(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
os.Chdir(originalWd)
}()
os.Unsetenv("BEADS_DIR")
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "beads-redirect-finddb-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create stub .beads with redirect
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Create target .beads directory with actual database
targetDir := filepath.Join(tmpDir, "actual", ".beads")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
targetDB := filepath.Join(targetDir, "beads.db")
if err := os.WriteFile(targetDB, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Write redirect file
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
// Change to project directory
projectDir := filepath.Join(tmpDir, "project")
if err := os.Chdir(projectDir); err != nil {
t.Fatal(err)
}
// FindDatabasePath should follow the redirect
result := FindDatabasePath()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
targetDBResolved, _ := filepath.EvalSymlinks(targetDB)
if resultResolved != targetDBResolved {
t.Errorf("FindDatabasePath() = %q, want %q (via redirect)", result, targetDB)
}
}
// TestFindBeadsDirWithRedirect tests that FindBeadsDir follows redirects
func TestFindBeadsDirWithRedirect(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
originalWd, _ := os.Getwd()
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
os.Chdir(originalWd)
}()
os.Unsetenv("BEADS_DIR")
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "beads-redirect-finddir-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create stub .beads with redirect
stubDir := filepath.Join(tmpDir, "project", ".beads")
if err := os.MkdirAll(stubDir, 0755); err != nil {
t.Fatal(err)
}
// Create target .beads directory with project files
targetDir := filepath.Join(tmpDir, "actual", ".beads")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(targetDir, "issues.jsonl"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
// Write redirect file
redirectPath := filepath.Join(stubDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
// Change to project directory
projectDir := filepath.Join(tmpDir, "project")
if err := os.Chdir(projectDir); err != nil {
t.Fatal(err)
}
// FindBeadsDir should follow the redirect
result := FindBeadsDir()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
targetDirResolved, _ := filepath.EvalSymlinks(targetDir)
if resultResolved != targetDirResolved {
t.Errorf("FindBeadsDir() = %q, want %q (via redirect)", result, targetDir)
}
}