package routing import ( "errors" "os" "path/filepath" "reflect" "testing" ) func TestDetermineTargetRepo(t *testing.T) { tests := []struct { name string config *RoutingConfig userRole UserRole repoPath string want string }{ { name: "explicit override takes precedence", config: &RoutingConfig{ Mode: "auto", DefaultRepo: "~/planning", MaintainerRepo: ".", ContributorRepo: "~/contributor-planning", ExplicitOverride: "/tmp/custom", }, userRole: Maintainer, repoPath: ".", want: "/tmp/custom", }, { name: "auto mode - maintainer uses maintainer repo", config: &RoutingConfig{ Mode: "auto", MaintainerRepo: ".", ContributorRepo: "~/contributor-planning", }, userRole: Maintainer, repoPath: ".", want: ".", }, { name: "auto mode - contributor uses contributor repo", config: &RoutingConfig{ Mode: "auto", MaintainerRepo: ".", ContributorRepo: "~/contributor-planning", }, userRole: Contributor, repoPath: ".", want: "~/contributor-planning", }, { name: "explicit mode uses default", config: &RoutingConfig{ Mode: "explicit", DefaultRepo: "~/planning", }, userRole: Maintainer, repoPath: ".", want: "~/planning", }, { name: "no config defaults to current directory", config: &RoutingConfig{ Mode: "auto", }, userRole: Maintainer, repoPath: ".", want: ".", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := DetermineTargetRepo(tt.config, tt.userRole, tt.repoPath) if got != tt.want { t.Errorf("DetermineTargetRepo() = %v, want %v", got, tt.want) } }) } } func TestDetectUserRole_Fallback(t *testing.T) { // Test fallback behavior when git is not available - local projects default to maintainer role, err := DetectUserRole("/nonexistent/path/that/does/not/exist") if err != nil { t.Fatalf("DetectUserRole() error = %v, want nil", err) } if role != Maintainer { t.Errorf("DetectUserRole() = %v, want %v (local project fallback)", role, Maintainer) } } func TestExtractPrefix(t *testing.T) { tests := []struct { id string want string }{ {"gt-abc123", "gt-"}, {"bd-xyz", "bd-"}, {"hq-1234", "hq-"}, {"abc123", ""}, // No hyphen {"", ""}, // Empty string {"-abc", "-"}, // Starts with hyphen } for _, tt := range tests { t.Run(tt.id, func(t *testing.T) { got := ExtractPrefix(tt.id) if got != tt.want { t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want) } }) } } func TestExtractProjectFromPath(t *testing.T) { tests := []struct { path string want string }{ {"beads/mayor/rig", "beads"}, {"gastown/crew/max", "gastown"}, {"simple", "simple"}, {"", ""}, {"/absolute/path", ""}, // Starts with /, first component is empty } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { got := ExtractProjectFromPath(tt.path) if got != tt.want { t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want) } }) } } func TestResolveToExternalRef(t *testing.T) { // This test is limited since it requires a routes.jsonl file // Just test that it returns empty string for nonexistent directory got := ResolveToExternalRef("bd-abc", "/nonexistent/path") if got != "" { t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got) } } type gitCall struct { repo string args []string } type gitResponse struct { expect gitCall output string err error } type gitStub struct { t *testing.T responses []gitResponse idx int } func (s *gitStub) run(repo string, args ...string) ([]byte, error) { if s.idx >= len(s.responses) { s.t.Fatalf("unexpected git call %v in repo %s", args, repo) } resp := s.responses[s.idx] s.idx++ if resp.expect.repo != repo { s.t.Fatalf("repo mismatch: got %q want %q", repo, resp.expect.repo) } if !reflect.DeepEqual(resp.expect.args, args) { s.t.Fatalf("args mismatch: got %v want %v", args, resp.expect.args) } return []byte(resp.output), resp.err } func (s *gitStub) verify() { if s.idx != len(s.responses) { s.t.Fatalf("expected %d git calls, got %d", len(s.responses), s.idx) } } func TestDetectUserRole_ConfigOverrideMaintainer(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"", []string{"config", "--get", "beads.role"}}, output: "maintainer\n"}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Maintainer { t.Fatalf("expected %s, got %s", Maintainer, role) } } func TestDetectUserRole_ConfigOverrideContributor(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "contributor\n"}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("/repo") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Contributor { t.Fatalf("expected %s, got %s", Contributor, role) } } func TestDetectUserRole_PushURLMaintainer(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "unknown"}, {expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "git@github.com:owner/repo.git"}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("/repo") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Maintainer { t.Fatalf("expected %s, got %s", Maintainer, role) } } func TestDetectUserRole_HTTPSCredentialsMaintainer(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: ""}, {expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "https://token@github.com/owner/repo.git"}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("/repo") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Maintainer { t.Fatalf("expected %s, got %s", Maintainer, role) } } func TestDetectUserRole_HTTPSNoCredentialsContributor(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"", []string{"config", "--get", "beads.role"}}, err: errors.New("missing")}, {expect: gitCall{"", []string{"remote", "get-url", "--push", "origin"}}, err: errors.New("no push")}, {expect: gitCall{"", []string{"remote", "get-url", "origin"}}, output: "https://github.com/owner/repo.git"}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Contributor { t.Fatalf("expected %s, got %s", Contributor, role) } } func TestDetectUserRole_NoRemoteMaintainer(t *testing.T) { // When no git remote is configured, default to maintainer (local project) orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ {expect: gitCall{"/local", []string{"config", "--get", "beads.role"}}, err: errors.New("missing")}, {expect: gitCall{"/local", []string{"remote", "get-url", "--push", "origin"}}, err: errors.New("no remote")}, {expect: gitCall{"/local", []string{"remote", "get-url", "origin"}}, err: errors.New("no remote")}, }} gitCommandRunner = stub.run t.Cleanup(func() { gitCommandRunner = orig stub.verify() }) role, err := DetectUserRole("/local") if err != nil { t.Fatalf("DetectUserRole error = %v", err) } if role != Maintainer { t.Fatalf("expected %s for local project with no remote, got %s", Maintainer, role) } } // TestFindTownRoutes_SymlinkedBeadsDir verifies that findTownRoutes correctly // handles symlinked .beads directories by using findTownRootFromCWD() instead of // walking up from the beadsDir path. // // Scenario: ~/gt/.beads is a symlink to ~/gt/olympus/.beads // Before fix: walking up from ~/gt/olympus/.beads finds ~/gt/olympus (WRONG) // After fix: findTownRootFromCWD() walks up from CWD to find mayor/town.json at ~/gt func TestFindTownRoutes_SymlinkedBeadsDir(t *testing.T) { // Create temporary directory structure simulating Gas Town: // tmpDir/ // mayor/ // town.json <- town root marker // olympus/ <- actual beads storage // .beads/ // routes.jsonl // .beads -> olympus/.beads <- symlink // daedalus/ // mayor/ // rig/ // .beads/ <- target rig tmpDir, err := os.MkdirTemp("", "routing-symlink-test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) // Resolve symlinks in tmpDir (macOS /var -> /private/var) tmpDir, err = filepath.EvalSymlinks(tmpDir) if err != nil { t.Fatal(err) } // Create mayor/town.json to mark town root mayorDir := filepath.Join(tmpDir, "mayor") if err := os.MkdirAll(mayorDir, 0750); err != nil { t.Fatal(err) } townJSON := filepath.Join(mayorDir, "town.json") if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0600); err != nil { t.Fatal(err) } // Create olympus/.beads with routes.jsonl olympusBeadsDir := filepath.Join(tmpDir, "olympus", ".beads") if err := os.MkdirAll(olympusBeadsDir, 0750); err != nil { t.Fatal(err) } routesContent := `{"prefix": "gt-", "path": "daedalus/mayor/rig"} ` routesPath := filepath.Join(olympusBeadsDir, "routes.jsonl") if err := os.WriteFile(routesPath, []byte(routesContent), 0600); err != nil { t.Fatal(err) } // Create daedalus/mayor/rig/.beads as target rig daedalusBeadsDir := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads") if err := os.MkdirAll(daedalusBeadsDir, 0750); err != nil { t.Fatal(err) } // Create metadata.json so the rig is recognized as valid if err := os.WriteFile(filepath.Join(daedalusBeadsDir, "metadata.json"), []byte(`{}`), 0600); err != nil { t.Fatal(err) } // Create symlink: tmpDir/.beads -> olympus/.beads symlinkPath := filepath.Join(tmpDir, ".beads") if err := os.Symlink(olympusBeadsDir, symlinkPath); err != nil { t.Skip("Cannot create symlinks on this system (may require admin on Windows)") } // Change to the town root directory - this simulates the user running bd from ~/gt // The fix uses findTownRootFromCWD() which needs CWD to be inside the town t.Chdir(tmpDir) // Simulate what happens when FindBeadsDir() returns the resolved symlink path // (this is what CanonicalizePath does) resolvedBeadsDir := olympusBeadsDir // This is what would be passed to findTownRoutes // Call findTownRoutes with the resolved symlink path routes, townRoot := findTownRoutes(resolvedBeadsDir) // Verify we got the routes if len(routes) == 0 { t.Fatal("findTownRoutes returned no routes") } // Verify the town root is correct (should be tmpDir, NOT tmpDir/olympus) if townRoot != tmpDir { t.Errorf("findTownRoutes returned wrong townRoot:\n got: %s\n want: %s", townRoot, tmpDir) } // Verify route resolution works - the route should resolve to the correct path expectedRigPath := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads") for _, route := range routes { if route.Prefix == "gt-" { actualPath := filepath.Join(townRoot, route.Path, ".beads") if actualPath != expectedRigPath { t.Errorf("Route resolution failed:\n got: %s\n want: %s", actualPath, expectedRigPath) } } } }