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