store_test.go

  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}