store_test.go

  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}