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}