| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | ) |
| 11 | |
| 12 | // TestWorkspaceRootForDir covers the --dir plumbing: no --dir yields no override |
| 13 | // (empty, so boot falls back to git-root detection), while a --dir run returns |
| 14 | // the post-chdir working directory as the explicit root. A Getwd failure surfaces |
| 15 | // as an error rather than a silent empty fallback. |
| 16 | func TestWorkspaceRootForDir(t *testing.T) { |
| 17 | // No --dir: no explicit override, no error. |
| 18 | if got, err := workspaceRootForDir(""); err != nil || got != "" { |
| 19 | t.Fatalf("workspaceRootForDir(\"\") = %q, %v; want \"\", nil", got, err) |
| 20 | } |
| 21 | |
| 22 | // With --dir: chdirTo has already switched in, so the root is the CWD. |
| 23 | dir := t.TempDir() |
| 24 | t.Chdir(dir) |
| 25 | got, err := workspaceRootForDir(dir) |
| 26 | if err != nil { |
| 27 | t.Fatalf("workspaceRootForDir: %v", err) |
| 28 | } |
| 29 | want, err := filepath.EvalSymlinks(dir) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | gotResolved, err := filepath.EvalSymlinks(got) |
| 34 | if err != nil { |
| 35 | t.Fatalf("workspaceRootForDir returned unusable path %q: %v", got, err) |
| 36 | } |
| 37 | if gotResolved != want { |
| 38 | t.Fatalf("workspaceRootForDir(%q) = %q, want cwd %q", dir, got, dir) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | const minimalTestModelTOML = ` |
| 43 | default_model = "test-model" |
| 44 | |
| 45 | [[providers]] |
| 46 | name = "test-model" |
| 47 | kind = "openai" |
| 48 | base_url = "https://example.invalid" |
| 49 | model = "x" |
| 50 | api_key_env = "REASONIX_TEST_KEY_UNSET" |
| 51 | ` |
| 52 | |
| 53 | // TestSetupProfilePinsExplicitDirOverGitRoot drives the real chdirTo -> |
| 54 | // workspaceRootForDir -> setupProfile -> boot.Build wiring shared by the |
| 55 | // initial CLI controller (runAgent, chatREPL), not just the helpers in |
| 56 | // isolation. An explicit --dir root inside a git repo must reach the built |
| 57 | // controller unchanged; an empty --dir must still fall back to the nearest |
| 58 | // git root. This proves the seam actually forwards the value end to end. |
| 59 | func TestSetupProfilePinsExplicitDirOverGitRoot(t *testing.T) { |
| 60 | isolateCLIConfigHome(t) |
| 61 | |
| 62 | repo := t.TempDir() |
| 63 | if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | sub := filepath.Join(repo, "a", "b") |
| 67 | if err := os.MkdirAll(sub, 0o755); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | for _, dir := range []string{repo, sub} { |
| 71 | if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(minimalTestModelTOML), 0o644); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | origWD, err := os.Getwd() |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | t.Cleanup(func() { _ = os.Chdir(origWD) }) |
| 81 | if err := os.Chdir(sub); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | |
| 85 | // Explicit --dir: workspaceRootForDir pins sub; setupProfile must not widen |
| 86 | // it back to the repo's git root. |
| 87 | workspaceRoot, err := workspaceRootForDir(sub) |
| 88 | if err != nil { |
| 89 | t.Fatalf("workspaceRootForDir: %v", err) |
| 90 | } |
| 91 | ctrl, err := setupProfile(context.Background(), "", 0, false, event.Discard, "", workspaceRoot) |
| 92 | if err != nil { |
| 93 | t.Fatalf("setupProfile with explicit --dir: %v", err) |
| 94 | } |
| 95 | defer ctrl.Close() |
| 96 | wantSub, err := filepath.EvalSymlinks(sub) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | gotSub, err := filepath.EvalSymlinks(ctrl.WorkspaceRoot()) |
| 101 | if err != nil { |
| 102 | t.Fatalf("controller has unusable workspace root %q: %v", ctrl.WorkspaceRoot(), err) |
| 103 | } |
| 104 | if gotSub != wantSub { |
| 105 | t.Fatalf("setupProfile with --dir %s: controller workspace root = %q, want explicit dir (must not widen to repo root)", sub, ctrl.WorkspaceRoot()) |
| 106 | } |
| 107 | |
| 108 | // No --dir: still falls back to the nearest git root from the CWD (sub is |
| 109 | // still inside repo, which has the .git marker). |
| 110 | ctrlFallback, err := setupProfile(context.Background(), "", 0, false, event.Discard, "", "") |
| 111 | if err != nil { |
| 112 | t.Fatalf("setupProfile with no --dir: %v", err) |
| 113 | } |
| 114 | defer ctrlFallback.Close() |
| 115 | wantRepo, err := filepath.EvalSymlinks(repo) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | gotRepo, err := filepath.EvalSymlinks(ctrlFallback.WorkspaceRoot()) |
| 120 | if err != nil { |
| 121 | t.Fatalf("fallback controller has unusable workspace root %q: %v", ctrlFallback.WorkspaceRoot(), err) |
| 122 | } |
| 123 | if gotRepo != wantRepo { |
| 124 | t.Fatalf("setupProfile with no --dir: controller workspace root = %q, want git root %q", ctrlFallback.WorkspaceRoot(), repo) |
| 125 | } |
| 126 | } |
| 127 |