Skip to main content

par_term_config/
watcher.rs

1//! Config file watcher for automatic reload.
2//!
3//! Watches the config.yaml file for changes and triggers automatic reloading.
4//! Uses debouncing to avoid multiple reloads during rapid saves from editors.
5
6use 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/// Event indicating the config file has changed and needs reloading.
15#[derive(Debug, Clone)]
16pub struct ConfigReloadEvent {
17    /// Path to the config file that changed.
18    pub path: PathBuf,
19}
20
21/// Watches the config file for changes and sends reload events.
22pub struct ConfigWatcher {
23    /// The file system watcher (kept alive to maintain watching).
24    _watcher: Box<dyn Watcher + Send>,
25    /// Receiver for config change events.
26    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/// Which events a watcher reacts to.
36#[derive(Debug, Clone)]
37enum WatchTarget {
38    /// One file: modify/create events whose file name matches.
39    File(std::ffi::OsString),
40    /// A directory's direct `*.yaml` children, hidden files excluded (so a
41    /// sidecar such as `.confirmations.json` never triggers a reload).
42    /// Removals count too — a deleted child changes the directory's set.
43    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
72/// Build the shared event-handler closure used by both watcher backends.
73///
74/// Returns a closure that filters events to `target`, applies debouncing,
75/// and sends `ConfigReloadEvent` values on `tx`.
76fn 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            // Create handles atomic saves (temp + rename).
86            if !target.matches(&event) {
87                return;
88            }
89
90            // Debounce: skip if we sent an event too recently
91            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    /// Create a new config watcher.
123    ///
124    /// Attempts to use the platform's native watcher (`RecommendedWatcher`: inotify on
125    /// Linux, kqueue on macOS, ReadDirectoryChanges on Windows) for low-latency,
126    /// event-driven notifications. If the native backend fails to initialise (e.g.
127    /// inside a container or on a network filesystem), falls back to a `PollWatcher`
128    /// that checks for changes every 500 ms.
129    ///
130    /// # Arguments
131    /// * `config_path` - Path to the config file to watch.
132    /// * `debounce_delay_ms` - Debounce delay in milliseconds to avoid rapid reloads.
133    ///
134    /// # Errors
135    /// Returns an error if the config file doesn't exist or watching fails on both
136    /// backends.
137    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    /// Watch a directory's direct `*.yaml` children (non-hidden): one reload
165    /// event per debounced burst of create/modify/remove. The event's `path`
166    /// is the directory. Same backend fallback as [`Self::new`].
167    ///
168    /// # Errors
169    /// Returns an error if the directory doesn't exist or watching fails on
170    /// both backends.
171    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        // Try the platform-native watcher first; fall back to PollWatcher on failure.
195        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    /// Try to create the best available watcher backend.
216    ///
217    /// Attempts `RecommendedWatcher` first. If that fails (e.g. inside a
218    /// container, network filesystem, or restricted environment), logs a warning
219    /// and falls back to `PollWatcher` with a 500 ms poll interval.
220    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        // Build the shared handler (clone inputs for the fallback path).
228        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    /// Check for pending config reload events (non-blocking).
265    ///
266    /// Returns the next reload event if one is available, or `None` if no events are pending.
267    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        // Should return None immediately with no events
311        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        // Give the watcher time to set up
327        std::thread::sleep(Duration::from_millis(100));
328
329        // Modify the file
330        fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
331
332        // Wait for the watcher to detect the change (native is faster; poll takes up to 500ms)
333        std::thread::sleep(Duration::from_millis(700));
334
335        // Check for the reload event (platform-dependent, don't assert failure)
336        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        // The store previously built a File target from the directory path;
366        // that can never match a child — the bug this mode exists for.
367        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}