@@ -4,6 +4,7 @@ import (
7 "path/filepath"
8 "path/filepath"
@@ -36,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) {
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
37 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
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)
48+ t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
49+ parent := t.TempDir()
51+ // crush.json above the project must not be adopted.
52+ require.NoError(t, os.WriteFile(
53+ filepath.Join(parent, "crush.json"),
58+ project := filepath.Join(parent, "project")
59+ require.NoError(t, os.Mkdir(project, 0o755))
61+ got := lookupConfigs(project)
62+ for _, p := range got {
63+ require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
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")
72+ parent := t.TempDir()
74+ require.NoError(t, os.WriteFile(
75+ filepath.Join(parent, "crush.json"),
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())
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)
94+ require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
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))
103+ got := lookupConfigs(project)
105+ localEval, err := filepath.EvalSymlinks(local)
106+ require.NoError(t, err)
108+ for _, p := range got {
109+ pEval, err := filepath.EvalSymlinks(p)
113+ if pEval == localEval {
118+ require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
121+ t.Run("global config is always included regardless of boundary", func(t *testing.T) {
122+ project := t.TempDir()
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())
39func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
132func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
40 t.Parallel()
133 t.Parallel()
@@ -111,6 +204,98 @@ func TestConfig_setDefaults(t *testing.T) {
112 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
205 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
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}}
214+ cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
216+ require.Equal(t, absDir, cfg.Options.DataDirectory)
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")
225+ cfg.setDefaults(workingDir, "")
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, "")
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)
238+ t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
239+ parent := t.TempDir()
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))
245+ child := filepath.Join(parent, "child")
246+ require.NoError(t, os.Mkdir(child, 0o755))
249+ cfg.setDefaults(child, "")
252+ filepath.Clean(filepath.Join(child, defaultDataDirectory)),
253+ filepath.Clean(cfg.Options.DataDirectory),
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")
262+ parent := t.TempDir()
264+ // Stray .crush above the worktree root.
265+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
267+ worktree := filepath.Join(parent, "worktree")
268+ require.NoError(t, os.Mkdir(worktree, 0o755))
270+ sub := filepath.Join(worktree, "pkg")
271+ require.NoError(t, os.Mkdir(sub, 0o755))
273+ // Make worktree a real git repo so the boundary detection
274+ // resolves to it, mirroring what happens with linked worktrees
276+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
277+ gitInit.Dir = worktree
278+ require.NoError(t, gitInit.Run())
281+ cfg.setDefaults(sub, "")
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)
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")
295+ subEval, err := filepath.EvalSymlinks(sub)
296+ require.NoError(t, err)
297+ require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
116func TestConfig_configureProviders(t *testing.T) {
301func TestConfig_configureProviders(t *testing.T) {