webui_dev_server/
watch.rs1use std::collections::hash_map::DefaultHasher;
12use std::collections::{HashMap, HashSet};
13use std::hash::Hasher;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17
18use anyhow::{Context, Result};
19use notify::RecommendedWatcher;
20use notify::RecursiveMode;
21use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
22
23pub struct WatcherHandle {
29 _debouncer: Debouncer<RecommendedWatcher>,
30}
31
32pub struct WatchConfig {
34 pub paths: Vec<PathBuf>,
37 pub explicit_files: Vec<PathBuf>,
43 pub ignore: Vec<PathBuf>,
50 pub debounce: Duration,
53 pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
59}
60
61pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
83where
84 F: Fn(Vec<PathBuf>) + Send + 'static,
85{
86 let ignore: Vec<PathBuf> = cfg
90 .ignore
91 .iter()
92 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
93 .collect();
94 let explicit_files: Vec<PathBuf> = cfg
95 .explicit_files
96 .iter()
97 .filter_map(|path| normalize_explicit_file(path))
98 .collect();
99
100 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
101 let retry_unchanged_when = cfg.retry_unchanged_when.clone();
102 let explicit_filter = explicit_files.clone();
103 let mut debouncer = new_debouncer(cfg.debounce, move |res: DebounceEventResult| match res {
104 Ok(events) => {
105 let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
108 let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
109 for e in events {
110 if should_ignore_event(&e.path, &ignore, &explicit_filter) {
111 continue;
112 }
113 if seen.insert(e.path.clone()) {
114 paths.push(e.path);
115 }
116 }
117 let retry_unchanged = retry_unchanged_when
125 .as_ref()
126 .is_some_and(|predicate| predicate());
127 paths.retain(|path| should_forward_path(&mut content_hashes, path, retry_unchanged));
128 if !paths.is_empty() {
129 on_event(paths);
130 }
131 }
132 Err(e) => {
133 eprintln!("watcher error: {e:?}");
134 }
135 })
136 .context("Cannot start file watcher")?;
137
138 let mut watched_roots = Vec::with_capacity(cfg.paths.len());
139 for p in &cfg.paths {
140 if p.exists() {
141 watched_roots.push(std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
142 debouncer
143 .watcher()
144 .watch(p, RecursiveMode::Recursive)
145 .with_context(|| format!("Cannot watch {}", p.display()))?;
146 }
147 }
148 let mut explicit_parents = HashSet::new();
149 for file in &explicit_files {
150 if watched_roots.iter().any(|root| file.starts_with(root)) {
151 continue;
152 }
153 let Some(parent) = file.parent() else {
154 continue;
155 };
156 if parent.exists() && explicit_parents.insert(parent.to_path_buf()) {
157 debouncer
158 .watcher()
159 .watch(parent, RecursiveMode::NonRecursive)
160 .with_context(|| format!("Cannot watch {}", parent.display()))?;
161 }
162 }
163
164 Ok(WatcherHandle {
165 _debouncer: debouncer,
166 })
167}
168
169fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
170 if let Ok(canonical) = std::fs::canonicalize(path) {
171 return Some(canonical);
172 }
173 let parent = path
174 .parent()
175 .filter(|value| !value.as_os_str().is_empty())
176 .unwrap_or_else(|| Path::new("."));
177 let parent = std::fs::canonicalize(parent).ok()?;
178 Some(parent.join(path.file_name()?))
179}
180
181fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
182 let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
183 explicit_files.iter().any(|path| path == &normalized)
184}
185
186fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
187 is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
188}
189
190fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
203 if ignore.is_empty() {
204 return false;
205 }
206 let canon = std::fs::canonicalize(event_path).ok();
207 let candidate: &Path = canon.as_deref().unwrap_or(event_path);
208
209 for root in ignore {
210 if root.is_absolute() {
211 if candidate.starts_with(root) {
212 return true;
213 }
214 continue;
215 }
216 let mut components = root.components();
220 let first = components.next();
221 let only_one_component = components.next().is_none();
222 if let (Some(first), true) = (first, only_one_component) {
223 let name = first.as_os_str();
224 if candidate.components().any(|c| c.as_os_str() == name) {
225 return true;
226 }
227 } else if candidate.starts_with(root) {
228 return true;
230 }
231 }
232 false
233}
234
235#[must_use]
240pub fn default_ignore_paths() -> Vec<PathBuf> {
241 vec![
242 PathBuf::from("node_modules"),
243 PathBuf::from(".git"),
244 PathBuf::from("target"),
245 ]
246}
247
248const MAX_HASH_BYTES: u64 = 8 * 1024 * 1024;
253
254fn content_changed(cache: &mut HashMap<PathBuf, u64>, path: &Path) -> bool {
260 match hash_file(path) {
261 Some(hash) => match cache.insert(path.to_path_buf(), hash) {
262 Some(previous) => previous != hash,
263 None => true,
264 },
265 None => {
266 cache.remove(path);
267 true
268 }
269 }
270}
271
272fn should_forward_path(
273 cache: &mut HashMap<PathBuf, u64>,
274 path: &Path,
275 retry_unchanged: bool,
276) -> bool {
277 content_changed(cache, path) || retry_unchanged
278}
279
280fn hash_file(path: &Path) -> Option<u64> {
284 let metadata = std::fs::metadata(path).ok()?;
285 if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES {
286 return None;
287 }
288 let bytes = std::fs::read(path).ok()?;
289 let mut hasher = DefaultHasher::new();
290 hasher.write(&bytes);
291 Some(hasher.finish())
292}
293
294#[cfg(test)]
295#[allow(clippy::disallowed_methods)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn is_ignored_matches_descendant() {
301 let dir = tempfile::tempdir().unwrap();
302 let dist = dir.path().join("dist");
303 std::fs::create_dir(&dist).unwrap();
304 let nested = dist.join("a/b.html");
305 std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
306 std::fs::write(&nested, "x").unwrap();
307
308 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
309 assert!(is_ignored(&nested, &ignore));
310 }
311
312 #[test]
313 fn explicit_manifest_bypasses_ignored_output_directory() {
314 let dir = tempfile::tempdir().unwrap();
315 let dist = dir.path().join("dist");
316 std::fs::create_dir(&dist).unwrap();
317 let manifest = dist.join("webui-projection.json");
318 let bundle = dist.join("index.js");
319 std::fs::write(&manifest, "{}").unwrap();
320 std::fs::write(&bundle, "export {};").unwrap();
321
322 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
323 let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
324 assert!(!should_ignore_event(&manifest, &ignore, &explicit));
325 assert!(should_ignore_event(&bundle, &ignore, &explicit));
326 }
327
328 #[test]
329 fn is_ignored_misses_sibling() {
330 let dir = tempfile::tempdir().unwrap();
331 let dist = dir.path().join("dist");
332 let other = dir.path().join("src/a.md");
333 std::fs::create_dir(&dist).unwrap();
334 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
335 std::fs::write(&other, "x").unwrap();
336
337 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
338 assert!(!is_ignored(&other, &ignore));
339 }
340
341 #[test]
342 fn is_ignored_is_false_when_no_ignores() {
343 let p = Path::new("/anything/at/all");
344 assert!(!is_ignored(p, &[]));
345 }
346
347 #[test]
348 fn is_ignored_matches_relative_component_anywhere() {
349 let ignore = vec![PathBuf::from("target")];
353 assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
354 assert!(is_ignored(
355 Path::new("/repo/sub/crate/target/foo.rs"),
356 &ignore
357 ));
358 assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
359 }
360
361 #[test]
362 fn is_ignored_handles_node_modules_and_git() {
363 let ignore = default_ignore_paths();
364 assert!(is_ignored(
365 Path::new("/repo/node_modules/foo/index.js"),
366 &ignore
367 ));
368 assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
369 assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
370 }
371
372 #[test]
373 fn content_changed_skips_identical_saves() {
374 let dir = tempfile::tempdir().unwrap();
375 let file = dir.path().join("a.css");
376 std::fs::write(&file, "a { color: red; }").unwrap();
377 let mut cache = HashMap::new();
378
379 assert!(content_changed(&mut cache, &file));
381 assert!(!content_changed(&mut cache, &file));
383 assert!(!content_changed(&mut cache, &file));
384
385 std::fs::write(&file, "a { color: blue; }").unwrap();
387 assert!(content_changed(&mut cache, &file));
388 assert!(!content_changed(&mut cache, &file));
390
391 std::fs::remove_file(&file).unwrap();
393 assert!(content_changed(&mut cache, &file));
394 std::fs::write(&file, "a { color: blue; }").unwrap();
396 assert!(content_changed(&mut cache, &file));
397 }
398
399 #[test]
400 fn unchanged_save_can_retry_when_error_active() {
401 let dir = tempfile::tempdir().unwrap();
402 let file = dir.path().join("a.css");
403 std::fs::write(&file, "a { color: red; }").unwrap();
404 let mut cache = HashMap::new();
405
406 assert!(should_forward_path(&mut cache, &file, false));
407 assert!(!should_forward_path(&mut cache, &file, false));
408 assert!(should_forward_path(&mut cache, &file, true));
409 }
410}