1use anyhow::{Context, Result};
7use notify::{Config as NotifyConfig, Event, PollWatcher, RecursiveMode, Watcher};
8use parking_lot::Mutex;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::mpsc::{Receiver, channel};
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone)]
16pub struct ConfigReloadEvent {
17 pub path: PathBuf,
19}
20
21pub struct ConfigWatcher {
23 _watcher: Box<dyn Watcher + Send>,
25 event_receiver: Receiver<ConfigReloadEvent>,
27}
28
29impl std::fmt::Debug for ConfigWatcher {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.debug_struct("ConfigWatcher").finish_non_exhaustive()
32 }
33}
34
35#[derive(Debug, Clone)]
37enum WatchTarget {
38 File(std::ffi::OsString),
40 YamlChildren(PathBuf),
44}
45
46impl WatchTarget {
47 fn matches(&self, event: &Event) -> bool {
48 use notify::EventKind::{Create, Modify, Remove};
49 match self {
50 Self::File(filename) => {
51 matches!(event.kind, Modify(_) | Create(_))
52 && event
53 .paths
54 .iter()
55 .any(|p| p.file_name().is_some_and(|f| f == filename))
56 }
57 Self::YamlChildren(dir) => {
58 matches!(event.kind, Modify(_) | Create(_) | Remove(_))
59 && event.paths.iter().any(|p| {
60 p.parent() == Some(dir.as_path())
61 && p.extension().is_some_and(|e| e == "yaml")
62 && !p
63 .file_name()
64 .and_then(|n| n.to_str())
65 .is_some_and(|n| n.starts_with('.'))
66 })
67 }
68 }
69 }
70}
71
72fn make_event_handler(
77 target: WatchTarget,
78 canonical_path: PathBuf,
79 debounce_delay: Duration,
80 tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
81 last_event_time: Arc<Mutex<Option<Instant>>>,
82) -> impl Fn(std::result::Result<Event, notify::Error>) + Send + 'static {
83 move |result: std::result::Result<Event, notify::Error>| {
84 if let Ok(event) = result {
85 if !target.matches(&event) {
87 return;
88 }
89
90 let should_send: bool = {
92 let now: Instant = Instant::now();
93 let mut last: parking_lot::MutexGuard<'_, Option<Instant>> = last_event_time.lock();
94 if let Some(last_time) = *last {
95 if now.duration_since(last_time) < debounce_delay {
96 log::trace!("Debouncing config reload event");
97 false
98 } else {
99 *last = Some(now);
100 true
101 }
102 } else {
103 *last = Some(now);
104 true
105 }
106 };
107
108 if should_send {
109 let reload_event = ConfigReloadEvent {
110 path: canonical_path.clone(),
111 };
112 log::info!("Config file changed: {}", reload_event.path.display());
113 if let Err(e) = tx.send(reload_event) {
114 log::error!("Failed to send config reload event: {}", e);
115 }
116 }
117 }
118 }
119}
120
121impl ConfigWatcher {
122 pub fn new(config_path: &Path, debounce_delay_ms: u64) -> Result<Self> {
138 if !config_path.exists() {
139 anyhow::bail!("Config file not found: {}", config_path.display());
140 }
141
142 let canonical: PathBuf = config_path
143 .canonicalize()
144 .unwrap_or_else(|_| config_path.to_path_buf());
145
146 let filename: std::ffi::OsString = canonical
147 .file_name()
148 .context("Config path has no filename")?
149 .to_os_string();
150
151 let parent_dir: PathBuf = canonical
152 .parent()
153 .context("Config path has no parent directory")?
154 .to_path_buf();
155
156 Self::start(
157 WatchTarget::File(filename),
158 canonical,
159 parent_dir,
160 debounce_delay_ms,
161 )
162 }
163
164 pub fn new_yaml_dir(dir: &Path, debounce_delay_ms: u64) -> Result<Self> {
172 if !dir.is_dir() {
173 anyhow::bail!("Watch directory not found: {}", dir.display());
174 }
175 let canonical: PathBuf = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
176 Self::start(
177 WatchTarget::YamlChildren(canonical.clone()),
178 canonical.clone(),
179 canonical,
180 debounce_delay_ms,
181 )
182 }
183
184 fn start(
185 target: WatchTarget,
186 canonical: PathBuf,
187 watch_dir: PathBuf,
188 debounce_delay_ms: u64,
189 ) -> Result<Self> {
190 let (tx, rx) = channel::<ConfigReloadEvent>();
191 let debounce_delay: Duration = Duration::from_millis(debounce_delay_ms);
192 let last_event_time: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
193
194 let mut watcher: Box<dyn Watcher + Send> = Self::create_watcher(
196 target,
197 canonical.clone(),
198 debounce_delay,
199 tx,
200 last_event_time,
201 )?;
202
203 watcher
204 .watch(&watch_dir, RecursiveMode::NonRecursive)
205 .with_context(|| format!("Failed to watch directory: {}", watch_dir.display()))?;
206
207 log::info!("Config hot reload: watching {}", canonical.display());
208
209 Ok(Self {
210 _watcher: watcher,
211 event_receiver: rx,
212 })
213 }
214
215 fn create_watcher(
221 target: WatchTarget,
222 canonical_path: PathBuf,
223 debounce_delay: Duration,
224 tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
225 last_event_time: Arc<Mutex<Option<Instant>>>,
226 ) -> Result<Box<dyn Watcher + Send>> {
227 let target2 = target.clone();
229 let canonical_path2 = canonical_path.clone();
230 let debounce_delay2 = debounce_delay;
231 let tx2 = tx.clone();
232 let last_event_time2 = Arc::clone(&last_event_time);
233
234 let handler =
235 make_event_handler(target, canonical_path, debounce_delay, tx, last_event_time);
236
237 match notify::recommended_watcher(handler) {
238 Ok(w) => {
239 log::debug!("Config watcher: using native (RecommendedWatcher) backend");
240 Ok(Box::new(w))
241 }
242 Err(e) => {
243 log::warn!(
244 "Config watcher: native backend unavailable ({}); falling back to PollWatcher",
245 e
246 );
247 let fallback_handler = make_event_handler(
248 target2,
249 canonical_path2,
250 debounce_delay2,
251 tx2,
252 last_event_time2,
253 );
254 let poll_watcher = PollWatcher::new(
255 fallback_handler,
256 NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
257 )
258 .context("Failed to create fallback PollWatcher")?;
259 Ok(Box::new(poll_watcher))
260 }
261 }
262 }
263
264 pub fn try_recv(&self) -> Option<ConfigReloadEvent> {
268 self.event_receiver.try_recv().ok()
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use std::fs;
276 use tempfile::TempDir;
277
278 #[test]
279 fn test_watcher_creation_with_existing_file() {
280 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
281 let config_path: PathBuf = temp_dir.path().join("config.yaml");
282 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
283
284 let result = ConfigWatcher::new(&config_path, 100);
285 assert!(
286 result.is_ok(),
287 "ConfigWatcher should succeed with existing file"
288 );
289 }
290
291 #[test]
292 fn test_watcher_creation_with_nonexistent_file() {
293 let path = PathBuf::from("/tmp/nonexistent_config_watcher_test/config.yaml");
294 let result = ConfigWatcher::new(&path, 100);
295 assert!(
296 result.is_err(),
297 "ConfigWatcher should fail with nonexistent file"
298 );
299 }
300
301 #[test]
302 fn test_no_initial_events() {
303 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
304 let config_path: PathBuf = temp_dir.path().join("config.yaml");
305 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
306
307 let watcher: ConfigWatcher =
308 ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
309
310 assert!(
312 watcher.try_recv().is_none(),
313 "No events should be pending after creation"
314 );
315 }
316
317 #[test]
318 fn test_file_change_detection() {
319 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
320 let config_path: PathBuf = temp_dir.path().join("config.yaml");
321 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
322
323 let watcher: ConfigWatcher =
324 ConfigWatcher::new(&config_path, 50).expect("Failed to create watcher");
325
326 std::thread::sleep(Duration::from_millis(100));
328
329 fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
331
332 std::thread::sleep(Duration::from_millis(700));
334
335 if let Some(event) = watcher.try_recv() {
337 assert!(
338 event.path.ends_with("config.yaml"),
339 "Event path should end with config.yaml"
340 );
341 }
342 }
343
344 #[test]
345 fn yaml_dir_target_matches_children_and_skips_hidden_and_foreign() {
346 use notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind};
347 let dir = PathBuf::from("/cfg/par-term/commands");
348 let target = WatchTarget::YamlChildren(dir.clone());
349 let ev = |kind: EventKind, p: &str| Event::new(kind).add_path(dir.join(p));
350 let create = EventKind::Create(CreateKind::File);
351
352 assert!(target.matches(&ev(create, "greet.yaml")));
353 assert!(target.matches(&ev(EventKind::Modify(ModifyKind::Any), "greet.yaml")));
354 assert!(target.matches(&ev(EventKind::Remove(RemoveKind::File), "greet.yaml")));
355 assert!(!target.matches(&ev(create, ".confirmations.json")));
356 assert!(!target.matches(&ev(create, ".greet.yaml.swp")));
357 assert!(!target.matches(&ev(create, "notes.txt")));
358 assert!(!target.matches(&ev(
359 EventKind::Access(notify::event::AccessKind::Any),
360 "greet.yaml"
361 )));
362 let nested = Event::new(create).add_path(dir.join("sub/greet.yaml"));
363 assert!(!target.matches(&nested));
364
365 let old = WatchTarget::File(std::ffi::OsString::from("commands"));
368 assert!(!old.matches(&ev(create, "greet.yaml")));
369 }
370
371 #[test]
372 fn yaml_dir_watcher_rejects_missing_dir() {
373 let path = PathBuf::from("/tmp/nonexistent_yaml_dir_watcher_test");
374 assert!(ConfigWatcher::new_yaml_dir(&path, 100).is_err());
375 }
376
377 #[test]
378 fn test_debug_impl() {
379 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
380 let config_path: PathBuf = temp_dir.path().join("config.yaml");
381 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
382
383 let watcher: ConfigWatcher =
384 ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
385
386 let debug_str: String = format!("{:?}", watcher);
387 assert!(
388 debug_str.contains("ConfigWatcher"),
389 "Debug output should contain struct name"
390 );
391 }
392}