1use 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
18const DEBOUNCE_MS: u64 = 200;
20
21pub struct ConfigWatcher {
26 path: PathBuf,
28 tx: watch::Sender<Arc<ForgeConfig>>,
30 rx: watch::Receiver<Arc<ForgeConfig>>,
32}
33
34impl ConfigWatcher {
35 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 pub fn subscribe(&self) -> watch::Receiver<Arc<ForgeConfig>> {
48 self.rx.clone()
49 }
50
51 pub fn current(&self) -> Arc<ForgeConfig> {
53 self.rx.borrow().clone()
54 }
55
56 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 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 if notify_rx.recv().await.is_none() {
95 break; }
97
98 tokio::time::sleep(Duration::from_millis(DEBOUNCE_MS)).await;
100 while notify_rx.try_recv().is_ok() {}
101
102 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 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 tokio::time::sleep(Duration::from_millis(100)).await;
165
166 std::fs::write(file.path(), valid_toml_modified()).unwrap();
168
169 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 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 let changed = tokio::time::timeout(Duration::from_secs(3), rx.changed()).await;
203 assert!(changed.is_ok(), "should eventually detect changes");
204
205 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 std::fs::write(file.path(), invalid_toml()).unwrap();
259
260 tokio::time::sleep(Duration::from_millis(500)).await;
262
263 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 std::fs::remove_file(&path).unwrap();
289
290 tokio::time::sleep(Duration::from_millis(500)).await;
292
293 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 #[test]
307 fn watch_06_feature_gate_compiles_without() {
308 let config = ForgeConfig::from_toml(valid_toml()).unwrap();
311 assert_eq!(config.sandbox.timeout_secs, Some(5));
312 }
313}