1use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use notify::{
18 event::{AccessKind, AccessMode},
19 Event, EventHandler, EventKind, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind,
20};
21use notify_debouncer_mini::{
22 new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, Debouncer,
23};
24
25#[path = "watch_hash.rs"]
26mod hash;
27
28#[cfg(test)]
29#[path = "watch_hash_tests.rs"]
30mod hash_tests;
31
32use hash::{hash_file, HASH_BUFFER_SIZE};
33
34pub struct WatcherHandle {
40 _debouncer: Debouncer<ContentChangeWatcher>,
41}
42
43struct ContentChangeWatcher(RecommendedWatcher);
44
45impl Watcher for ContentChangeWatcher {
46 fn new<F: EventHandler>(mut event_handler: F, config: notify::Config) -> notify::Result<Self> {
47 RecommendedWatcher::new(
48 move |event| {
49 if matches!(&event, Ok(event) if is_read_only_access_event(event)) {
50 return;
51 }
52 event_handler.handle_event(event);
53 },
54 config,
55 )
56 .map(Self)
57 }
58
59 fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
60 self.0.watch(path, recursive_mode)
61 }
62
63 fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
64 self.0.unwatch(path)
65 }
66
67 fn configure(&mut self, config: notify::Config) -> notify::Result<bool> {
68 self.0.configure(config)
69 }
70
71 fn kind() -> WatcherKind {
72 RecommendedWatcher::kind()
73 }
74}
75
76fn is_read_only_access_event(event: &Event) -> bool {
77 matches!(
78 event.kind,
79 EventKind::Access(
80 AccessKind::Read
81 | AccessKind::Open(AccessMode::Read)
82 | AccessKind::Close(AccessMode::Read)
83 )
84 )
85}
86
87pub struct WatchConfig {
89 pub paths: Vec<PathBuf>,
92 pub explicit_files: Vec<PathBuf>,
98 pub ignore: Vec<PathBuf>,
105 pub debounce: Duration,
108 pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
114}
115
116pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
138where
139 F: Fn(Vec<PathBuf>) + Send + 'static,
140{
141 let ignore: Vec<PathBuf> = cfg
145 .ignore
146 .iter()
147 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
148 .collect();
149 let explicit_files: Vec<PathBuf> = cfg
150 .explicit_files
151 .iter()
152 .filter_map(|path| normalize_explicit_file(path))
153 .collect();
154
155 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
156 let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
157 let retry_unchanged_when = cfg.retry_unchanged_when.clone();
158 let explicit_filter = explicit_files.clone();
159 let notify_config = notify::Config::default().with_follow_symlinks(false);
160 let debouncer_config = DebouncerConfig::default()
161 .with_timeout(cfg.debounce)
162 .with_notify_config(notify_config);
163 let mut debouncer = new_debouncer_opt::<_, ContentChangeWatcher>(
164 debouncer_config,
165 move |res: DebounceEventResult| match res {
166 Ok(events) => {
167 let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
170 let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
171 for e in events {
172 if should_ignore_event(&e.path, &ignore, &explicit_filter) {
173 continue;
174 }
175 if seen.insert(e.path.clone()) {
176 paths.push(e.path);
177 }
178 }
179 paths.retain(|path| !path.is_dir());
184 let retry_unchanged = retry_unchanged_when
192 .as_ref()
193 .is_some_and(|predicate| predicate());
194 paths.retain(|path| {
195 should_forward_path(
196 &mut content_hashes,
197 path,
198 retry_unchanged,
199 &mut hash_buffer,
200 )
201 });
202 if !paths.is_empty() {
203 on_event(paths);
204 }
205 }
206 Err(e) => {
207 eprintln!("watcher error: {e:?}");
208 }
209 },
210 )
211 .context("Cannot start file watcher")?;
212
213 let mut watched_roots = Vec::with_capacity(cfg.paths.len());
214 for p in &cfg.paths {
215 if p.exists() {
216 watched_roots.push(std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
217 debouncer
218 .watcher()
219 .watch(p, RecursiveMode::Recursive)
220 .with_context(|| format!("Cannot watch {}", p.display()))?;
221 }
222 }
223 let mut explicit_parents = HashSet::new();
224 for file in &explicit_files {
225 if watched_roots.iter().any(|root| file.starts_with(root)) {
226 continue;
227 }
228 let Some(parent) = file.parent() else {
229 continue;
230 };
231 if parent.exists() && explicit_parents.insert(parent.to_path_buf()) {
232 debouncer
233 .watcher()
234 .watch(parent, RecursiveMode::NonRecursive)
235 .with_context(|| format!("Cannot watch {}", parent.display()))?;
236 }
237 }
238
239 Ok(WatcherHandle {
240 _debouncer: debouncer,
241 })
242}
243
244fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
245 if let Ok(canonical) = std::fs::canonicalize(path) {
246 return Some(canonical);
247 }
248 let parent = path
249 .parent()
250 .filter(|value| !value.as_os_str().is_empty())
251 .unwrap_or_else(|| Path::new("."));
252 let parent = std::fs::canonicalize(parent).ok()?;
253 Some(parent.join(path.file_name()?))
254}
255
256fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
257 let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
258 explicit_files.iter().any(|path| path == &normalized)
259}
260
261fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
262 is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
263}
264
265fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
278 if ignore.is_empty() {
279 return false;
280 }
281 let canon = std::fs::canonicalize(event_path).ok();
282 let candidate: &Path = canon.as_deref().unwrap_or(event_path);
283
284 for root in ignore {
285 if root.is_absolute() {
286 if candidate.starts_with(root) {
287 return true;
288 }
289 continue;
290 }
291 let mut components = root.components();
295 let first = components.next();
296 let only_one_component = components.next().is_none();
297 if let (Some(first), true) = (first, only_one_component) {
298 let name = first.as_os_str();
299 if event_path.components().any(|c| c.as_os_str() == name)
303 || candidate.components().any(|c| c.as_os_str() == name)
304 {
305 return true;
306 }
307 } else if candidate.starts_with(root) {
308 return true;
310 }
311 }
312 false
313}
314
315#[must_use]
320pub fn default_ignore_paths() -> Vec<PathBuf> {
321 vec![
322 PathBuf::from("node_modules"),
323 PathBuf::from(".git"),
324 PathBuf::from("target"),
325 ]
326}
327
328fn content_changed(
334 cache: &mut HashMap<PathBuf, u64>,
335 path: &Path,
336 buffer: &mut [u8; HASH_BUFFER_SIZE],
337) -> bool {
338 match hash_file(path, buffer) {
339 Some(hash) => match cache.insert(path.to_path_buf(), hash) {
340 Some(previous) => previous != hash,
341 None => true,
342 },
343 None => {
344 cache.remove(path);
345 true
346 }
347 }
348}
349
350fn should_forward_path(
351 cache: &mut HashMap<PathBuf, u64>,
352 path: &Path,
353 retry_unchanged: bool,
354 buffer: &mut [u8; HASH_BUFFER_SIZE],
355) -> bool {
356 content_changed(cache, path, buffer) || retry_unchanged
357}
358
359#[cfg(test)]
360#[allow(clippy::disallowed_methods)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn is_ignored_matches_descendant() {
366 let dir = tempfile::tempdir().unwrap();
367 let dist = dir.path().join("dist");
368 std::fs::create_dir(&dist).unwrap();
369 let nested = dist.join("a/b.html");
370 std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
371 std::fs::write(&nested, "x").unwrap();
372
373 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
374 assert!(is_ignored(&nested, &ignore));
375 }
376
377 #[test]
378 fn explicit_manifest_bypasses_ignored_output_directory() {
379 let dir = tempfile::tempdir().unwrap();
380 let dist = dir.path().join("dist");
381 std::fs::create_dir(&dist).unwrap();
382 let manifest = dist.join("webui-projection.json");
383 let bundle = dist.join("index.js");
384 std::fs::write(&manifest, "{}").unwrap();
385 std::fs::write(&bundle, "export {};").unwrap();
386
387 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
388 let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
389 assert!(!should_ignore_event(&manifest, &ignore, &explicit));
390 assert!(should_ignore_event(&bundle, &ignore, &explicit));
391 }
392
393 #[test]
394 fn is_ignored_misses_sibling() {
395 let dir = tempfile::tempdir().unwrap();
396 let dist = dir.path().join("dist");
397 let other = dir.path().join("src/a.md");
398 std::fs::create_dir(&dist).unwrap();
399 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
400 std::fs::write(&other, "x").unwrap();
401
402 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
403 assert!(!is_ignored(&other, &ignore));
404 }
405
406 #[test]
407 fn is_ignored_is_false_when_no_ignores() {
408 let p = Path::new("/anything/at/all");
409 assert!(!is_ignored(p, &[]));
410 }
411
412 #[test]
413 fn is_ignored_matches_relative_component_anywhere() {
414 let ignore = vec![PathBuf::from("target")];
418 assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
419 assert!(is_ignored(
420 Path::new("/repo/sub/crate/target/foo.rs"),
421 &ignore
422 ));
423 assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
424 }
425
426 #[test]
427 fn is_ignored_handles_node_modules_and_git() {
428 let ignore = default_ignore_paths();
429 assert!(is_ignored(
430 Path::new("/repo/node_modules/foo/index.js"),
431 &ignore
432 ));
433 assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
434 assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
435 }
436
437 #[test]
438 fn read_only_access_events_are_not_content_changes() {
439 use notify::event::ModifyKind;
440
441 for kind in [
442 AccessKind::Read,
443 AccessKind::Open(AccessMode::Read),
444 AccessKind::Close(AccessMode::Read),
445 ] {
446 assert!(is_read_only_access_event(&Event::new(EventKind::Access(
447 kind
448 ))));
449 }
450 for kind in [
451 AccessKind::Any,
452 AccessKind::Open(AccessMode::Write),
453 AccessKind::Close(AccessMode::Write),
454 ] {
455 assert!(!is_read_only_access_event(&Event::new(EventKind::Access(
456 kind
457 ))));
458 }
459 assert!(!is_read_only_access_event(&Event::new(EventKind::Modify(
460 ModifyKind::Any
461 ))));
462 }
463
464 #[cfg(unix)]
465 #[test]
466 fn is_ignored_matches_node_modules_symlink_path() {
467 use std::os::unix::fs::symlink;
468
469 let dir = tempfile::tempdir().unwrap();
470 let package = dir.path().join("packages/example");
471 let node_modules = dir.path().join("node_modules");
472 std::fs::create_dir_all(&package).unwrap();
473 std::fs::create_dir(&node_modules).unwrap();
474 std::fs::write(package.join("index.js"), "export {};").unwrap();
475 symlink(&package, node_modules.join("example")).unwrap();
476
477 let linked_file = node_modules.join("example/index.js");
478 let canonical = std::fs::canonicalize(&linked_file).unwrap();
479 assert!(!canonical
480 .components()
481 .any(|component| component.as_os_str() == "node_modules"));
482 assert!(is_ignored(&linked_file, &[PathBuf::from("node_modules")]));
483 }
484
485 #[test]
486 fn content_changed_skips_identical_saves() {
487 let dir = tempfile::tempdir().unwrap();
488 let file = dir.path().join("a.css");
489 std::fs::write(&file, "a { color: red; }").unwrap();
490 let mut cache = HashMap::new();
491 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
492
493 assert!(content_changed(&mut cache, &file, &mut buffer));
495 assert!(!content_changed(&mut cache, &file, &mut buffer));
497 assert!(!content_changed(&mut cache, &file, &mut buffer));
498
499 std::fs::write(&file, "a { color: blue; }").unwrap();
501 assert!(content_changed(&mut cache, &file, &mut buffer));
502 assert!(!content_changed(&mut cache, &file, &mut buffer));
504
505 std::fs::remove_file(&file).unwrap();
507 assert!(content_changed(&mut cache, &file, &mut buffer));
508 std::fs::write(&file, "a { color: blue; }").unwrap();
510 assert!(content_changed(&mut cache, &file, &mut buffer));
511 }
512
513 #[test]
514 fn unchanged_save_can_retry_when_error_active() {
515 let dir = tempfile::tempdir().unwrap();
516 let file = dir.path().join("a.css");
517 std::fs::write(&file, "a { color: red; }").unwrap();
518 let mut cache = HashMap::new();
519 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
520
521 assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
522 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
523 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
524 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
525
526 std::fs::write(&file, "a { color: blue; }").unwrap();
527 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
528 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
529 }
530
531 #[test]
532 fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
533 let dir = tempfile::tempdir()?;
534 let file = dir.path().join("a.css");
535 let mut cache = HashMap::new();
536 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
537
538 for replacement in ["deleted", "directory", "oversized"] {
539 std::fs::write(&file, "original")?;
540 assert!(content_changed(&mut cache, &file, &mut buffer));
541 assert!(!content_changed(&mut cache, &file, &mut buffer));
542
543 std::fs::remove_file(&file)?;
544 match replacement {
545 "directory" => std::fs::create_dir(&file)?,
546 "oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
547 _ => {}
548 }
549 for _ in 0..2 {
550 assert!(content_changed(&mut cache, &file, &mut buffer));
551 assert!(!cache.contains_key(&file));
552 }
553 match replacement {
554 "directory" => std::fs::remove_dir(&file)?,
555 "oversized" => std::fs::remove_file(&file)?,
556 _ => {}
557 }
558 }
559 std::fs::write(&file, "original")?;
560 assert!(content_changed(&mut cache, &file, &mut buffer));
561 Ok(())
562 }
563
564 #[test]
565 fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
566 let dir = tempfile::tempdir()?;
567 let paths = [
568 dir.path().join("large.css"),
569 dir.path().join("empty.css"),
570 dir.path().join("small.css"),
571 ];
572 for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
573 std::fs::write(path, vec![b'x'; size])?;
574 }
575 let mut cache = HashMap::new();
576 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
577 for changed in [true, false, false] {
578 for path in &paths {
579 assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
580 }
581 }
582 Ok(())
583 }
584
585 #[cfg(unix)]
586 #[test]
587 fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
588 let dir = tempfile::tempdir()?;
589 let file = dir.path().join("unreadable.css");
590 std::fs::write(&file, "original")?;
591 let mut cache = HashMap::new();
592 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
593 assert!(content_changed(&mut cache, &file, &mut buffer));
594 std::fs::remove_file(&file)?;
595 std::os::unix::fs::symlink("unreadable.css", &file)?;
596 assert!(content_changed(&mut cache, &file, &mut buffer));
597 assert!(!cache.contains_key(&file));
598 std::fs::remove_file(&file)?;
599 std::fs::write(&file, "original")?;
600 assert!(content_changed(&mut cache, &file, &mut buffer));
601 Ok(())
602 }
603}