wavecraft_dev_server/reload/
watcher.rs1use 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#[derive(Debug, Clone)]
16pub enum WatchEvent {
17 RustFilesChanged(Vec<PathBuf>),
19}
20
21pub struct FileWatcher {
23 #[allow(dead_code)] debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
25 #[allow(dead_code)] _shutdown_rx: watch::Receiver<bool>,
27}
28
29impl FileWatcher {
30 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, 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 let src_path = engine_dir_clone.join("src");
63 debouncer.watch(&src_path, RecursiveMode::Recursive)?;
64
65 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 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 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 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 fn is_relevant_file(path: &Path, engine_dir: &Path) -> bool {
124 if path.starts_with(engine_dir.join("target")) {
126 return false;
127 }
128
129 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 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 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 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 assert!(FileWatcher::is_relevant_file(
176 &engine_dir.join("Cargo.toml"),
177 &engine_dir
178 ));
179
180 assert!(!FileWatcher::is_relevant_file(
182 &engine_dir.join("target/debug/libfoo.dylib"),
183 &engine_dir
184 ));
185
186 assert!(!FileWatcher::is_relevant_file(
188 &engine_dir.join("src/.hidden.rs"),
189 &engine_dir
190 ));
191
192 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 assert!(!FileWatcher::is_relevant_file(
208 &engine_dir.join("src/data.json"),
209 &engine_dir
210 ));
211 }
212}