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