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