1package config
2
3import (
4 "cmp"
5 "context"
6 "encoding/json"
7 "fmt"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "slices"
12
13 "charm.land/catwalk/pkg/catwalk"
14 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
15 "github.com/charmbracelet/crush/internal/env"
16 "github.com/charmbracelet/crush/internal/oauth"
17 "github.com/charmbracelet/crush/internal/oauth/copilot"
18 "github.com/charmbracelet/crush/internal/oauth/hyper"
19 "github.com/tidwall/gjson"
20 "github.com/tidwall/sjson"
21)
22
23// fileSnapshot captures metadata about a config file at a point in time.
24type fileSnapshot struct {
25 Path string
26 Exists bool
27 Size int64
28 ModTime int64 // UnixNano
29}
30
31// RuntimeOverrides holds per-session settings that are never persisted to
32// disk. They are applied on top of the loaded Config and survive only for
33// the lifetime of the process (or workspace).
34type RuntimeOverrides struct {
35 SkipPermissionRequests bool
36}
37
38// ConfigStore is the single entry point for all config access. It owns the
39// pure-data Config, runtime state (working directory, resolver, known
40// providers), and persistence to both global and workspace config files.
41type ConfigStore struct {
42 config *Config
43 workingDir string
44 resolver VariableResolver
45 globalDataPath string // ~/.local/share/crush/crush.json
46 workspacePath string // .crush/crush.json
47 loadedPaths []string // config files that were successfully loaded
48 knownProviders []catwalk.Provider
49 overrides RuntimeOverrides
50 trackedConfigPaths []string // unique, normalized config file paths
51 snapshots map[string]fileSnapshot // path -> snapshot at last capture
52 autoReloadDisabled bool // set during load/reload to prevent re-entrancy
53 reloadInProgress bool // set during reload to avoid disk writes mid-reload
54}
55
56// Config returns the pure-data config struct (read-only after load).
57func (s *ConfigStore) Config() *Config {
58 return s.config
59}
60
61// WorkingDir returns the current working directory.
62func (s *ConfigStore) WorkingDir() string {
63 return s.workingDir
64}
65
66// Resolver returns the variable resolver.
67func (s *ConfigStore) Resolver() VariableResolver {
68 return s.resolver
69}
70
71// Resolve resolves a variable reference using the configured resolver.
72func (s *ConfigStore) Resolve(key string) (string, error) {
73 if s.resolver == nil {
74 return "", fmt.Errorf("no variable resolver configured")
75 }
76 return s.resolver.ResolveValue(key)
77}
78
79// KnownProviders returns the list of known providers.
80func (s *ConfigStore) KnownProviders() []catwalk.Provider {
81 return s.knownProviders
82}
83
84// SetupAgents configures the coder and task agents on the config.
85func (s *ConfigStore) SetupAgents() {
86 s.config.SetupAgents()
87}
88
89// Overrides returns the runtime overrides for this store.
90func (s *ConfigStore) Overrides() *RuntimeOverrides {
91 return &s.overrides
92}
93
94// LoadedPaths returns the config file paths that were successfully loaded.
95func (s *ConfigStore) LoadedPaths() []string {
96 return slices.Clone(s.loadedPaths)
97}
98
99// configPath returns the file path for the given scope.
100func (s *ConfigStore) configPath(scope Scope) (string, error) {
101 switch scope {
102 case ScopeWorkspace:
103 if s.workspacePath == "" {
104 return "", ErrNoWorkspaceConfig
105 }
106 return s.workspacePath, nil
107 default:
108 return s.globalDataPath, nil
109 }
110}
111
112// HasConfigField checks whether a key exists in the config file for the given
113// scope.
114func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
115 path, err := s.configPath(scope)
116 if err != nil {
117 return false
118 }
119 data, err := os.ReadFile(path)
120 if err != nil {
121 return false
122 }
123 return gjson.Get(string(data), key).Exists()
124}
125
126// SetConfigField sets a key/value pair in the config file for the given scope.
127// After a successful write, it automatically reloads config to keep in-memory
128// state fresh.
129func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
130 path, err := s.configPath(scope)
131 if err != nil {
132 return fmt.Errorf("%s: %w", key, err)
133 }
134 data, err := os.ReadFile(path)
135 if err != nil {
136 if os.IsNotExist(err) {
137 data = []byte("{}")
138 } else {
139 return fmt.Errorf("failed to read config file: %w", err)
140 }
141 }
142
143 newValue, err := sjson.Set(string(data), key, value)
144 if err != nil {
145 return fmt.Errorf("failed to set config field %s: %w", key, err)
146 }
147 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
148 return fmt.Errorf("failed to create config directory %q: %w", path, err)
149 }
150 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
151 return fmt.Errorf("failed to write config file: %w", err)
152 }
153
154 // Auto-reload to keep in-memory state fresh after config edits.
155 // We use context.Background() since this is an internal operation that
156 // shouldn't be cancelled by user context.
157 if err := s.autoReload(context.Background()); err != nil {
158 // Log warning but don't fail the write - disk is already updated.
159 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
160 }
161
162 return nil
163}
164
165// RemoveConfigField removes a key from the config file for the given scope.
166// After a successful write, it automatically reloads config to keep in-memory
167// state fresh.
168func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
169 path, err := s.configPath(scope)
170 if err != nil {
171 return fmt.Errorf("%s: %w", key, err)
172 }
173 data, err := os.ReadFile(path)
174 if err != nil {
175 return fmt.Errorf("failed to read config file: %w", err)
176 }
177
178 newValue, err := sjson.Delete(string(data), key)
179 if err != nil {
180 return fmt.Errorf("failed to delete config field %s: %w", key, err)
181 }
182 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
183 return fmt.Errorf("failed to create config directory %q: %w", path, err)
184 }
185 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
186 return fmt.Errorf("failed to write config file: %w", err)
187 }
188
189 // Auto-reload to keep in-memory state fresh after config edits.
190 if err := s.autoReload(context.Background()); err != nil {
191 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
192 }
193
194 return nil
195}
196
197// UpdatePreferredModel updates the preferred model for the given type and
198// persists it to the config file at the given scope.
199func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
200 s.config.Models[modelType] = model
201 if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
202 return fmt.Errorf("failed to update preferred model: %w", err)
203 }
204 if err := s.recordRecentModel(scope, modelType, model); err != nil {
205 return err
206 }
207 return nil
208}
209
210// SetCompactMode sets the compact mode setting and persists it.
211func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
212 if s.config.Options == nil {
213 s.config.Options = &Options{}
214 }
215 s.config.Options.TUI.CompactMode = enabled
216 return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
217}
218
219// SetTransparentBackground sets the transparent background setting and persists it.
220func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
221 if s.config.Options == nil {
222 s.config.Options = &Options{}
223 }
224 s.config.Options.TUI.Transparent = &enabled
225 return s.SetConfigField(scope, "options.tui.transparent", enabled)
226}
227
228// SetProviderAPIKey sets the API key for a provider and persists it.
229func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
230 var providerConfig ProviderConfig
231 var exists bool
232 var setKeyOrToken func()
233
234 switch v := apiKey.(type) {
235 case string:
236 if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
237 return fmt.Errorf("failed to save api key to config file: %w", err)
238 }
239 setKeyOrToken = func() { providerConfig.APIKey = v }
240 case *oauth.Token:
241 if err := cmp.Or(
242 s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
243 s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v),
244 ); err != nil {
245 return err
246 }
247 setKeyOrToken = func() {
248 providerConfig.APIKey = v.AccessToken
249 providerConfig.OAuthToken = v
250 switch providerID {
251 case string(catwalk.InferenceProviderCopilot):
252 providerConfig.SetupGitHubCopilot()
253 }
254 }
255 }
256
257 providerConfig, exists = s.config.Providers.Get(providerID)
258 if exists {
259 setKeyOrToken()
260 s.config.Providers.Set(providerID, providerConfig)
261 return nil
262 }
263
264 var foundProvider *catwalk.Provider
265 for _, p := range s.knownProviders {
266 if string(p.ID) == providerID {
267 foundProvider = &p
268 break
269 }
270 }
271
272 if foundProvider != nil {
273 providerConfig = ProviderConfig{
274 ID: providerID,
275 Name: foundProvider.Name,
276 BaseURL: foundProvider.APIEndpoint,
277 Type: foundProvider.Type,
278 Disable: false,
279 ExtraHeaders: make(map[string]string),
280 ExtraParams: make(map[string]string),
281 Models: foundProvider.Models,
282 }
283 setKeyOrToken()
284 } else {
285 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
286 }
287 s.config.Providers.Set(providerID, providerConfig)
288 return nil
289}
290
291// RefreshOAuthToken refreshes the OAuth token for the given provider.
292// Before making an external refresh request, it checks the config file on
293// disk to see if another Crush session has already refreshed the token. If
294// a newer token is found, it is used instead of refreshing.
295func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
296 providerConfig, exists := s.config.Providers.Get(providerID)
297 if !exists {
298 return fmt.Errorf("provider %s not found", providerID)
299 }
300
301 if providerConfig.OAuthToken == nil {
302 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
303 }
304
305 // Check if another session refreshed the token recently by reading
306 // the current token from the config file on disk.
307 newToken, err := s.loadTokenFromDisk(scope, providerID)
308 if err != nil {
309 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
310 } else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
311 slog.Info("Using token refreshed by another session", "provider", providerID)
312 providerConfig.OAuthToken = newToken
313 providerConfig.APIKey = newToken.AccessToken
314 if providerID == string(catwalk.InferenceProviderCopilot) {
315 providerConfig.SetupGitHubCopilot()
316 }
317 s.config.Providers.Set(providerID, providerConfig)
318 return nil
319 }
320
321 var refreshedToken *oauth.Token
322 var refreshErr error
323 switch providerID {
324 case string(catwalk.InferenceProviderCopilot):
325 refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
326 case hyperp.Name:
327 refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
328 default:
329 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
330 }
331 if refreshErr != nil {
332 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
333 }
334
335 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
336 providerConfig.OAuthToken = refreshedToken
337 providerConfig.APIKey = refreshedToken.AccessToken
338
339 switch providerID {
340 case string(catwalk.InferenceProviderCopilot):
341 providerConfig.SetupGitHubCopilot()
342 }
343
344 s.config.Providers.Set(providerID, providerConfig)
345
346 if err := cmp.Or(
347 s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), refreshedToken.AccessToken),
348 s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), refreshedToken),
349 ); err != nil {
350 return fmt.Errorf("failed to persist refreshed token: %w", err)
351 }
352
353 return nil
354}
355
356// loadTokenFromDisk reads the OAuth token for the given provider from the
357// config file on disk. Returns nil if the token is not found or matches the
358// current in-memory token.
359func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
360 path, err := s.configPath(scope)
361 if err != nil {
362 return nil, err
363 }
364
365 data, err := os.ReadFile(path)
366 if err != nil {
367 if os.IsNotExist(err) {
368 return nil, nil
369 }
370 return nil, err
371 }
372
373 oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
374 oauthResult := gjson.Get(string(data), oauthKey)
375 if !oauthResult.Exists() {
376 return nil, nil
377 }
378
379 var token oauth.Token
380 if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
381 return nil, err
382 }
383
384 if token.AccessToken == "" {
385 return nil, nil
386 }
387
388 return &token, nil
389}
390
391// recordRecentModel records a model in the recent models list.
392func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
393 if model.Provider == "" || model.Model == "" {
394 return nil
395 }
396
397 if s.config.RecentModels == nil {
398 s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
399 }
400
401 eq := func(a, b SelectedModel) bool {
402 return a.Provider == b.Provider && a.Model == b.Model
403 }
404
405 entry := SelectedModel{
406 Provider: model.Provider,
407 Model: model.Model,
408 }
409
410 current := s.config.RecentModels[modelType]
411 withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
412 return eq(existing, entry)
413 })
414
415 updated := append([]SelectedModel{entry}, withoutCurrent...)
416 if len(updated) > maxRecentModelsPerType {
417 updated = updated[:maxRecentModelsPerType]
418 }
419
420 if slices.EqualFunc(current, updated, eq) {
421 return nil
422 }
423
424 s.config.RecentModels[modelType] = updated
425
426 if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
427 return fmt.Errorf("failed to persist recent models: %w", err)
428 }
429
430 return nil
431}
432
433// NewTestStore creates a ConfigStore for testing purposes.
434func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
435 return &ConfigStore{
436 config: cfg,
437 loadedPaths: loadedPaths,
438 }
439}
440
441// ImportCopilot attempts to import a GitHub Copilot token from disk.
442func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
443 if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
444 return nil, false
445 }
446
447 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
448 if !hasDiskToken {
449 return nil, false
450 }
451
452 slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
453 token, err := copilot.RefreshToken(context.TODO(), diskToken)
454 if err != nil {
455 slog.Error("Unable to import GitHub Copilot token", "error", err)
456 return nil, false
457 }
458
459 if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
460 return token, false
461 }
462
463 if err := cmp.Or(
464 s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
465 s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token),
466 ); err != nil {
467 slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
468 }
469
470 slog.Info("GitHub Copilot successfully imported")
471 return token, true
472}
473
474// StalenessResult contains the result of a staleness check.
475type StalenessResult struct {
476 Dirty bool
477 Changed []string
478 Missing []string
479 Errors map[string]error // stat errors by path
480}
481
482// ConfigStaleness checks whether any tracked config files have changed on disk
483// since the last snapshot. Returns dirty=true if any files changed or went
484// missing, along with sorted lists of affected paths. Stat errors are
485// captured in Errors map but still treated as non-existence for dirty detection.
486func (s *ConfigStore) ConfigStaleness() StalenessResult {
487 var result StalenessResult
488 result.Errors = make(map[string]error)
489
490 for _, path := range s.trackedConfigPaths {
491 snapshot, hadSnapshot := s.snapshots[path]
492
493 info, err := os.Stat(path)
494 exists := err == nil && !info.IsDir()
495
496 if err != nil && !os.IsNotExist(err) {
497 // Capture permission/IO errors separately from non-existence
498 result.Errors[path] = err
499 result.Dirty = true
500 }
501
502 if !exists {
503 if hadSnapshot && snapshot.Exists {
504 // File existed before but now missing
505 result.Missing = append(result.Missing, path)
506 result.Dirty = true
507 }
508 continue
509 }
510
511 // File exists now
512 if !hadSnapshot || !snapshot.Exists {
513 // File didn't exist before but does now
514 result.Changed = append(result.Changed, path)
515 result.Dirty = true
516 continue
517 }
518
519 // Check for content or metadata changes
520 if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
521 result.Changed = append(result.Changed, path)
522 result.Dirty = true
523 }
524 }
525
526 // Sort for deterministic output
527 slices.Sort(result.Changed)
528 slices.Sort(result.Missing)
529
530 return result
531}
532
533// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
534// Call this after reloading config to clear dirty state.
535func (s *ConfigStore) RefreshStalenessSnapshot() error {
536 if s.snapshots == nil {
537 s.snapshots = make(map[string]fileSnapshot)
538 }
539
540 for _, path := range s.trackedConfigPaths {
541 info, err := os.Stat(path)
542 exists := err == nil && !info.IsDir()
543
544 snapshot := fileSnapshot{
545 Path: path,
546 Exists: exists,
547 }
548
549 if exists {
550 snapshot.Size = info.Size()
551 snapshot.ModTime = info.ModTime().UnixNano()
552 }
553
554 s.snapshots[path] = snapshot
555 }
556
557 return nil
558}
559
560// CaptureStalenessSnapshot captures snapshots for the given paths, building the
561// tracked config paths list. Paths are deduplicated and normalized.
562func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
563 // Build unique set of normalized paths
564 seen := make(map[string]struct{})
565 for _, p := range paths {
566 if p == "" {
567 continue
568 }
569 // Normalize path
570 abs, err := filepath.Abs(p)
571 if err != nil {
572 abs = p
573 }
574 seen[abs] = struct{}{}
575 }
576
577 // Also track workspace and global config paths if set
578 if s.workspacePath != "" {
579 abs, err := filepath.Abs(s.workspacePath)
580 if err == nil {
581 seen[abs] = struct{}{}
582 }
583 }
584 if s.globalDataPath != "" {
585 abs, err := filepath.Abs(s.globalDataPath)
586 if err == nil {
587 seen[abs] = struct{}{}
588 }
589 }
590
591 // Build sorted list for deterministic ordering
592 s.trackedConfigPaths = make([]string, 0, len(seen))
593 for p := range seen {
594 s.trackedConfigPaths = append(s.trackedConfigPaths, p)
595 }
596 slices.Sort(s.trackedConfigPaths)
597
598 // Capture initial snapshots
599 s.RefreshStalenessSnapshot()
600}
601
602// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
603func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
604 s.CaptureStalenessSnapshot(paths)
605}
606
607// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
608// config atomically. It rebuilds the staleness snapshot after successful reload.
609// On failure, the store state is rolled back to its previous state.
610func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
611 if s.workingDir == "" {
612 return fmt.Errorf("cannot reload: working directory not set")
613 }
614
615 // Disable auto-reload during reload to prevent nested/re-entrant calls.
616 s.autoReloadDisabled = true
617 s.reloadInProgress = true
618 defer func() {
619 s.autoReloadDisabled = false
620 s.reloadInProgress = false
621 }()
622
623 configPaths := lookupConfigs(s.workingDir)
624 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
625 if err != nil {
626 return fmt.Errorf("failed to reload config: %w", err)
627 }
628
629 // Apply defaults (using existing data directory if set)
630 var dataDir string
631 if s.config != nil && s.config.Options != nil {
632 dataDir = s.config.Options.DataDirectory
633 }
634 cfg.setDefaults(s.workingDir, dataDir)
635
636 // Merge workspace config if present
637 workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
638 if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
639 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
640 if mergeErr == nil {
641 dataDir := cfg.Options.DataDirectory
642 *cfg = *merged
643 cfg.setDefaults(s.workingDir, dataDir)
644 loadedPaths = append(loadedPaths, workspacePath)
645 }
646 }
647
648 // Validate hooks after all config merging is complete so matcher
649 // regexes are recompiled on the reloaded config (mirrors Load).
650 if err := cfg.ValidateHooks(); err != nil {
651 return fmt.Errorf("invalid hook configuration on reload: %w", err)
652 }
653
654 // Preserve runtime overrides
655 overrides := s.overrides
656
657 // Reconfigure providers
658 env := env.New()
659 resolver := NewShellVariableResolver(env)
660 providers, err := Providers(cfg)
661 if err != nil {
662 return fmt.Errorf("failed to load providers during reload: %w", err)
663 }
664
665 if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
666 return fmt.Errorf("failed to configure providers during reload: %w", err)
667 }
668
669 // Save current state for potential rollback
670 oldConfig := s.config
671 oldLoadedPaths := s.loadedPaths
672 oldResolver := s.resolver
673 oldKnownProviders := s.knownProviders
674 oldOverrides := s.overrides
675 oldWorkspacePath := s.workspacePath
676
677 // Update store state BEFORE running model/agent setup (so they see new config)
678 s.config = cfg
679 s.loadedPaths = loadedPaths
680 s.resolver = resolver
681 s.knownProviders = providers
682 s.overrides = overrides
683 s.workspacePath = workspacePath
684
685 // Mirror startup flow: setup models and agents against NEW config
686 var setupErr error
687 if !cfg.IsConfigured() {
688 slog.Warn("No providers configured after reload")
689 } else {
690 if err := configureSelectedModels(s, providers, false); err != nil {
691 setupErr = fmt.Errorf("failed to configure selected models during reload: %w", err)
692 } else {
693 s.SetupAgents()
694 }
695 }
696
697 // Rollback on setup failure
698 if setupErr != nil {
699 s.config = oldConfig
700 s.loadedPaths = oldLoadedPaths
701 s.resolver = oldResolver
702 s.knownProviders = oldKnownProviders
703 s.overrides = oldOverrides
704 s.workspacePath = oldWorkspacePath
705 return setupErr
706 }
707
708 // Rebuild staleness tracking
709 s.captureStalenessSnapshot(loadedPaths)
710
711 return nil
712}
713
714// autoReload conditionally reloads config from disk after writes.
715// It returns nil (no error) for expected skip cases: when auto-reload is
716// disabled during load/reload flows, or when working directory is not set
717// (e.g., during testing). Only actual reload failures return an error.
718func (s *ConfigStore) autoReload(ctx context.Context) error {
719 if s.autoReloadDisabled {
720 return nil // Expected skip: already in load/reload flow
721 }
722 if s.workingDir == "" {
723 return nil // Expected skip: working directory not set
724 }
725 return s.ReloadFromDisk(ctx)
726}