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 _debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
24 _shutdown_rx: watch::Receiver<bool>,
25}
26
27impl FileWatcher {
28 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, 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 let src_path = engine_dir_path.join("src");
62 debouncer.watch(&src_path, RecursiveMode::Recursive)?;
63
64 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 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 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 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 fn is_relevant_file(path: &Path, engine_dir: &Path) -> bool {
131 if path.starts_with(engine_dir.join("target")) {
133 return false;
134 }
135
136 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 if Self::is_editor_temp_file(&file_name) {
151 return false;
152 }
153
154 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 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 assert!(FileWatcher::is_relevant_file(
186 &engine_dir.join("Cargo.toml"),
187 &engine_dir
188 ));
189
190 assert!(!FileWatcher::is_relevant_file(
192 &engine_dir.join("target/debug/libfoo.dylib"),
193 &engine_dir
194 ));
195
196 assert!(!FileWatcher::is_relevant_file(
198 &engine_dir.join("src/.hidden.rs"),
199 &engine_dir
200 ));
201
202 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 assert!(!FileWatcher::is_relevant_file(
218 &engine_dir.join("src/data.json"),
219 &engine_dir
220 ));
221 }
222}