Skip to main content

wavecraft_dev_server/reload/
watcher.rs

1//! File watching for Rust hot-reload
2//!
3//! Watches engine source files and triggers rebuild events on changes.
4//! Uses debouncing to handle rapid file saves and editor temp files.
5
6use anyhow::Result;
7use notify::RecursiveMode;
8use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::{mpsc, watch};
13
14/// Events emitted by the file watcher
15#[derive(Debug, Clone)]
16pub enum WatchEvent {
17    /// Rust source files changed (list of changed paths)
18    RustFilesChanged(Vec<PathBuf>),
19}
20
21/// File watcher with debouncing for Rust source files
22pub struct FileWatcher {
23    _debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
24    _shutdown_rx: watch::Receiver<bool>,
25}
26
27impl FileWatcher {
28    /// Create a new file watcher.
29    ///
30    /// Watches `engine/src/**/*.rs` and `engine/Cargo.toml` for changes.
31    /// Events are debounced with a 500ms timeout to handle rapid multi-file saves.
32    ///
33    /// # Arguments
34    ///
35    /// * `engine_dir` - Path to the engine directory (containing src/ and Cargo.toml)
36    /// * `tx` - Channel to send watch events to
37    pub fn new(
38        engine_dir: &Path,
39        tx: mpsc::UnboundedSender<WatchEvent>,
40        shutdown_rx: watch::Receiver<bool>,
41    ) -> Result<Self> {
42        let engine_dir_path = engine_dir.to_path_buf();
43        let watched_root = Arc::new(engine_dir_path.clone());
44        let shutdown_rx_for_events = shutdown_rx.clone();
45
46        let mut debouncer = new_debouncer(
47            Duration::from_millis(500),
48            None, // no tick rate override
49            move |result: DebounceEventResult| {
50                Self::handle_events(
51                    result,
52                    Arc::clone(&watched_root),
53                    &tx,
54                    &shutdown_rx_for_events,
55                );
56            },
57        )?;
58
59        // Watch engine/src recursively
60        let src_path = engine_dir_path.join("src");
61        debouncer.watch(&src_path, RecursiveMode::Recursive)?;
62
63        // Watch engine/Cargo.toml non-recursively
64        let cargo_toml = engine_dir_path.join("Cargo.toml");
65        if cargo_toml.exists() {
66            debouncer.watch(&cargo_toml, RecursiveMode::NonRecursive)?;
67        }
68
69        Ok(Self {
70            _debouncer: debouncer,
71            _shutdown_rx: shutdown_rx,
72        })
73    }
74
75    /// Handle debounced file events
76    fn handle_events(
77        result: DebounceEventResult,
78        engine_dir: Arc<PathBuf>,
79        tx: &mpsc::UnboundedSender<WatchEvent>,
80        shutdown_rx: &watch::Receiver<bool>,
81    ) {
82        if *shutdown_rx.borrow() {
83            return;
84        }
85
86        let events = match result {
87            Ok(events) => events,
88            Err(errors) => {
89                for error in errors {
90                    eprintln!("Warning: file watcher error: {:?}", error);
91                }
92                return;
93            }
94        };
95
96        // Filter to relevant files
97        let mut changed_paths = Vec::new();
98        for event in events {
99            for path in &event.paths {
100                if Self::is_relevant_file(path, &engine_dir) {
101                    changed_paths.push(path.clone());
102                }
103            }
104        }
105
106        if !changed_paths.is_empty() {
107            // Deduplicate paths
108            changed_paths.sort();
109            changed_paths.dedup();
110
111            if let Err(e) = tx.send(WatchEvent::RustFilesChanged(changed_paths)) {
112                eprintln!(
113                    "Warning: File watcher failed to send event (channel closed): {:?}",
114                    e
115                );
116            }
117        }
118    }
119
120    /// Check if a file path is relevant for hot-reload
121    fn is_relevant_file(path: &Path, engine_dir: &Path) -> bool {
122        // Ignore target/ directory
123        if path.starts_with(engine_dir.join("target")) {
124            return false;
125        }
126
127        // Ignore hidden files and directories
128        if path
129            .components()
130            .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
131        {
132            return false;
133        }
134
135        let file_name = match path.file_name() {
136            Some(name) => name.to_string_lossy(),
137            None => return false,
138        };
139
140        // Ignore editor temp files
141        if Self::is_editor_temp_file(&file_name) {
142            return false;
143        }
144
145        // Accept .rs files and Cargo.toml
146        file_name.ends_with(".rs") || file_name == "Cargo.toml"
147    }
148
149    fn is_editor_temp_file(file_name: &str) -> bool {
150        file_name.ends_with(".swp")
151            || file_name.ends_with(".swo")
152            || file_name.ends_with('~')
153            || file_name.starts_with(".#")
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_is_relevant_file() {
163        let engine_dir = PathBuf::from("/project/engine");
164
165        // Should accept .rs files
166        assert!(FileWatcher::is_relevant_file(
167            &engine_dir.join("src/lib.rs"),
168            &engine_dir
169        ));
170        assert!(FileWatcher::is_relevant_file(
171            &engine_dir.join("src/dsp/oscillator.rs"),
172            &engine_dir
173        ));
174
175        // Should accept Cargo.toml
176        assert!(FileWatcher::is_relevant_file(
177            &engine_dir.join("Cargo.toml"),
178            &engine_dir
179        ));
180
181        // Should reject target/ files
182        assert!(!FileWatcher::is_relevant_file(
183            &engine_dir.join("target/debug/libfoo.dylib"),
184            &engine_dir
185        ));
186
187        // Should reject hidden files
188        assert!(!FileWatcher::is_relevant_file(
189            &engine_dir.join("src/.hidden.rs"),
190            &engine_dir
191        ));
192
193        // Should reject editor temp files
194        assert!(!FileWatcher::is_relevant_file(
195            &engine_dir.join("src/lib.rs.swp"),
196            &engine_dir
197        ));
198        assert!(!FileWatcher::is_relevant_file(
199            &engine_dir.join("src/lib.rs~"),
200            &engine_dir
201        ));
202        assert!(!FileWatcher::is_relevant_file(
203            &engine_dir.join("src/.#lib.rs"),
204            &engine_dir
205        ));
206
207        // Should reject non-Rust files
208        assert!(!FileWatcher::is_relevant_file(
209            &engine_dir.join("src/data.json"),
210            &engine_dir
211        ));
212    }
213}