Skip to main content

forge_config/
watcher.rs

1//! Config file watcher.
2//!
3//! Watches a TOML config file for changes, debounces rapid modifications,
4//! and reloads the configuration. Invalid configs are rejected, preserving
5//! the last known good config.
6//!
7//! Requires the `config-watch` feature.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12
13use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
14use tokio::sync::watch;
15
16use crate::{ConfigError, ForgeConfig};
17
18/// Debounce interval for rapid file changes.
19const DEBOUNCE_MS: u64 = 200;
20
21/// A config file watcher that reloads on changes.
22///
23/// Uses `notify` for filesystem events and debounces rapid changes.
24/// Invalid config files are rejected — the last valid config is preserved.
25pub struct ConfigWatcher {
26    /// The path to the watched config file.
27    path: PathBuf,
28    /// Sender for config updates.
29    tx: watch::Sender<Arc<ForgeConfig>>,
30    /// Receiver for config updates (clone for consumers).
31    rx: watch::Receiver<Arc<ForgeConfig>>,
32}
33
34impl ConfigWatcher {
35    /// Create a new `ConfigWatcher` for the given config file.
36    ///
37    /// Loads the initial config from the file. Returns an error if the initial
38    /// load fails.
39    pub fn new(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
40        let path = path.as_ref().to_path_buf();
41        let config = ForgeConfig::from_file_with_env(&path)?;
42        let (tx, rx) = watch::channel(Arc::new(config));
43        Ok(Self { path, tx, rx })
44    }
45
46    /// Get a receiver that yields the latest config on each change.
47    pub fn subscribe(&self) -> watch::Receiver<Arc<ForgeConfig>> {
48        self.rx.clone()
49    }
50
51    /// Get the current config.
52    pub fn current(&self) -> Arc<ForgeConfig> {
53        self.rx.borrow().clone()
54    }
55
56    /// Start watching the config file for changes.
57    ///
58    /// Returns a `JoinHandle` for the background task. The task runs until
59    /// the watcher is dropped or the file becomes unwatchable.
60    pub fn start(self) -> tokio::task::JoinHandle<()> {
61        tokio::spawn(async move {
62            if let Err(e) = self.watch_loop().await {
63                tracing::error!("Config watcher stopped: {}", e);
64            }
65        })
66    }
67
68    async fn watch_loop(&self) -> Result<(), ConfigError> {
69        let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel::<()>(16);
70
71        let mut watcher: RecommendedWatcher =
72            notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
73                if let Ok(event) = res {
74                    if matches!(
75                        event.kind,
76                        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
77                    ) {
78                        let _ = notify_tx.blocking_send(());
79                    }
80                }
81            })
82            .map_err(|e| ConfigError::Invalid(format!("failed to create watcher: {}", e)))?;
83
84        // Watch the parent directory (some editors do atomic save via rename)
85        let watch_dir = self.path.parent().unwrap_or_else(|| Path::new("."));
86        watcher
87            .watch(watch_dir, RecursiveMode::NonRecursive)
88            .map_err(|e| ConfigError::Invalid(format!("failed to watch directory: {}", e)))?;
89
90        tracing::info!("Watching config file: {}", self.path.display());
91
92        loop {
93            // Wait for a filesystem event
94            if notify_rx.recv().await.is_none() {
95                break; // Channel closed
96            }
97
98            // Debounce: drain any rapid events within the window
99            tokio::time::sleep(Duration::from_millis(DEBOUNCE_MS)).await;
100            while notify_rx.try_recv().is_ok() {}
101
102            // Attempt reload
103            match ForgeConfig::from_file_with_env(&self.path) {
104                Ok(new_config) => {
105                    tracing::info!("Config reloaded from {}", self.path.display());
106                    let _ = self.tx.send(Arc::new(new_config));
107                }
108                Err(e) => {
109                    // File might have been deleted or be invalid — preserve old config
110                    tracing::warn!("Config reload failed (keeping previous config): {}", e);
111                }
112            }
113        }
114
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::io::Write;
123    use tempfile::NamedTempFile;
124
125    fn valid_toml() -> &'static str {
126        r#"
127[servers.test]
128command = "test-server"
129args = []
130transport = "stdio"
131
132[sandbox]
133timeout_secs = 5
134"#
135    }
136
137    fn valid_toml_modified() -> &'static str {
138        r#"
139[servers.test]
140command = "test-server-v2"
141args = ["--verbose"]
142transport = "stdio"
143
144[sandbox]
145timeout_secs = 10
146"#
147    }
148
149    fn invalid_toml() -> &'static str {
150        "this is not valid toml {{{"
151    }
152
153    #[tokio::test]
154    async fn watch_01_detects_file_change() {
155        let mut file = NamedTempFile::new().unwrap();
156        write!(file, "{}", valid_toml()).unwrap();
157        file.flush().unwrap();
158
159        let watcher = ConfigWatcher::new(file.path()).unwrap();
160        let mut rx = watcher.subscribe();
161        let handle = watcher.start();
162
163        // Give the watcher time to set up
164        tokio::time::sleep(Duration::from_millis(100)).await;
165
166        // Modify the file
167        std::fs::write(file.path(), valid_toml_modified()).unwrap();
168
169        // Wait for the debounced reload
170        let changed = tokio::time::timeout(Duration::from_secs(3), rx.changed()).await;
171        assert!(changed.is_ok(), "should detect file change within timeout");
172
173        let config = rx.borrow().clone();
174        assert_eq!(config.sandbox.timeout_secs, Some(10));
175
176        handle.abort();
177    }
178
179    #[tokio::test]
180    async fn watch_02_debounces_rapid_changes() {
181        let mut file = NamedTempFile::new().unwrap();
182        write!(file, "{}", valid_toml()).unwrap();
183        file.flush().unwrap();
184
185        let watcher = ConfigWatcher::new(file.path()).unwrap();
186        let mut rx = watcher.subscribe();
187        let handle = watcher.start();
188
189        tokio::time::sleep(Duration::from_millis(100)).await;
190
191        // Rapid-fire writes
192        for i in 0..5 {
193            let content = format!(
194                "[servers.test]\ncommand = \"v{}\"\nargs = []\ntransport = \"stdio\"\n\n[sandbox]\ntimeout_secs = {}\n",
195                i, 10 + i
196            );
197            std::fs::write(file.path(), &content).unwrap();
198            tokio::time::sleep(Duration::from_millis(20)).await;
199        }
200
201        // Wait for debounced reload
202        let changed = tokio::time::timeout(Duration::from_secs(3), rx.changed()).await;
203        assert!(changed.is_ok(), "should eventually detect changes");
204
205        // The final config should reflect one of the later writes
206        let config = rx.borrow().clone();
207        assert!(
208            config.sandbox.timeout_secs.unwrap_or(0) >= 10,
209            "debounced config should reflect a recent write"
210        );
211
212        handle.abort();
213    }
214
215    #[tokio::test]
216    async fn watch_03_reloads_valid_config() {
217        let mut file = NamedTempFile::new().unwrap();
218        write!(file, "{}", valid_toml()).unwrap();
219        file.flush().unwrap();
220
221        let watcher = ConfigWatcher::new(file.path()).unwrap();
222        let initial = watcher.current();
223        assert_eq!(initial.sandbox.timeout_secs, Some(5));
224
225        let mut rx = watcher.subscribe();
226        let handle = watcher.start();
227
228        tokio::time::sleep(Duration::from_millis(100)).await;
229
230        std::fs::write(file.path(), valid_toml_modified()).unwrap();
231
232        let changed = tokio::time::timeout(Duration::from_secs(3), rx.changed()).await;
233        assert!(changed.is_ok());
234
235        let updated = rx.borrow().clone();
236        assert_eq!(updated.sandbox.timeout_secs, Some(10));
237        assert_eq!(
238            updated.servers["test"].command.as_deref(),
239            Some("test-server-v2")
240        );
241
242        handle.abort();
243    }
244
245    #[tokio::test]
246    async fn watch_04_rejects_invalid_config_preserves_old() {
247        let mut file = NamedTempFile::new().unwrap();
248        write!(file, "{}", valid_toml()).unwrap();
249        file.flush().unwrap();
250
251        let watcher = ConfigWatcher::new(file.path()).unwrap();
252        let mut rx = watcher.subscribe();
253        let handle = watcher.start();
254
255        tokio::time::sleep(Duration::from_millis(100)).await;
256
257        // Write invalid TOML
258        std::fs::write(file.path(), invalid_toml()).unwrap();
259
260        // Wait a bit — the watcher should detect the change but reject the invalid config
261        tokio::time::sleep(Duration::from_millis(500)).await;
262
263        // Config should still be the original valid one
264        let config = rx.borrow_and_update().clone();
265        assert_eq!(
266            config.sandbox.timeout_secs,
267            Some(5),
268            "invalid config should be rejected, old config preserved"
269        );
270
271        handle.abort();
272    }
273
274    #[tokio::test]
275    async fn watch_05_handles_file_deletion_gracefully() {
276        let mut file = NamedTempFile::new().unwrap();
277        write!(file, "{}", valid_toml()).unwrap();
278        file.flush().unwrap();
279
280        let path = file.path().to_path_buf();
281        let watcher = ConfigWatcher::new(&path).unwrap();
282        let mut rx = watcher.subscribe();
283        let handle = watcher.start();
284
285        tokio::time::sleep(Duration::from_millis(100)).await;
286
287        // Delete the file
288        std::fs::remove_file(&path).unwrap();
289
290        // Wait a bit
291        tokio::time::sleep(Duration::from_millis(500)).await;
292
293        // Config should still be the original (file deletion = reload failure = keep old)
294        let config = rx.borrow_and_update().clone();
295        assert_eq!(
296            config.sandbox.timeout_secs,
297            Some(5),
298            "file deletion should not clear the config"
299        );
300
301        handle.abort();
302    }
303
304    /// Verify that the module compiles without the config-watch feature
305    /// by testing the types available in the base crate.
306    #[test]
307    fn watch_06_feature_gate_compiles_without() {
308        // This test verifies that ForgeConfig works without config-watch.
309        // The watcher module itself is gated by #[cfg(feature = "config-watch")].
310        let config = ForgeConfig::from_toml(valid_toml()).unwrap();
311        assert_eq!(config.sandbox.timeout_secs, Some(5));
312    }
313}