watcher.go RawHistoryFolder
1package config
2
3import (
4 "fmt"
5
6 "github.com/bnema/dumber/internal/logging"
7 "github.com/fsnotify/fsnotify"
8)
9
10// Watch starts watching the config file for changes and reloads automatically.
11func (m *Manager) Watch() error {
12 m.mu.Lock()
13 defer m.mu.Unlock()
14
15 if m.watching {
16 return nil // Already watching
17 }
18
19 m.viper.WatchConfig()
20 m.viper.OnConfigChange(func(e fsnotify.Event) {
21 log := logging.NewFromEnv()
22 log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected")
23
24 // Acquire write lock before reload (reload modifies m.config)
25 m.mu.Lock()
26
27 // Track whether notifyCallbacksLocked will handle the unlock
28 willNotify := false
29 defer func() {
30 if !willNotify {
31 m.mu.Unlock()
32 }
33 }()
34
35 // Skip reload if this was triggered by our own Save() - the in-memory
36 // config is already correct and viper may have stale cached data
37 if m.skipNextReload {
38 log.Debug().Msg("skipping reload (triggered by own Save)")
39 m.skipNextReload = false
40 // Still need to sync viper's internal state with the file we just wrote
41 if err := m.viper.ReadInConfig(); err != nil {
42 log.Warn().Err(err).Msg("failed to sync viper config after Save")
43 // Continue anyway - in-memory config is correct, just viper internal state is off
44 }
45 willNotify = true
46 m.notifyCallbacksLocked()
47 return
48 }
49
50 log.Debug().Msg("reloading config from external change")
51 if err := m.reload(); err != nil {
52 log.Warn().Err(err).Msg("failed to reload config")
53 return // defer will unlock
54 }
55 willNotify = true
56 m.notifyCallbacksLocked()
57 })
58
59 m.watching = true
60 return nil
61}
62
63// notifyCallbacksLocked copies callbacks and config, releases lock, then notifies.
64// Must be called with m.mu held for write. Releases the lock before calling callbacks.
65func (m *Manager) notifyCallbacksLocked() {
66 config := m.config
67 callbacks := make([]func(*Config), len(m.callbacks))
68 copy(callbacks, m.callbacks)
69 m.mu.Unlock()
70
71 for _, callback := range callbacks {
72 callback(config)
73 }
74}
75
76// OnConfigChange registers a callback function to be called when config changes.
77func (m *Manager) OnConfigChange(callback func(*Config)) {
78 m.mu.Lock()
79 defer m.mu.Unlock()
80
81 m.callbacks = append(m.callbacks, callback)
82}
83
84// reload reloads the configuration (internal method, must be called with lock held for write).
85func (m *Manager) reload() error {
86 if err := m.viper.ReadInConfig(); err != nil {
87 return err
88 }
89
90 m.transformLegacyConfig()
91
92 config := &Config{}
93 if err := m.viper.Unmarshal(config); err != nil {
94 return err
95 }
96
97 // Set database path if not specified
98 if config.Database.Path == "" {
99 dbPath, err := GetDatabaseFile()
100 if err != nil {
101 return fmt.Errorf("failed to get database path: %w", err)
102 }
103 config.Database.Path = dbPath
104 }
105
106 normalizeConfig(config)
107
108 // Validate all config values
109 if err := validateConfig(config); err != nil {
110 return fmt.Errorf("configuration validation failed: %w", err)
111 }
112
113 m.config = config
114 return nil
115}
116
117// Watch starts watching the global configuration for changes.
118func Watch() error {
119 if globalManager == nil {
120 return fmt.Errorf("configuration not initialized")
121 }
122 return globalManager.Watch()
123}
124
125// OnConfigChange registers a callback for global configuration changes.
126func OnConfigChange(callback func(*Config)) {
127 if globalManager == nil {
128 return
129 }
130 globalManager.OnConfigChange(callback)
131}