store_test.go

  1package config
  2
  3import (
  4	"context"
  5	"errors"
  6	"os"
  7	"path/filepath"
  8	"testing"
  9	"time"
 10
 11	"github.com/charmbracelet/crush/internal/csync"
 12	"github.com/charmbracelet/crush/internal/oauth"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
 17	t.Parallel()
 18
 19	store := &ConfigStore{
 20		globalDataPath: "/some/global/crush.json",
 21	}
 22
 23	path, err := store.configPath(ScopeGlobal)
 24	require.NoError(t, err)
 25	require.Equal(t, "/some/global/crush.json", path)
 26}
 27
 28func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
 29	t.Parallel()
 30
 31	store := &ConfigStore{
 32		workspacePath: "/some/workspace/.crush/crush.json",
 33	}
 34
 35	path, err := store.configPath(ScopeWorkspace)
 36	require.NoError(t, err)
 37	require.Equal(t, "/some/workspace/.crush/crush.json", path)
 38}
 39
 40func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
 41	t.Parallel()
 42
 43	store := &ConfigStore{
 44		globalDataPath: "/some/global/crush.json",
 45		workspacePath:  "",
 46	}
 47
 48	_, err := store.configPath(ScopeWorkspace)
 49	require.Error(t, err)
 50	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
 51}
 52
 53func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
 54	t.Parallel()
 55
 56	store := &ConfigStore{
 57		config:         &Config{},
 58		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
 59		workspacePath:  "",
 60	}
 61
 62	err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
 63	require.Error(t, err)
 64	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
 65}
 66
 67func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
 68	t.Parallel()
 69
 70	dir := t.TempDir()
 71	globalPath := filepath.Join(dir, "crush.json")
 72	store := &ConfigStore{
 73		config:         &Config{},
 74		globalDataPath: globalPath,
 75	}
 76
 77	err := store.SetConfigField(ScopeGlobal, "foo", "bar")
 78	require.NoError(t, err)
 79
 80	data, err := os.ReadFile(globalPath)
 81	require.NoError(t, err)
 82	require.Contains(t, string(data), `"foo"`)
 83}
 84
 85func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
 86	t.Parallel()
 87
 88	store := &ConfigStore{
 89		config:         &Config{},
 90		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
 91		workspacePath:  "",
 92	}
 93
 94	err := store.RemoveConfigField(ScopeWorkspace, "foo")
 95	require.Error(t, err)
 96	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
 97}
 98
 99func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
100	t.Parallel()
101
102	store := &ConfigStore{
103		config:         &Config{},
104		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
105		workspacePath:  "",
106	}
107
108	has := store.HasConfigField(ScopeWorkspace, "foo")
109	require.False(t, has)
110}
111
112func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
113	t.Parallel()
114
115	store1 := &ConfigStore{config: &Config{}}
116	store2 := &ConfigStore{config: &Config{}}
117
118	require.False(t, store1.Overrides().SkipPermissionRequests)
119	require.False(t, store2.Overrides().SkipPermissionRequests)
120
121	store1.Overrides().SkipPermissionRequests = true
122
123	require.True(t, store1.Overrides().SkipPermissionRequests)
124	require.False(t, store2.Overrides().SkipPermissionRequests)
125}
126
127func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
128	t.Parallel()
129
130	store := &ConfigStore{config: &Config{}}
131	overrides := store.Overrides()
132
133	require.False(t, overrides.SkipPermissionRequests)
134
135	overrides.SkipPermissionRequests = true
136	require.True(t, store.Overrides().SkipPermissionRequests)
137}
138
139func TestGlobalWorkspaceDir(t *testing.T) {
140	dir := t.TempDir()
141	t.Setenv("CRUSH_GLOBAL_DATA", dir)
142
143	wsDir := GlobalWorkspaceDir()
144	globalData := GlobalConfigData()
145
146	require.Equal(t, filepath.Dir(globalData), wsDir)
147	require.Equal(t, dir, wsDir)
148}
149
150func TestScope_String(t *testing.T) {
151	t.Parallel()
152
153	require.Equal(t, "global", ScopeGlobal.String())
154	require.Equal(t, "workspace", ScopeWorkspace.String())
155	require.Contains(t, Scope(99).String(), "Scope(99)")
156}
157
158func TestConfigStaleness_CleanImmediatelyAfterSnapshot(t *testing.T) {
159	t.Parallel()
160
161	dir := t.TempDir()
162	configPath := filepath.Join(dir, "crush.json")
163
164	// Create a config file
165	content := []byte(`{"options": {"debug": true}}`)
166	require.NoError(t, os.WriteFile(configPath, content, 0o600))
167
168	store := &ConfigStore{
169		config:         &Config{},
170		globalDataPath: configPath,
171	}
172	store.captureStalenessSnapshot([]string{configPath})
173
174	result := store.ConfigStaleness()
175	require.False(t, result.Dirty)
176	require.Empty(t, result.Changed)
177	require.Empty(t, result.Missing)
178}
179
180func TestConfigStaleness_DetectsFileContentChange(t *testing.T) {
181	t.Parallel()
182
183	dir := t.TempDir()
184	configPath := filepath.Join(dir, "crush.json")
185
186	// Create initial config file
187	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
188
189	store := &ConfigStore{
190		config:         &Config{},
191		globalDataPath: configPath,
192	}
193	store.captureStalenessSnapshot([]string{configPath})
194
195	// Modify the file
196	time.Sleep(10 * time.Millisecond) // Ensure different mtime
197	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
198
199	result := store.ConfigStaleness()
200	require.True(t, result.Dirty)
201	require.Contains(t, result.Changed, configPath)
202	require.Empty(t, result.Missing)
203}
204
205func TestConfigStaleness_DetectsFileDeletion(t *testing.T) {
206	t.Parallel()
207
208	dir := t.TempDir()
209	configPath := filepath.Join(dir, "crush.json")
210
211	// Create initial config file
212	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
213
214	store := &ConfigStore{
215		config:         &Config{},
216		globalDataPath: configPath,
217	}
218	store.captureStalenessSnapshot([]string{configPath})
219
220	// Delete the file
221	require.NoError(t, os.Remove(configPath))
222
223	result := store.ConfigStaleness()
224	require.True(t, result.Dirty)
225	require.Empty(t, result.Changed)
226	require.Contains(t, result.Missing, configPath)
227}
228
229func TestConfigStaleness_DetectsNewFile(t *testing.T) {
230	t.Parallel()
231
232	dir := t.TempDir()
233	configPath := filepath.Join(dir, "crush.json")
234
235	// Don't create file initially
236	store := &ConfigStore{
237		config:         &Config{},
238		globalDataPath: configPath,
239	}
240	store.captureStalenessSnapshot([]string{configPath})
241
242	// Now create the file
243	time.Sleep(10 * time.Millisecond)
244	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
245
246	result := store.ConfigStaleness()
247	require.True(t, result.Dirty)
248	require.Contains(t, result.Changed, configPath)
249	require.Empty(t, result.Missing)
250}
251
252func TestConfigStaleness_SortedOutput(t *testing.T) {
253	t.Parallel()
254
255	dir := t.TempDir()
256	pathA := filepath.Join(dir, "a.json")
257	pathB := filepath.Join(dir, "b.json")
258	pathC := filepath.Join(dir, "c.json")
259
260	// Create all files
261	for _, p := range []string{pathA, pathB, pathC} {
262		require.NoError(t, os.WriteFile(p, []byte(`{}`), 0o600))
263	}
264
265	store := &ConfigStore{
266		config:         &Config{},
267		globalDataPath: pathA,
268	}
269	// Add in reverse order to test sorting
270	store.captureStalenessSnapshot([]string{pathC, pathA, pathB})
271
272	// Modify all files
273	time.Sleep(10 * time.Millisecond)
274	for _, p := range []string{pathA, pathB, pathC} {
275		require.NoError(t, os.WriteFile(p, []byte(`{"changed": true}`), 0o600))
276	}
277
278	result := store.ConfigStaleness()
279	require.True(t, result.Dirty)
280	// Should be sorted alphabetically
281	require.Equal(t, []string{pathA, pathB, pathC}, result.Changed)
282}
283
284func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
285	t.Parallel()
286
287	dir := t.TempDir()
288	configPath := filepath.Join(dir, "crush.json")
289
290	// Create initial config file
291	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
292
293	store := &ConfigStore{
294		config:         &Config{},
295		globalDataPath: configPath,
296	}
297	store.captureStalenessSnapshot([]string{configPath})
298
299	// Modify the file
300	time.Sleep(10 * time.Millisecond)
301	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
302
303	// Verify dirty
304	result := store.ConfigStaleness()
305	require.True(t, result.Dirty)
306
307	// Refresh snapshot
308	require.NoError(t, store.RefreshStalenessSnapshot())
309
310	// Verify clean now
311	result = store.ConfigStaleness()
312	require.False(t, result.Dirty)
313	require.Empty(t, result.Changed)
314	require.Empty(t, result.Missing)
315}
316
317// TestReloadFromDisk_UsesNewConfigValues is a regression test ensuring that
318// ReloadFromDisk updates store state BEFORE running model/agent setup,
319// so the new config values are used rather than stale pre-reload values.
320func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
321	t.Parallel()
322
323	dir := t.TempDir()
324	configPath := filepath.Join(dir, "crush.json")
325
326	// Create initial config with one model preference
327	initialConfig := `{
328		"models": {
329			"large": {"provider": "openai", "model": "gpt-4"}
330		},
331		"providers": {
332			"openai": {
333				"api_key": "test-key",
334				"models": [{"id": "gpt-4", "name": "GPT-4"}]
335			}
336		}
337	}`
338	require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
339
340	// Load initial config properly
341	store, err := Load(dir, dir, false)
342	require.NoError(t, err)
343
344	// Set globalDataPath for the test (Load doesn't set this directly)
345	store.globalDataPath = configPath
346	store.CaptureStalenessSnapshot([]string{configPath})
347
348	// Verify initial model
349	require.Equal(t, "openai", store.config.Models[SelectedModelTypeLarge].Provider)
350	require.Equal(t, "gpt-4", store.config.Models[SelectedModelTypeLarge].Model)
351
352	// Modify config on disk to change model
353	updatedConfig := `{
354		"models": {
355			"large": {"provider": "anthropic", "model": "claude-3"}
356		},
357		"providers": {
358			"openai": {
359				"api_key": "test-key",
360				"models": [{"id": "gpt-4", "name": "GPT-4"}]
361			},
362			"anthropic": {
363				"api_key": "test-key-2",
364				"models": [{"id": "claude-3", "name": "Claude 3"}]
365			}
366		}
367	}`
368	time.Sleep(10 * time.Millisecond)
369	require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0o600))
370
371	// Reload from disk
372	ctx := context.Background()
373	err = store.ReloadFromDisk(ctx)
374	require.NoError(t, err)
375
376	// Verify the NEW config values are now in effect (regression check)
377	require.Equal(t, "anthropic", store.config.Models[SelectedModelTypeLarge].Provider)
378	require.Equal(t, "claude-3", store.config.Models[SelectedModelTypeLarge].Model)
379}
380
381// TestSetConfigField_AutoReloads verifies that SetConfigField automatically
382// reloads config into memory after writing, so subsequent reads see the new value.
383func TestSetConfigField_AutoReloads(t *testing.T) {
384	t.Parallel()
385
386	dir := t.TempDir()
387	configPath := filepath.Join(dir, "crush.json")
388
389	// Create initial config file with debug = false
390	initialConfig := `{"options": {"debug": false}}`
391	require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
392
393	// Load initial config
394	store, err := Load(dir, dir, false)
395	require.NoError(t, err)
396
397	// Verify initial state
398	require.False(t, store.config.Options.Debug)
399
400	// Set globalDataPath and capture snapshot for staleness tracking
401	store.globalDataPath = configPath
402	store.CaptureStalenessSnapshot([]string{configPath})
403
404	// Use SetConfigField to change debug to true
405	err = store.SetConfigField(ScopeGlobal, "options.debug", true)
406	require.NoError(t, err)
407
408	// Verify in-memory state was automatically reloaded and reflects the change
409	require.True(t, store.config.Options.Debug, "Expected config to auto-reload and show debug = true")
410
411	// Verify staleness is clean after the reload
412	staleness := store.ConfigStaleness()
413	require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload")
414}
415
416// TestRemoveConfigField_AutoReloads verifies that RemoveConfigField automatically
417// reloads config into memory after writing.
418func TestRemoveConfigField_AutoReloads(t *testing.T) {
419	t.Parallel()
420
421	dir := t.TempDir()
422	configPath := filepath.Join(dir, "crush.json")
423
424	// Create initial config file with a custom option
425	initialConfig := `{"options": {"debug": true, "custom_field": "value"}}`
426	require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
427
428	// Load initial config
429	store, err := Load(dir, dir, false)
430	require.NoError(t, err)
431
432	// Set globalDataPath and capture snapshot
433	store.globalDataPath = configPath
434	store.CaptureStalenessSnapshot([]string{configPath})
435
436	// Verify the field exists initially (indirectly - store loaded successfully)
437	require.True(t, store.config.Options.Debug)
438
439	// Remove the debug field
440	err = store.RemoveConfigField(ScopeGlobal, "options.debug")
441	require.NoError(t, err)
442
443	// Verify auto-reload occurred and stale state is clean
444	staleness := store.ConfigStaleness()
445	require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload from RemoveConfigField")
446}
447
448// TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir verifies that auto-reload
449// gracefully skips when working directory is not set (e.g., during testing).
450func TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir(t *testing.T) {
451	t.Parallel()
452
453	dir := t.TempDir()
454	configPath := filepath.Join(dir, "crush.json")
455
456	// Create a store without working directory (like some test setups)
457	store := &ConfigStore{
458		config:         &Config{},
459		globalDataPath: configPath,
460		// workingDir is empty
461	}
462
463	// SetConfigField should succeed even without workingDir (auto-reload skips)
464	err := store.SetConfigField(ScopeGlobal, "foo", "bar")
465	require.NoError(t, err)
466
467	// Verify file was still written
468	data, err := os.ReadFile(configPath)
469	require.NoError(t, err)
470	require.Contains(t, string(data), "foo")
471}
472
473// TestAutoReloadDisabledDuringReload verifies that auto-reload is suppressed
474// during ReloadFromDisk to prevent re-entrant/nested reload calls.
475func TestAutoReloadDisabledDuringReload(t *testing.T) {
476	t.Parallel()
477
478	dir := t.TempDir()
479	configPath := filepath.Join(dir, "crush.json")
480
481	// Create initial config with a provider that will trigger config modification during reload
482	// (simulating the anthropic OAuth token removal case)
483	initialConfig := `{
484		"providers": {
485			"anthropic": {
486				"api_key": "test-key",
487				"oauth": {"access_token": "token", "refresh_token": "refresh"}
488			}
489		}
490	}`
491	require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
492
493	// Load will trigger configureProviders which removes anthropic OAuth config
494	// This should NOT cause infinite recursion thanks to autoReloadDisabled guard
495	store, err := Load(dir, dir, false)
496	require.NoError(t, err)
497
498	// Verify the store loaded successfully and autoReloadDisabled was unset
499	require.False(t, store.autoReloadDisabled)
500
501	// Capture snapshot and verify reload also works without recursion
502	store.globalDataPath = configPath
503	store.CaptureStalenessSnapshot([]string{configPath})
504
505	// Modify file and reload - this should work without re-entrancy issues
506	time.Sleep(10 * time.Millisecond)
507	require.NoError(t, os.WriteFile(configPath, []byte(`{"options": {"debug": true}}`), 0o600))
508
509	err = store.ReloadFromDisk(context.Background())
510	require.NoError(t, err)
511
512	// Verify reload completed successfully
513	require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
514}
515
516func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
517	t.Parallel()
518
519	dir := t.TempDir()
520	configPath := filepath.Join(dir, "crush.json")
521
522	// Create config file with a newer token on disk
523	configContent := `{
524		"providers": {
525			"hyper": {
526				"oauth": {
527					"access_token": "newer-token-from-disk",
528					"refresh_token": "refresh-abc",
529					"expires_in": 3600,
530					"expires_at": 9999999999
531				}
532			}
533		}
534	}`
535	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
536
537	store := &ConfigStore{
538		config:         &Config{},
539		globalDataPath: configPath,
540	}
541
542	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
543	require.NoError(t, err)
544	require.NotNil(t, token)
545	require.Equal(t, "newer-token-from-disk", token.AccessToken)
546	require.Equal(t, "refresh-abc", token.RefreshToken)
547	require.Equal(t, 3600, token.ExpiresIn)
548	require.Equal(t, int64(9999999999), token.ExpiresAt)
549}
550
551func TestLoadTokenFromDisk_ReturnsNilWhenSameToken(t *testing.T) {
552	t.Parallel()
553
554	dir := t.TempDir()
555	configPath := filepath.Join(dir, "crush.json")
556
557	// Create config file with the same token
558	configContent := `{
559		"providers": {
560			"hyper": {
561				"oauth": {
562					"access_token": "same-token",
563					"refresh_token": "refresh-abc",
564					"expires_in": 3600,
565					"expires_at": 9999999999
566				}
567			}
568		}
569	}`
570	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
571
572	store := &ConfigStore{
573		config:         &Config{},
574		globalDataPath: configPath,
575	}
576
577	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
578	require.NoError(t, err)
579	require.NotNil(t, token)
580	require.Equal(t, "same-token", token.AccessToken)
581}
582
583func TestLoadTokenFromDisk_ReturnsNilWhenFileMissing(t *testing.T) {
584	t.Parallel()
585
586	dir := t.TempDir()
587	configPath := filepath.Join(dir, "nonexistent.json")
588
589	store := &ConfigStore{
590		config:         &Config{},
591		globalDataPath: configPath,
592	}
593
594	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
595	require.NoError(t, err)
596	require.Nil(t, token)
597}
598
599func TestLoadTokenFromDisk_ReturnsNilWhenProviderMissing(t *testing.T) {
600	t.Parallel()
601
602	dir := t.TempDir()
603	configPath := filepath.Join(dir, "crush.json")
604
605	// Create config file without the hyper provider
606	configContent := `{"providers": {"openai": {"api_key": "test-key"}}}`
607	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
608
609	store := &ConfigStore{
610		config:         &Config{},
611		globalDataPath: configPath,
612	}
613
614	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
615	require.NoError(t, err)
616	require.Nil(t, token)
617}
618
619func TestLoadTokenFromDisk_ReturnsNilWhenOAuthMissing(t *testing.T) {
620	t.Parallel()
621
622	dir := t.TempDir()
623	configPath := filepath.Join(dir, "crush.json")
624
625	// Create config file with provider but no OAuth token
626	configContent := `{"providers": {"hyper": {"api_key": "test-key"}}}`
627	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
628
629	store := &ConfigStore{
630		config:         &Config{},
631		globalDataPath: configPath,
632	}
633
634	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
635	require.NoError(t, err)
636	require.Nil(t, token)
637}
638
639func TestRefreshOAuthToken_UsesDiskTokenWhenDifferent(t *testing.T) {
640	t.Parallel()
641
642	dir := t.TempDir()
643	configPath := filepath.Join(dir, "crush.json")
644
645	// Create config file with a newer token on disk
646	configContent := `{
647		"providers": {
648			"hyper": {
649				"api_key": "newer-access-token",
650				"oauth": {
651					"access_token": "newer-access-token",
652					"refresh_token": "refresh-abc",
653					"expires_in": 3600,
654					"expires_at": 9999999999
655				}
656			}
657		}
658	}`
659	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
660
661	// Set up store with an older in-memory token
662	oldToken := &oauth.Token{
663		AccessToken:  "older-access-token",
664		RefreshToken: "refresh-abc",
665		ExpiresIn:    3600,
666		ExpiresAt:    time.Now().Add(-time.Hour).Unix(), // Expired
667	}
668
669	providers := csync.NewMap[string, ProviderConfig]()
670	providers.Set("hyper", ProviderConfig{
671		ID:         "hyper",
672		Name:       "Hyper",
673		APIKey:     oldToken.AccessToken,
674		OAuthToken: oldToken,
675	})
676
677	store := &ConfigStore{
678		config: &Config{
679			Providers: providers,
680		},
681		globalDataPath: configPath,
682	}
683
684	// Refresh should use the disk token without making an external call
685	err := store.RefreshOAuthToken(context.Background(), ScopeGlobal, "hyper")
686	require.NoError(t, err)
687
688	// Verify the in-memory token was updated to the disk token
689	updatedConfig, ok := store.config.Providers.Get("hyper")
690	require.True(t, ok)
691	require.Equal(t, "newer-access-token", updatedConfig.APIKey)
692	require.Equal(t, "newer-access-token", updatedConfig.OAuthToken.AccessToken)
693	require.Equal(t, "refresh-abc", updatedConfig.OAuthToken.RefreshToken)
694}