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        additional_watch_paths: &[PathBuf],
40        tx: mpsc::UnboundedSender<WatchEvent>,
41        shutdown_rx: watch::Receiver<bool>,
42    ) -> Result<Self> {
43        let engine_dir_path = engine_dir.to_path_buf();
44        let watched_root = Arc::new(engine_dir_path.clone());
45        let shutdown_rx_for_events = shutdown_rx.clone();
46
47        let mut debouncer = new_debouncer(
48            Duration::from_millis(500),
49            None, // no tick rate override
50            move |result: DebounceEventResult| {
51                Self::handle_events(
52                    result,
53                    Arc::clone(&watched_root),
54                    &tx,
55                    &shutdown_rx_for_events,
56                );
57            },
58        )?;
59
60        // Watch engine/src recursively
61        let src_path = engine_dir_path.join("src");
62        debouncer.watch(&src_path, RecursiveMode::Recursive)?;
63
64        // Watch engine/Cargo.toml non-recursively
65        let cargo_toml = engine_dir_path.join("Cargo.toml");
66        if cargo_toml.exists() {
67            debouncer.watch(&cargo_toml, RecursiveMode::NonRecursive)?;
68        }
69
70        for path in additional_watch_paths {
71            if path.is_dir() {
72                debouncer.watch(path, RecursiveMode::Recursive)?;
73            } else if path.is_file() {
74                debouncer.watch(path, RecursiveMode::NonRecursive)?;
75            }
76        }
77
78        Ok(Self {
79            _debouncer: debouncer,
80            _shutdown_rx: shutdown_rx,
81        })
82    }
83
84    /// Handle debounced file events
85    fn handle_events(
86        result: DebounceEventResult,
87        engine_dir: Arc<PathBuf>,
88        tx: &mpsc::UnboundedSender<WatchEvent>,
89        shutdown_rx: &watch::Receiver<bool>,
90    ) {
91        if *shutdown_rx.borrow() {
92            return;
93        }
94
95        let events = match result {
96            Ok(events) => events,
97            Err(errors) => {
98                for error in errors {
99                    eprintln!("Warning: file watcher error: {:?}", error);
100                }
101                return;
102            }
103        };
104
105        // Filter to relevant files
106        let mut changed_paths = Vec::new();
107        for event in events {
108            for path in &event.paths {
109                if Self::is_relevant_file(path, &engine_dir) {
110                    changed_paths.push(path.clone());
111                }
112            }
113        }
114
115        if !changed_paths.is_empty() {
116            // Deduplicate paths
117            changed_paths.sort();
118            changed_paths.dedup();
119
120            if let Err(e) = tx.send(WatchEvent::RustFilesChanged(changed_paths)) {
121                eprintln!(
122                    "Warning: File watcher failed to send event (channel closed): {:?}",
123                    e
124                );
125            }
126        }
127    }
128
129    /// Check if a file path is relevant for hot-reload
130    fn is_relevant_file(path: &Path, engine_dir: &Path) -> bool {
131        // Ignore target/ directory
132        if path.starts_with(engine_dir.join("target")) {
133            return false;
134        }
135
136        // Ignore hidden files and directories
137        if path
138            .components()
139            .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
140        {
141            return false;
142        }
143
144        let file_name = match path.file_name() {
145            Some(name) => name.to_string_lossy(),
146            None => return false,
147        };
148
149        // Ignore editor temp files
150        if Self::is_editor_temp_file(&file_name) {
151            return false;
152        }
153
154        // Accept Rust sources and Cargo manifests
155        file_name.ends_with(".rs") || file_name == "Cargo.toml"
156    }
157
158    fn is_editor_temp_file(file_name: &str) -> bool {
159        file_name.ends_with(".swp")
160            || file_name.ends_with(".swo")
161            || file_name.ends_with('~')
162            || file_name.starts_with(".#")
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_is_relevant_file() {
172        let engine_dir = PathBuf::from("/project/engine");
173
174        // Should accept .rs files
175        assert!(FileWatcher::is_relevant_file(
176            &engine_dir.join("src/lib.rs"),
177            &engine_dir
178        ));
179        assert!(FileWatcher::is_relevant_file(
180            &engine_dir.join("src/dsp/test_tone.rs"),
181            &engine_dir
182        ));
183
184        // Should accept Cargo.toml
185        assert!(FileWatcher::is_relevant_file(
186            &engine_dir.join("Cargo.toml"),
187            &engine_dir
188        ));
189
190        // Should reject target/ files
191        assert!(!FileWatcher::is_relevant_file(
192            &engine_dir.join("target/debug/libfoo.dylib"),
193            &engine_dir
194        ));
195
196        // Should reject hidden files
197        assert!(!FileWatcher::is_relevant_file(
198            &engine_dir.join("src/.hidden.rs"),
199            &engine_dir
200        ));
201
202        // Should reject editor temp files
203        assert!(!FileWatcher::is_relevant_file(
204            &engine_dir.join("src/lib.rs.swp"),
205            &engine_dir
206        ));
207        assert!(!FileWatcher::is_relevant_file(
208            &engine_dir.join("src/lib.rs~"),
209            &engine_dir
210        ));
211        assert!(!FileWatcher::is_relevant_file(
212            &engine_dir.join("src/.#lib.rs"),
213            &engine_dir
214        ));
215
216        // Should reject non-Rust files
217        assert!(!FileWatcher::is_relevant_file(
218            &engine_dir.join("src/data.json"),
219            &engine_dir
220        ));
221    }
222}