waterui_cli/debug/
file_watcher.rs

1//! File system watcher for hot reload.
2
3use std::path::Path;
4use std::sync::mpsc;
5use std::time::SystemTime;
6
7use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
8use smol::channel::{self, Receiver};
9
10/// Watches source files for changes and emits events.
11pub struct FileWatcher {
12    watcher: RecommendedWatcher,
13    rx: Receiver<()>,
14}
15
16impl std::fmt::Debug for FileWatcher {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_struct("FileWatcher").finish_non_exhaustive()
19    }
20}
21
22impl FileWatcher {
23    /// Create a new file watcher for the given project directory.
24    ///
25    /// Watches the `src/` directory for `.rs` file changes.
26    ///
27    /// # Errors
28    /// Returns an error if the watcher cannot be created.
29    pub fn new(project_path: &Path) -> notify::Result<Self> {
30        let (tx, rx) = channel::unbounded();
31
32        // Create a sync channel for notify (which uses std::sync::mpsc)
33        let (sync_tx, sync_rx) = mpsc::channel::<notify::Result<Event>>();
34
35        // Spawn a task to bridge sync channel to async channel
36        let tx_clone = tx;
37        let started_at = SystemTime::now();
38        std::thread::spawn(move || {
39            while let Ok(event) = sync_rx.recv() {
40                if let Ok(event) = event {
41                    // Only trigger on Rust file modifications
42                    if is_relevant_change(&event, started_at) {
43                        let _ = tx_clone.send_blocking(());
44                    }
45                }
46            }
47        });
48
49        let watcher = notify::recommended_watcher(move |res| {
50            let _ = sync_tx.send(res);
51        })?;
52
53        let mut file_watcher = Self { watcher, rx };
54
55        // Watch src directory
56        let src_path = project_path.join("src");
57        if src_path.exists() {
58            file_watcher
59                .watcher
60                .watch(&src_path, RecursiveMode::Recursive)?;
61        }
62
63        Ok(file_watcher)
64    }
65
66    /// Returns a receiver for file change events.
67    ///
68    /// Each receive indicates that source files have changed and a rebuild may be needed.
69    #[must_use]
70    pub const fn receiver(&self) -> &Receiver<()> {
71        &self.rx
72    }
73}
74
75/// Check if the event is a relevant change (Rust source file modification).
76fn is_relevant_change(event: &Event, started_at: SystemTime) -> bool {
77    use notify::{EventKind, event::ModifyKind};
78
79    // Only care about changes that can affect a build. On macOS it's common to receive follow-up
80    // metadata-only modifications for a save; ignore those to avoid redundant rebuilds.
81    let kind = &event.kind;
82    let is_relevant_kind = match kind {
83        EventKind::Create(_) | EventKind::Remove(_) => true,
84        EventKind::Modify(modify_kind) => !matches!(modify_kind, ModifyKind::Metadata(_)),
85        _ => false,
86    };
87
88    if !is_relevant_kind {
89        return false;
90    }
91
92    event
93        .paths
94        .iter()
95        .any(|path| is_relevant_path(path, *kind, started_at))
96}
97
98fn is_relevant_path(path: &Path, kind: notify::EventKind, started_at: SystemTime) -> bool {
99    use notify::{EventKind, event::ModifyKind};
100
101    if !path
102        .extension()
103        .is_some_and(|ext| ext == "rs" || ext == "toml")
104    {
105        return false;
106    }
107
108    // Deletions are always relevant.
109    if matches!(kind, EventKind::Remove(_)) {
110        return true;
111    }
112
113    // Renames don't necessarily update the mtime; treat them as relevant if they touch a watched
114    // file path.
115    if matches!(kind, EventKind::Modify(ModifyKind::Name(_))) {
116        return true;
117    }
118
119    // Some backends can emit initial "create/modify" events for pre-existing files when a watch
120    // is first installed. Filter those out by requiring the file to have been modified after we
121    // started watching.
122    std::fs::metadata(path)
123        .and_then(|m| m.modified())
124        .map_or(true, |modified| modified > started_at)
125}