@@ -4,6 +4,7 @@ import (
44 "io"
55 "log/slog"
66 "os"
7+ "os/exec"
78 "path/filepath"
89 "testing"
910
@@ -36,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) {
3637 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
3738}
3839
40+func TestLookupConfigs_BoundedByProject(t *testing.T) {
41+ // Force GlobalConfig and GlobalConfigData to point at locations we
42+ // control so they can be present in the result without polluting
43+ // the developer's real config.
44+ globalDir := t.TempDir()
45+ t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
46+ t.Setenv("CRUSH_GLOBAL_DATA", globalDir)
47+
48+ t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
49+ parent := t.TempDir()
50+
51+ // crush.json above the project must not be adopted.
52+ require.NoError(t, os.WriteFile(
53+ filepath.Join(parent, "crush.json"),
54+ []byte(`{}`),
55+ 0o644,
56+ ))
57+
58+ project := filepath.Join(parent, "project")
59+ require.NoError(t, os.Mkdir(project, 0o755))
60+
61+ got := lookupConfigs(project)
62+ for _, p := range got {
63+ require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
64+ }
65+ })
66+
67+ t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
68+ if _, err := exec.LookPath("git"); err != nil {
69+ t.Skip("git not available")
70+ }
71+
72+ parent := t.TempDir()
73+
74+ require.NoError(t, os.WriteFile(
75+ filepath.Join(parent, "crush.json"),
76+ []byte(`{}`),
77+ 0o644,
78+ ))
79+
80+ worktree := filepath.Join(parent, "worktree")
81+ require.NoError(t, os.Mkdir(worktree, 0o755))
82+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
83+ gitInit.Dir = worktree
84+ require.NoError(t, gitInit.Run())
85+
86+ got := lookupConfigs(worktree)
87+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
88+ require.NoError(t, err)
89+ for _, p := range got {
90+ pEval, err := filepath.EvalSymlinks(p)
91+ if err != nil {
92+ continue
93+ }
94+ require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
95+ }
96+ })
97+
98+ t.Run("picks up crush.json inside the project", func(t *testing.T) {
99+ project := t.TempDir()
100+ local := filepath.Join(project, "crush.json")
101+ require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
102+
103+ got := lookupConfigs(project)
104+
105+ localEval, err := filepath.EvalSymlinks(local)
106+ require.NoError(t, err)
107+ var foundLocal bool
108+ for _, p := range got {
109+ pEval, err := filepath.EvalSymlinks(p)
110+ if err != nil {
111+ continue
112+ }
113+ if pEval == localEval {
114+ foundLocal = true
115+ break
116+ }
117+ }
118+ require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
119+ })
120+
121+ t.Run("global config is always included regardless of boundary", func(t *testing.T) {
122+ project := t.TempDir()
123+
124+ got := lookupConfigs(project)
125+ // Global config and global data path are always prepended,
126+ // even when no project file exists.
127+ require.Contains(t, got, GlobalConfig())
128+ require.Contains(t, got, GlobalConfigData())
129+ })
130+}
131+
39132func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
40133 t.Parallel()
41134
@@ -111,6 +204,98 @@ func TestConfig_setDefaults(t *testing.T) {
111204
112205 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
113206 })
207+
208+ t.Run("preserves absolute configured data directory", func(t *testing.T) {
209+ // Use a platform-appropriate absolute path so the test runs
210+ // the same way on POSIX and Windows.
211+ absDir := filepath.Join(t.TempDir(), "data")
212+ cfg := &Config{Options: &Options{DataDirectory: absDir}}
213+
214+ cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
215+
216+ require.Equal(t, absDir, cfg.Options.DataDirectory)
217+ })
218+
219+ t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
220+ // Simulate the load and reload paths: defaults are applied
221+ // twice with the data directory potentially carried through
222+ // from an earlier merge as a relative string.
223+ workingDir := filepath.Join(t.TempDir(), "worktree")
224+ cfg := &Config{}
225+ cfg.setDefaults(workingDir, "")
226+
227+ // Workspace JSON sets data_directory to a relative value; the
228+ // merge replaces the struct, then setDefaults runs again.
229+ cfg.Options.DataDirectory = "./state"
230+ cfg.setDefaults(workingDir, "")
231+
232+ require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
233+ "data directory must remain absolute after re-merge, got %q",
234+ cfg.Options.DataDirectory)
235+ require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
236+ })
237+
238+ t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
239+ parent := t.TempDir()
240+
241+ // .crush in the parent: it should not be reused by the child
242+ // because there is no git context joining them.
243+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
244+
245+ child := filepath.Join(parent, "child")
246+ require.NoError(t, os.Mkdir(child, 0o755))
247+
248+ cfg := &Config{}
249+ cfg.setDefaults(child, "")
250+
251+ require.Equal(t,
252+ filepath.Clean(filepath.Join(child, defaultDataDirectory)),
253+ filepath.Clean(cfg.Options.DataDirectory),
254+ )
255+ })
256+
257+ t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
258+ if _, err := exec.LookPath("git"); err != nil {
259+ t.Skip("git not available")
260+ }
261+
262+ parent := t.TempDir()
263+
264+ // Stray .crush above the worktree root.
265+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
266+
267+ worktree := filepath.Join(parent, "worktree")
268+ require.NoError(t, os.Mkdir(worktree, 0o755))
269+
270+ sub := filepath.Join(worktree, "pkg")
271+ require.NoError(t, os.Mkdir(sub, 0o755))
272+
273+ // Make worktree a real git repo so the boundary detection
274+ // resolves to it, mirroring what happens with linked worktrees
275+ // in real usage.
276+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
277+ gitInit.Dir = worktree
278+ require.NoError(t, gitInit.Run())
279+
280+ cfg := &Config{}
281+ cfg.setDefaults(sub, "")
282+
283+ // Resolve symlinks because TempDir on macOS sits under /var
284+ // which is a symlink to /private/var. The data directory has
285+ // not been created yet, so resolve its parent and join.
286+ gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
287+ gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
288+ require.NoError(t, err)
289+ gotEval := filepath.Join(gotEvalDir, gotName)
290+
291+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
292+ require.NoError(t, err)
293+ require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
294+
295+ subEval, err := filepath.EvalSymlinks(sub)
296+ require.NoError(t, err)
297+ require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
298+ })
114299}
115300
116301func TestConfig_configureProviders(t *testing.T) {