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