1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/require"
12)
13
14func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
15 t.Parallel()
16
17 store := &ConfigStore{
18 globalDataPath: "/some/global/crush.json",
19 }
20
21 path, err := store.configPath(ScopeGlobal)
22 require.NoError(t, err)
23 require.Equal(t, "/some/global/crush.json", path)
24}
25
26func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
27 t.Parallel()
28
29 store := &ConfigStore{
30 workspacePath: "/some/workspace/.crush/crush.json",
31 }
32
33 path, err := store.configPath(ScopeWorkspace)
34 require.NoError(t, err)
35 require.Equal(t, "/some/workspace/.crush/crush.json", path)
36}
37
38func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
39 t.Parallel()
40
41 store := &ConfigStore{
42 globalDataPath: "/some/global/crush.json",
43 workspacePath: "",
44 }
45
46 _, err := store.configPath(ScopeWorkspace)
47 require.Error(t, err)
48 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
49}
50
51func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
52 t.Parallel()
53
54 store := &ConfigStore{
55 config: &Config{},
56 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
57 workspacePath: "",
58 }
59
60 err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
61 require.Error(t, err)
62 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
63}
64
65func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
66 t.Parallel()
67
68 dir := t.TempDir()
69 globalPath := filepath.Join(dir, "crush.json")
70 store := &ConfigStore{
71 config: &Config{},
72 globalDataPath: globalPath,
73 }
74
75 err := store.SetConfigField(ScopeGlobal, "foo", "bar")
76 require.NoError(t, err)
77
78 data, err := os.ReadFile(globalPath)
79 require.NoError(t, err)
80 require.Contains(t, string(data), `"foo"`)
81}
82
83func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
84 t.Parallel()
85
86 store := &ConfigStore{
87 config: &Config{},
88 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
89 workspacePath: "",
90 }
91
92 err := store.RemoveConfigField(ScopeWorkspace, "foo")
93 require.Error(t, err)
94 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
95}
96
97func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
98 t.Parallel()
99
100 store := &ConfigStore{
101 config: &Config{},
102 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
103 workspacePath: "",
104 }
105
106 has := store.HasConfigField(ScopeWorkspace, "foo")
107 require.False(t, has)
108}
109
110func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
111 t.Parallel()
112
113 store1 := &ConfigStore{config: &Config{}}
114 store2 := &ConfigStore{config: &Config{}}
115
116 require.False(t, store1.Overrides().SkipPermissionRequests)
117 require.False(t, store2.Overrides().SkipPermissionRequests)
118
119 store1.Overrides().SkipPermissionRequests = true
120
121 require.True(t, store1.Overrides().SkipPermissionRequests)
122 require.False(t, store2.Overrides().SkipPermissionRequests)
123}
124
125func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
126 t.Parallel()
127
128 store := &ConfigStore{config: &Config{}}
129 overrides := store.Overrides()
130
131 require.False(t, overrides.SkipPermissionRequests)
132
133 overrides.SkipPermissionRequests = true
134 require.True(t, store.Overrides().SkipPermissionRequests)
135}
136
137func TestGlobalWorkspaceDir(t *testing.T) {
138 dir := t.TempDir()
139 t.Setenv("CRUSH_GLOBAL_DATA", dir)
140
141 wsDir := GlobalWorkspaceDir()
142 globalData := GlobalConfigData()
143
144 require.Equal(t, filepath.Dir(globalData), wsDir)
145 require.Equal(t, dir, wsDir)
146}
147
148func TestScope_String(t *testing.T) {
149 t.Parallel()
150
151 require.Equal(t, "global", ScopeGlobal.String())
152 require.Equal(t, "workspace", ScopeWorkspace.String())
153 require.Contains(t, Scope(99).String(), "Scope(99)")
154}
155
156func TestConfigStaleness_CleanImmediatelyAfterSnapshot(t *testing.T) {
157 t.Parallel()
158
159 dir := t.TempDir()
160 configPath := filepath.Join(dir, "crush.json")
161
162 // Create a config file
163 content := []byte(`{"options": {"debug": true}}`)
164 require.NoError(t, os.WriteFile(configPath, content, 0o600))
165
166 store := &ConfigStore{
167 config: &Config{},
168 globalDataPath: configPath,
169 }
170 store.captureStalenessSnapshot([]string{configPath})
171
172 result := store.ConfigStaleness()
173 require.False(t, result.Dirty)
174 require.Empty(t, result.Changed)
175 require.Empty(t, result.Missing)
176}
177
178func TestConfigStaleness_DetectsFileContentChange(t *testing.T) {
179 t.Parallel()
180
181 dir := t.TempDir()
182 configPath := filepath.Join(dir, "crush.json")
183
184 // Create initial config file
185 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
186
187 store := &ConfigStore{
188 config: &Config{},
189 globalDataPath: configPath,
190 }
191 store.captureStalenessSnapshot([]string{configPath})
192
193 // Modify the file
194 time.Sleep(10 * time.Millisecond) // Ensure different mtime
195 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
196
197 result := store.ConfigStaleness()
198 require.True(t, result.Dirty)
199 require.Contains(t, result.Changed, configPath)
200 require.Empty(t, result.Missing)
201}
202
203func TestConfigStaleness_DetectsFileDeletion(t *testing.T) {
204 t.Parallel()
205
206 dir := t.TempDir()
207 configPath := filepath.Join(dir, "crush.json")
208
209 // Create initial config file
210 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
211
212 store := &ConfigStore{
213 config: &Config{},
214 globalDataPath: configPath,
215 }
216 store.captureStalenessSnapshot([]string{configPath})
217
218 // Delete the file
219 require.NoError(t, os.Remove(configPath))
220
221 result := store.ConfigStaleness()
222 require.True(t, result.Dirty)
223 require.Empty(t, result.Changed)
224 require.Contains(t, result.Missing, configPath)
225}
226
227func TestConfigStaleness_DetectsNewFile(t *testing.T) {
228 t.Parallel()
229
230 dir := t.TempDir()
231 configPath := filepath.Join(dir, "crush.json")
232
233 // Don't create file initially
234 store := &ConfigStore{
235 config: &Config{},
236 globalDataPath: configPath,
237 }
238 store.captureStalenessSnapshot([]string{configPath})
239
240 // Now create the file
241 time.Sleep(10 * time.Millisecond)
242 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
243
244 result := store.ConfigStaleness()
245 require.True(t, result.Dirty)
246 require.Contains(t, result.Changed, configPath)
247 require.Empty(t, result.Missing)
248}
249
250func TestConfigStaleness_SortedOutput(t *testing.T) {
251 t.Parallel()
252
253 dir := t.TempDir()
254 pathA := filepath.Join(dir, "a.json")
255 pathB := filepath.Join(dir, "b.json")
256 pathC := filepath.Join(dir, "c.json")
257
258 // Create all files
259 for _, p := range []string{pathA, pathB, pathC} {
260 require.NoError(t, os.WriteFile(p, []byte(`{}`), 0o600))
261 }
262
263 store := &ConfigStore{
264 config: &Config{},
265 globalDataPath: pathA,
266 }
267 // Add in reverse order to test sorting
268 store.captureStalenessSnapshot([]string{pathC, pathA, pathB})
269
270 // Modify all files
271 time.Sleep(10 * time.Millisecond)
272 for _, p := range []string{pathA, pathB, pathC} {
273 require.NoError(t, os.WriteFile(p, []byte(`{"changed": true}`), 0o600))
274 }
275
276 result := store.ConfigStaleness()
277 require.True(t, result.Dirty)
278 // Should be sorted alphabetically
279 require.Equal(t, []string{pathA, pathB, pathC}, result.Changed)
280}
281
282func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
283 t.Parallel()
284
285 dir := t.TempDir()
286 configPath := filepath.Join(dir, "crush.json")
287
288 // Create initial config file
289 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
290
291 store := &ConfigStore{
292 config: &Config{},
293 globalDataPath: configPath,
294 }
295 store.captureStalenessSnapshot([]string{configPath})
296
297 // Modify the file
298 time.Sleep(10 * time.Millisecond)
299 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
300
301 // Verify dirty
302 result := store.ConfigStaleness()
303 require.True(t, result.Dirty)
304
305 // Refresh snapshot
306 require.NoError(t, store.RefreshStalenessSnapshot())
307
308 // Verify clean now
309 result = store.ConfigStaleness()
310 require.False(t, result.Dirty)
311 require.Empty(t, result.Changed)
312 require.Empty(t, result.Missing)
313}
314
315// TestReloadFromDisk_UsesNewConfigValues is a regression test ensuring that
316// ReloadFromDisk updates store state BEFORE running model/agent setup,
317// so the new config values are used rather than stale pre-reload values.
318func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
319 t.Parallel()
320
321 dir := t.TempDir()
322 configPath := filepath.Join(dir, "crush.json")
323
324 // Create initial config with one model preference
325 initialConfig := `{
326 "models": {
327 "large": {"provider": "openai", "model": "gpt-4"}
328 },
329 "providers": {
330 "openai": {
331 "api_key": "test-key",
332 "models": [{"id": "gpt-4", "name": "GPT-4"}]
333 }
334 }
335 }`
336 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
337
338 // Load initial config properly
339 store, err := Load(dir, dir, false)
340 require.NoError(t, err)
341
342 // Set globalDataPath for the test (Load doesn't set this directly)
343 store.globalDataPath = configPath
344 store.CaptureStalenessSnapshot([]string{configPath})
345
346 // Verify initial model
347 require.Equal(t, "openai", store.config.Models[SelectedModelTypeLarge].Provider)
348 require.Equal(t, "gpt-4", store.config.Models[SelectedModelTypeLarge].Model)
349
350 // Modify config on disk to change model
351 updatedConfig := `{
352 "models": {
353 "large": {"provider": "anthropic", "model": "claude-3"}
354 },
355 "providers": {
356 "openai": {
357 "api_key": "test-key",
358 "models": [{"id": "gpt-4", "name": "GPT-4"}]
359 },
360 "anthropic": {
361 "api_key": "test-key-2",
362 "models": [{"id": "claude-3", "name": "Claude 3"}]
363 }
364 }
365 }`
366 time.Sleep(10 * time.Millisecond)
367 require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0o600))
368
369 // Reload from disk
370 ctx := context.Background()
371 err = store.ReloadFromDisk(ctx)
372 require.NoError(t, err)
373
374 // Verify the NEW config values are now in effect (regression check)
375 require.Equal(t, "anthropic", store.config.Models[SelectedModelTypeLarge].Provider)
376 require.Equal(t, "claude-3", store.config.Models[SelectedModelTypeLarge].Model)
377}
378
379// TestSetConfigField_AutoReloads verifies that SetConfigField automatically
380// reloads config into memory after writing, so subsequent reads see the new value.
381func TestSetConfigField_AutoReloads(t *testing.T) {
382 t.Parallel()
383
384 dir := t.TempDir()
385 configPath := filepath.Join(dir, "crush.json")
386
387 // Create initial config file with debug = false
388 initialConfig := `{"options": {"debug": false}}`
389 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
390
391 // Load initial config
392 store, err := Load(dir, dir, false)
393 require.NoError(t, err)
394
395 // Verify initial state
396 require.False(t, store.config.Options.Debug)
397
398 // Set globalDataPath and capture snapshot for staleness tracking
399 store.globalDataPath = configPath
400 store.CaptureStalenessSnapshot([]string{configPath})
401
402 // Use SetConfigField to change debug to true
403 err = store.SetConfigField(ScopeGlobal, "options.debug", true)
404 require.NoError(t, err)
405
406 // Verify in-memory state was automatically reloaded and reflects the change
407 require.True(t, store.config.Options.Debug, "Expected config to auto-reload and show debug = true")
408
409 // Verify staleness is clean after the reload
410 staleness := store.ConfigStaleness()
411 require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload")
412}
413
414// TestRemoveConfigField_AutoReloads verifies that RemoveConfigField automatically
415// reloads config into memory after writing.
416func TestRemoveConfigField_AutoReloads(t *testing.T) {
417 t.Parallel()
418
419 dir := t.TempDir()
420 configPath := filepath.Join(dir, "crush.json")
421
422 // Create initial config file with a custom option
423 initialConfig := `{"options": {"debug": true, "custom_field": "value"}}`
424 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
425
426 // Load initial config
427 store, err := Load(dir, dir, false)
428 require.NoError(t, err)
429
430 // Set globalDataPath and capture snapshot
431 store.globalDataPath = configPath
432 store.CaptureStalenessSnapshot([]string{configPath})
433
434 // Verify the field exists initially (indirectly - store loaded successfully)
435 require.True(t, store.config.Options.Debug)
436
437 // Remove the debug field
438 err = store.RemoveConfigField(ScopeGlobal, "options.debug")
439 require.NoError(t, err)
440
441 // Verify auto-reload occurred and stale state is clean
442 staleness := store.ConfigStaleness()
443 require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload from RemoveConfigField")
444}
445
446// TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir verifies that auto-reload
447// gracefully skips when working directory is not set (e.g., during testing).
448func TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir(t *testing.T) {
449 t.Parallel()
450
451 dir := t.TempDir()
452 configPath := filepath.Join(dir, "crush.json")
453
454 // Create a store without working directory (like some test setups)
455 store := &ConfigStore{
456 config: &Config{},
457 globalDataPath: configPath,
458 // workingDir is empty
459 }
460
461 // SetConfigField should succeed even without workingDir (auto-reload skips)
462 err := store.SetConfigField(ScopeGlobal, "foo", "bar")
463 require.NoError(t, err)
464
465 // Verify file was still written
466 data, err := os.ReadFile(configPath)
467 require.NoError(t, err)
468 require.Contains(t, string(data), "foo")
469}
470
471// TestAutoReloadDisabledDuringReload verifies that auto-reload is suppressed
472// during ReloadFromDisk to prevent re-entrant/nested reload calls.
473func TestAutoReloadDisabledDuringReload(t *testing.T) {
474 t.Parallel()
475
476 dir := t.TempDir()
477 configPath := filepath.Join(dir, "crush.json")
478
479 // Create initial config with a provider that will trigger config modification during reload
480 // (simulating the anthropic OAuth token removal case)
481 initialConfig := `{
482 "providers": {
483 "anthropic": {
484 "api_key": "test-key",
485 "oauth": {"access_token": "token", "refresh_token": "refresh"}
486 }
487 }
488 }`
489 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
490
491 // Load will trigger configureProviders which removes anthropic OAuth config
492 // This should NOT cause infinite recursion thanks to autoReloadDisabled guard
493 store, err := Load(dir, dir, false)
494 require.NoError(t, err)
495
496 // Verify the store loaded successfully and autoReloadDisabled was unset
497 require.False(t, store.autoReloadDisabled)
498
499 // Capture snapshot and verify reload also works without recursion
500 store.globalDataPath = configPath
501 store.CaptureStalenessSnapshot([]string{configPath})
502
503 // Modify file and reload - this should work without re-entrancy issues
504 time.Sleep(10 * time.Millisecond)
505 require.NoError(t, os.WriteFile(configPath, []byte(`{"options": {"debug": true}}`), 0o600))
506
507 err = store.ReloadFromDisk(context.Background())
508 require.NoError(t, err)
509
510 // Verify reload completed successfully
511 require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
512}