Skip to main content

mcp_methods/server/
watch.rs

1//! Filesystem-watcher subsystem for `--watch DIR` mode.
2//!
3//! Boots a debounced recursive watcher on the configured directory and
4//! invokes a caller-supplied callback when files change. Downstream
5//! binaries register callbacks to drive whatever rebuild they need —
6//! kglite-mcp-server, for example, wires this to `code_tree::build()`
7//! against the watched directory and atomic-swaps the active graph.
8//!
9//! mcp-methods's binary on its own does not own a rebuild target;
10//! it logs change events at INFO level and forwards them to any
11//! registered callback. When no callback is set the watcher still
12//! runs, so the change events show up in stderr.
13
14#![allow(dead_code)]
15
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19
20use anyhow::{Context, Result};
21use notify_debouncer_mini::notify::RecursiveMode;
22use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
23
24/// Callback invoked on a debounced file-change event.
25///
26/// `paths` is the deduplicated set of paths reported as changed within
27/// the debounce window. The callback runs on a background thread; keep
28/// it non-blocking or push work onto a channel.
29pub type ChangeHandler = Arc<dyn Fn(&[PathBuf]) + Send + Sync>;
30
31/// Default debounce window — short enough to feel responsive, long
32/// enough to coalesce noisy editor saves and IDE temp-file dance.
33pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
34
35/// Active watcher handle. Drop to stop watching.
36pub struct WatchHandle {
37    _debouncer: Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
38}
39
40/// Spawn a recursive debounced watcher on ``dir``.
41///
42/// Returns a handle whose `Drop` impl tears the watcher down. Errors
43/// surface synchronously if the path is not a directory or the platform
44/// watcher refuses to register.
45pub fn watch(
46    dir: &Path,
47    on_change: Option<ChangeHandler>,
48    debounce: Option<Duration>,
49) -> Result<WatchHandle> {
50    if !dir.is_dir() {
51        anyhow::bail!("--watch path is not a directory: {}", dir.display());
52    }
53    let debounce = debounce.unwrap_or(DEFAULT_DEBOUNCE);
54    let dir_for_log = dir.to_path_buf();
55    let on_change = on_change.unwrap_or_else(|| {
56        Arc::new(|_| {
57            // No-op callback when no downstream consumer is configured.
58        })
59    });
60
61    let mut debouncer = new_debouncer(debounce, move |result: DebounceEventResult| match result {
62        Ok(events) => {
63            let paths: Vec<PathBuf> = events.into_iter().map(|e| e.path).collect();
64            tracing::info!(
65                root = %dir_for_log.display(),
66                changed = paths.len(),
67                "watch: file change debounced"
68            );
69            on_change(&paths);
70        }
71        Err(e) => {
72            tracing::warn!(error = %e, "watch: error from notify");
73        }
74    })
75    .context("failed to construct file-system debouncer")?;
76
77    debouncer
78        .watcher()
79        .watch(dir, RecursiveMode::Recursive)
80        .with_context(|| format!("failed to watch {}", dir.display()))?;
81
82    tracing::info!(root = %dir.display(), debounce_ms = debounce.as_millis() as u64, "watch: active");
83    Ok(WatchHandle {
84        _debouncer: debouncer,
85    })
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::sync::atomic::{AtomicUsize, Ordering};
92
93    #[test]
94    fn watch_rejects_non_directory() {
95        let result = watch(Path::new("/this/does/not/exist"), None, None);
96        assert!(result.is_err());
97    }
98
99    #[test]
100    fn watch_starts_and_drops_clean() {
101        let dir = tempfile::tempdir().unwrap();
102        let _handle = watch(dir.path(), None, Some(Duration::from_millis(100))).unwrap();
103        // Drop at end of scope tears it down without panicking.
104    }
105
106    #[test]
107    fn callback_fires_on_file_change() {
108        use std::thread::sleep;
109        let dir = tempfile::tempdir().unwrap();
110        let counter = Arc::new(AtomicUsize::new(0));
111        let counter_for_cb = counter.clone();
112        let cb: ChangeHandler = Arc::new(move |_paths: &[PathBuf]| {
113            counter_for_cb.fetch_add(1, Ordering::SeqCst);
114        });
115        let _handle = watch(dir.path(), Some(cb), Some(Duration::from_millis(100))).unwrap();
116        sleep(Duration::from_millis(50)); // let watcher settle
117        std::fs::write(dir.path().join("a.txt"), "hi").unwrap();
118        sleep(Duration::from_millis(400)); // debounce + buffer
119        assert!(
120            counter.load(Ordering::SeqCst) >= 1,
121            "expected callback to fire at least once after file write"
122        );
123    }
124}