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 thanks to autoReloadDisabled guard
502	store, err := Load(dir, dir, false)
503	require.NoError(t, err)
504
505	// Verify the store loaded successfully and autoReloadDisabled was unset
506	require.False(t, store.autoReloadDisabled)
507
508	// Capture snapshot and verify reload also works without recursion
509	store.globalDataPath = configPath
510	store.CaptureStalenessSnapshot([]string{configPath})
511
512	// Modify file and reload - this should work without re-entrancy issues
513	time.Sleep(10 * time.Millisecond)
514	require.NoError(t, os.WriteFile(configPath, []byte(`{"options": {"debug": true}}`), 0o600))
515
516	err = store.ReloadFromDisk(context.Background())
517	require.NoError(t, err)
518
519	// Verify reload completed successfully
520	require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
521}
522
523// TestSetConfigFields_AutoReloadsAtomically verifies that SetConfigFields writes
524// multiple fields in a single disk write and triggers only one auto-reload,
525// avoiding intermediate states where only some fields are persisted.
526func TestSetConfigFields_AutoReloadsAtomically(t *testing.T) {
527	t.Parallel()
528
529	dir := t.TempDir()
530	configPath := filepath.Join(dir, "crush.json")
531
532	// Create initial config file.
533	initialConfig := `{"options": {"debug": false}}`
534	require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
535
536	// Load initial config.
537	store, err := Load(dir, dir, false)
538	require.NoError(t, err)
539
540	// Set globalDataPath and capture snapshot.
541	store.globalDataPath = configPath
542	store.CaptureStalenessSnapshot([]string{configPath})
543
544	// Write multiple fields atomically.
545	err = store.SetConfigFields(ScopeGlobal, map[string]any{
546		"options.debug":  true,
547		"options.custom": "hello",
548	})
549	require.NoError(t, err)
550
551	// Verify both fields are reflected in memory.
552	require.True(t, store.config.Options.Debug)
553}
554
555func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
556	t.Parallel()
557
558	dir := t.TempDir()
559	configPath := filepath.Join(dir, "crush.json")
560
561	// Create config file with a newer token on disk
562	configContent := `{
563		"providers": {
564			"hyper": {
565				"oauth": {
566					"access_token": "newer-token-from-disk",
567					"refresh_token": "refresh-abc",
568					"expires_in": 3600,
569					"expires_at": 9999999999
570				}
571			}
572		}
573	}`
574	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
575
576	store := &ConfigStore{
577		config:         &Config{},
578		globalDataPath: configPath,
579	}
580
581	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
582	require.NoError(t, err)
583	require.NotNil(t, token)
584	require.Equal(t, "newer-token-from-disk", token.AccessToken)
585	require.Equal(t, "refresh-abc", token.RefreshToken)
586	require.Equal(t, 3600, token.ExpiresIn)
587	require.Equal(t, int64(9999999999), token.ExpiresAt)
588}
589
590func TestLoadTokenFromDisk_ReturnsNilWhenSameToken(t *testing.T) {
591	t.Parallel()
592
593	dir := t.TempDir()
594	configPath := filepath.Join(dir, "crush.json")
595
596	// Create config file with the same token
597	configContent := `{
598		"providers": {
599			"hyper": {
600				"oauth": {
601					"access_token": "same-token",
602					"refresh_token": "refresh-abc",
603					"expires_in": 3600,
604					"expires_at": 9999999999
605				}
606			}
607		}
608	}`
609	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
610
611	store := &ConfigStore{
612		config:         &Config{},
613		globalDataPath: configPath,
614	}
615
616	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
617	require.NoError(t, err)
618	require.NotNil(t, token)
619	require.Equal(t, "same-token", token.AccessToken)
620}
621
622func TestLoadTokenFromDisk_ReturnsNilWhenFileMissing(t *testing.T) {
623	t.Parallel()
624
625	dir := t.TempDir()
626	configPath := filepath.Join(dir, "nonexistent.json")
627
628	store := &ConfigStore{
629		config:         &Config{},
630		globalDataPath: configPath,
631	}
632
633	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
634	require.NoError(t, err)
635	require.Nil(t, token)
636}
637
638func TestLoadTokenFromDisk_ReturnsNilWhenProviderMissing(t *testing.T) {
639	t.Parallel()
640
641	dir := t.TempDir()
642	configPath := filepath.Join(dir, "crush.json")
643
644	// Create config file without the hyper provider
645	configContent := `{"providers": {"openai": {"api_key": "test-key"}}}`
646	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
647
648	store := &ConfigStore{
649		config:         &Config{},
650		globalDataPath: configPath,
651	}
652
653	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
654	require.NoError(t, err)
655	require.Nil(t, token)
656}
657
658func TestLoadTokenFromDisk_ReturnsNilWhenOAuthMissing(t *testing.T) {
659	t.Parallel()
660
661	dir := t.TempDir()
662	configPath := filepath.Join(dir, "crush.json")
663
664	// Create config file with provider but no OAuth token
665	configContent := `{"providers": {"hyper": {"api_key": "test-key"}}}`
666	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
667
668	store := &ConfigStore{
669		config:         &Config{},
670		globalDataPath: configPath,
671	}
672
673	token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
674	require.NoError(t, err)
675	require.Nil(t, token)
676}
677
678func TestRefreshOAuthToken_UsesDiskTokenWhenDifferent(t *testing.T) {
679	t.Parallel()
680
681	dir := t.TempDir()
682	configPath := filepath.Join(dir, "crush.json")
683
684	// Create config file with a newer token on disk
685	configContent := `{
686		"providers": {
687			"hyper": {
688				"api_key": "newer-access-token",
689				"oauth": {
690					"access_token": "newer-access-token",
691					"refresh_token": "refresh-abc",
692					"expires_in": 3600,
693					"expires_at": 9999999999
694				}
695			}
696		}
697	}`
698	require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
699
700	// Set up store with an older in-memory token
701	oldToken := &oauth.Token{
702		AccessToken:  "older-access-token",
703		RefreshToken: "refresh-abc",
704		ExpiresIn:    3600,
705		ExpiresAt:    time.Now().Add(-time.Hour).Unix(), // Expired
706	}
707
708	providers := csync.NewMap[string, ProviderConfig]()
709	providers.Set("hyper", ProviderConfig{
710		ID:         "hyper",
711		Name:       "Hyper",
712		APIKey:     oldToken.AccessToken,
713		OAuthToken: oldToken,
714	})
715
716	store := &ConfigStore{
717		config: &Config{
718			Providers: providers,
719		},
720		globalDataPath: configPath,
721	}
722
723	// Refresh should use the disk token without making an external call
724	err := store.RefreshOAuthToken(context.Background(), ScopeGlobal, "hyper")
725	require.NoError(t, err)
726
727	// Verify the in-memory token was updated to the disk token
728	updatedConfig, ok := store.config.Providers.Get("hyper")
729	require.True(t, ok)
730	require.Equal(t, "newer-access-token", updatedConfig.APIKey)
731	require.Equal(t, "newer-access-token", updatedConfig.OAuthToken.AccessToken)
732	require.Equal(t, "refresh-abc", updatedConfig.OAuthToken.RefreshToken)
733}
734
735// TestConfigStore_SetConfigFields_concurrent verifies that concurrent writes do
736// not lose data when protected by the in-process mutex and cross-process flock.
737func TestConfigStore_SetConfigFields_concurrent(t *testing.T) {
738	t.Parallel()
739
740	dir := t.TempDir()
741	configPath := filepath.Join(dir, "crush.json")
742	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
743	require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0o600))
744
745	store := &ConfigStore{
746		config: &Config{
747			Providers: csync.NewMap[string, ProviderConfig](),
748			Models:    make(map[SelectedModelType]SelectedModel),
749		},
750		globalDataPath: configPath,
751		workingDir:     dir,
752	}
753
754	const (
755		numGoroutines    = 20
756		fieldsPerRoutine = 5
757	)
758
759	errs := make(chan error, numGoroutines)
760	for i := 0; i < numGoroutines; i++ {
761		go func(id int) {
762			kv := make(map[string]any, fieldsPerRoutine)
763			for j := 0; j < fieldsPerRoutine; j++ {
764				key := fmt.Sprintf("goroutine_%d_field_%d", id, j)
765				kv[key] = fmt.Sprintf("value_%d_%d", id, j)
766			}
767			errs <- store.SetConfigFields(ScopeGlobal, kv)
768		}(i)
769	}
770
771	for i := 0; i < numGoroutines; i++ {
772		require.NoError(t, <-errs)
773	}
774
775	// Verify all fields are present in the config file.
776	data, err := os.ReadFile(configPath)
777	require.NoError(t, err)
778
779	for i := 0; i < numGoroutines; i++ {
780		for j := 0; j < fieldsPerRoutine; j++ {
781			key := fmt.Sprintf("goroutine_%d_field_%d", i, j)
782			expectedValue := fmt.Sprintf("value_%d_%d", i, j)
783			result := gjson.Get(string(data), key)
784			require.True(t, result.Exists(), "key %s should exist", key)
785			require.Equal(t, expectedValue, result.String(), "key %s should have the correct value", key)
786		}
787	}
788}