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