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>,
100 pub ignore: Vec<PathBuf>,
107 pub debounce: Duration,
110 pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
116}
117
118pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
140where
141 F: Fn(Vec<PathBuf>) + Send + 'static,
142{
143 let ignore: Vec<PathBuf> = cfg
147 .ignore
148 .iter()
149 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
150 .collect();
151 let explicit_files: Vec<PathBuf> = cfg
152 .explicit_files
153 .iter()
154 .filter_map(|path| normalize_explicit_file(path))
155 .collect();
156
157 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
158 let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
159 for file in &explicit_files {
160 content_changed(&mut content_hashes, file, &mut hash_buffer);
161 }
162 let retry_unchanged_when = cfg.retry_unchanged_when.clone();
163 let explicit_filter = explicit_files.clone();
164 let notify_config = notify::Config::default().with_follow_symlinks(false);
165 let debouncer_config = DebouncerConfig::default()
166 .with_timeout(cfg.debounce)
167 .with_notify_config(notify_config);
168 let mut debouncer = new_debouncer_opt::<_, ContentChangeWatcher>(
169 debouncer_config,
170 move |res: DebounceEventResult| match res {
171 Ok(events) => {
172 let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
175 let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
176 for e in events {
177 collect_event_paths(&e.path, &ignore, &explicit_filter, &mut seen, &mut paths);
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 watch_explicit_parents(debouncer.watcher(), &explicit_files, &watched_roots)?;
224
225 Ok(WatcherHandle {
226 _debouncer: debouncer,
227 })
228}
229
230fn watch_explicit_parents(
231 watcher: &mut dyn Watcher,
232 explicit_files: &[PathBuf],
233 watched_roots: &[PathBuf],
234) -> Result<()> {
235 let mut explicit_parents = HashSet::new();
236 for file in explicit_files {
237 if watched_roots.iter().any(|root| file.starts_with(root)) {
238 continue;
239 }
240 while let Some(parent) = file.ancestors().skip(1).find(|parent| parent.is_dir()) {
241 if !explicit_parents.insert(parent.to_path_buf()) {
242 break;
243 }
244 watcher
245 .watch(parent, RecursiveMode::NonRecursive)
246 .with_context(|| format!("Cannot watch {}", parent.display()))?;
247 }
250 }
251
252 Ok(())
253}
254
255fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
256 if let Ok(canonical) = std::fs::canonicalize(path) {
257 return Some(canonical);
258 }
259 let path = std::path::absolute(path).ok()?;
260 for ancestor in path.ancestors().skip(1) {
261 if let Ok(canonical) = std::fs::canonicalize(ancestor) {
262 return Some(canonical.join(path.strip_prefix(ancestor).ok()?));
263 }
264 }
265 None
266}
267
268fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
269 let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
270 explicit_files.iter().any(|path| path == &normalized)
271}
272
273fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
274 is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
275}
276
277fn collect_event_paths(
278 event_path: &Path,
279 ignore: &[PathBuf],
280 explicit_files: &[PathBuf],
281 seen: &mut HashSet<PathBuf>,
282 paths: &mut Vec<PathBuf>,
283) {
284 let is_directory = event_path.is_dir();
285 if is_directory || !event_path.exists() {
286 if let Some(directory) = normalize_explicit_file(event_path) {
287 for file in explicit_files {
290 if file != &directory && file.starts_with(&directory) && seen.insert(file.clone()) {
291 paths.push(file.clone());
292 }
293 }
294 }
295 }
296 if !is_directory
297 && !should_ignore_event(event_path, ignore, explicit_files)
298 && seen.insert(event_path.to_path_buf())
299 {
300 paths.push(event_path.to_path_buf());
301 }
302}
303
304fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
317 if ignore.is_empty() {
318 return false;
319 }
320 let canon = std::fs::canonicalize(event_path).ok();
321 let candidate: &Path = canon.as_deref().unwrap_or(event_path);
322
323 for root in ignore {
324 if root.is_absolute() {
325 if candidate.starts_with(root) {
326 return true;
327 }
328 continue;
329 }
330 let mut components = root.components();
334 let first = components.next();
335 let only_one_component = components.next().is_none();
336 if let (Some(first), true) = (first, only_one_component) {
337 let name = first.as_os_str();
338 if event_path.components().any(|c| c.as_os_str() == name)
342 || candidate.components().any(|c| c.as_os_str() == name)
343 {
344 return true;
345 }
346 } else if candidate.starts_with(root) {
347 return true;
349 }
350 }
351 false
352}
353
354#[must_use]
359pub fn default_ignore_paths() -> Vec<PathBuf> {
360 vec![
361 PathBuf::from("node_modules"),
362 PathBuf::from(".git"),
363 PathBuf::from("target"),
364 ]
365}
366
367fn content_changed(
373 cache: &mut HashMap<PathBuf, u64>,
374 path: &Path,
375 buffer: &mut [u8; HASH_BUFFER_SIZE],
376) -> bool {
377 match hash_file(path, buffer) {
378 Some(hash) => match cache.insert(path.to_path_buf(), hash) {
379 Some(previous) => previous != hash,
380 None => true,
381 },
382 None => {
383 cache.remove(path);
384 true
385 }
386 }
387}
388
389fn should_forward_path(
390 cache: &mut HashMap<PathBuf, u64>,
391 path: &Path,
392 retry_unchanged: bool,
393 buffer: &mut [u8; HASH_BUFFER_SIZE],
394) -> bool {
395 content_changed(cache, path, buffer) || retry_unchanged
396}
397
398#[cfg(test)]
399#[allow(clippy::disallowed_methods)]
400mod tests {
401 use super::*;
402
403 #[derive(Default)]
404 struct RecordingWatcher {
405 paths: Vec<(PathBuf, RecursiveMode)>,
406 }
407
408 impl Watcher for RecordingWatcher {
409 fn new<F: EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
410 Ok(Self::default())
411 }
412
413 fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
414 self.paths.push((path.to_path_buf(), mode));
415 Ok(())
416 }
417
418 fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
419 Ok(())
420 }
421
422 fn kind() -> WatcherKind {
423 RecommendedWatcher::kind()
424 }
425 }
426
427 #[test]
428 fn missing_include_does_not_recursively_watch_ignored_ancestor() {
429 let root = tempfile::tempdir().unwrap();
430 let target = root.path().join("target");
431 std::fs::create_dir_all(target.join("debug/build/unrelated")).unwrap();
432 let file = normalize_explicit_file(&target.join("generated/new/state.json")).unwrap();
433 let mut watcher = RecordingWatcher::default();
434 watch_explicit_parents(&mut watcher, &[file], &[]).unwrap();
435 assert_eq!(
436 watcher.paths,
437 vec![(
438 std::fs::canonicalize(target).unwrap(),
439 RecursiveMode::NonRecursive
440 )]
441 );
442 }
443
444 #[test]
445 fn is_ignored_matches_descendant() {
446 let dir = tempfile::tempdir().unwrap();
447 let dist = dir.path().join("dist");
448 std::fs::create_dir(&dist).unwrap();
449 let nested = dist.join("a/b.html");
450 std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
451 std::fs::write(&nested, "x").unwrap();
452
453 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
454 assert!(is_ignored(&nested, &ignore));
455 }
456
457 #[test]
458 fn explicit_manifest_bypasses_ignored_output_directory() {
459 let dir = tempfile::tempdir().unwrap();
460 let dist = dir.path().join("dist");
461 std::fs::create_dir(&dist).unwrap();
462 let manifest = dist.join("webui-projection.json");
463 let bundle = dist.join("index.js");
464 std::fs::write(&manifest, "{}").unwrap();
465 std::fs::write(&bundle, "export {};").unwrap();
466
467 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
468 let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
469 assert!(!should_ignore_event(&manifest, &ignore, &explicit));
470 assert!(should_ignore_event(&bundle, &ignore, &explicit));
471 }
472
473 #[test]
474 fn explicit_file_tracks_missing_parent_directories() -> std::io::Result<()> {
475 let dir = tempfile::tempdir()?;
476 let file = dir.path().join("target/new/state.json");
477 let normalized = normalize_explicit_file(&file)
478 .ok_or_else(|| std::io::Error::other("missing include was discarded"))?;
479 assert_eq!(
480 normalized,
481 std::fs::canonicalize(dir.path())?.join("target/new/state.json")
482 );
483 std::fs::create_dir_all(
484 file.parent()
485 .ok_or_else(|| std::io::Error::other("missing state parent"))?,
486 )?;
487 std::fs::write(&file, "{}")?;
488 assert!(!should_ignore_event(
489 &file,
490 &[PathBuf::from("target")],
491 &[normalized]
492 ));
493 Ok(())
494 }
495
496 #[test]
497 fn directory_event_rechecks_explicit_files_created_before_recursive_watch() {
498 let dir = tempfile::tempdir().unwrap();
499 let directory = dir.path().join("target/new");
500 let file = directory.join("state.json");
501 let explicit = vec![normalize_explicit_file(&file).unwrap()];
502 std::fs::create_dir_all(&directory).unwrap();
503 std::fs::write(&file, "{}").unwrap();
504 let mut seen = HashSet::new();
505 let mut paths = Vec::new();
506 collect_event_paths(
507 &directory,
508 &[PathBuf::from("target")],
509 &explicit,
510 &mut seen,
511 &mut paths,
512 );
513 assert_eq!(paths, explicit);
514 }
515
516 #[test]
517 fn directory_rechecks_dedupe_and_skip_unchanged_explicit_files() {
518 let dir = tempfile::tempdir().unwrap();
519 let directory = dir.path().join("target/new");
520 std::fs::create_dir_all(&directory).unwrap();
521 let file = directory.join("state.json");
522 std::fs::write(&file, "{}").unwrap();
523 let explicit = vec![normalize_explicit_file(&file).unwrap()];
524 let mut hashes = HashMap::new();
525 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
526 assert!(content_changed(&mut hashes, &explicit[0], &mut buffer));
527 let mut seen = HashSet::new();
528 let mut paths = Vec::new();
529 for _ in 0..2 {
530 collect_event_paths(
531 &directory,
532 &[PathBuf::from("target")],
533 &explicit,
534 &mut seen,
535 &mut paths,
536 );
537 }
538 assert_eq!(paths, explicit);
539 assert!(!should_forward_path(
540 &mut hashes,
541 &paths[0],
542 false,
543 &mut buffer
544 ));
545 std::fs::write(&file, "{\"count\":7}").unwrap();
546 assert!(should_forward_path(
547 &mut hashes,
548 &paths[0],
549 false,
550 &mut buffer
551 ));
552 }
553
554 #[test]
555 fn directory_events_ignore_unrelated_files_and_recheck_removed_parents() {
556 let dir = tempfile::tempdir().unwrap();
557 let directory = dir.path().join("target/new");
558 let unrelated = dir.path().join("target/other");
559 std::fs::create_dir_all(&directory).unwrap();
560 std::fs::create_dir_all(&unrelated).unwrap();
561 let file = directory.join("state.json");
562 std::fs::write(&file, "{}").unwrap();
563 let explicit = vec![normalize_explicit_file(&file).unwrap()];
564 let ignore = vec![PathBuf::from("target")];
565 let mut seen = HashSet::new();
566 let mut paths = Vec::new();
567 collect_event_paths(&unrelated, &ignore, &explicit, &mut seen, &mut paths);
568 assert!(paths.is_empty());
569 std::fs::remove_file(&file).unwrap();
570 std::fs::remove_dir(&directory).unwrap();
571 collect_event_paths(&directory, &ignore, &explicit, &mut seen, &mut paths);
572 assert_eq!(paths, explicit);
573 }
574
575 #[test]
576 fn is_ignored_misses_sibling() {
577 let dir = tempfile::tempdir().unwrap();
578 let dist = dir.path().join("dist");
579 let other = dir.path().join("src/a.md");
580 std::fs::create_dir(&dist).unwrap();
581 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
582 std::fs::write(&other, "x").unwrap();
583
584 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
585 assert!(!is_ignored(&other, &ignore));
586 }
587
588 #[test]
589 fn is_ignored_is_false_when_no_ignores() {
590 let p = Path::new("/anything/at/all");
591 assert!(!is_ignored(p, &[]));
592 }
593
594 #[test]
595 fn is_ignored_matches_relative_component_anywhere() {
596 let ignore = vec![PathBuf::from("target")];
600 assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
601 assert!(is_ignored(
602 Path::new("/repo/sub/crate/target/foo.rs"),
603 &ignore
604 ));
605 assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
606 }
607
608 #[test]
609 fn is_ignored_handles_node_modules_and_git() {
610 let ignore = default_ignore_paths();
611 assert!(is_ignored(
612 Path::new("/repo/node_modules/foo/index.js"),
613 &ignore
614 ));
615 assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
616 assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
617 }
618
619 #[test]
620 fn read_only_access_events_are_not_content_changes() {
621 use notify::event::ModifyKind;
622
623 for kind in [
624 AccessKind::Read,
625 AccessKind::Open(AccessMode::Read),
626 AccessKind::Close(AccessMode::Read),
627 ] {
628 assert!(is_read_only_access_event(&Event::new(EventKind::Access(
629 kind
630 ))));
631 }
632 for kind in [
633 AccessKind::Any,
634 AccessKind::Open(AccessMode::Write),
635 AccessKind::Close(AccessMode::Write),
636 ] {
637 assert!(!is_read_only_access_event(&Event::new(EventKind::Access(
638 kind
639 ))));
640 }
641 assert!(!is_read_only_access_event(&Event::new(EventKind::Modify(
642 ModifyKind::Any
643 ))));
644 }
645
646 #[cfg(unix)]
647 #[test]
648 fn is_ignored_matches_node_modules_symlink_path() {
649 use std::os::unix::fs::symlink;
650
651 let dir = tempfile::tempdir().unwrap();
652 let package = dir.path().join("packages/example");
653 let node_modules = dir.path().join("node_modules");
654 std::fs::create_dir_all(&package).unwrap();
655 std::fs::create_dir(&node_modules).unwrap();
656 std::fs::write(package.join("index.js"), "export {};").unwrap();
657 symlink(&package, node_modules.join("example")).unwrap();
658
659 let linked_file = node_modules.join("example/index.js");
660 let canonical = std::fs::canonicalize(&linked_file).unwrap();
661 assert!(!canonical
662 .components()
663 .any(|component| component.as_os_str() == "node_modules"));
664 assert!(is_ignored(&linked_file, &[PathBuf::from("node_modules")]));
665 }
666
667 #[test]
668 fn content_changed_skips_identical_saves() {
669 let dir = tempfile::tempdir().unwrap();
670 let file = dir.path().join("a.css");
671 std::fs::write(&file, "a { color: red; }").unwrap();
672 let mut cache = HashMap::new();
673 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
674
675 assert!(content_changed(&mut cache, &file, &mut buffer));
677 assert!(!content_changed(&mut cache, &file, &mut buffer));
679 assert!(!content_changed(&mut cache, &file, &mut buffer));
680
681 std::fs::write(&file, "a { color: blue; }").unwrap();
683 assert!(content_changed(&mut cache, &file, &mut buffer));
684 assert!(!content_changed(&mut cache, &file, &mut buffer));
686
687 std::fs::remove_file(&file).unwrap();
689 assert!(content_changed(&mut cache, &file, &mut buffer));
690 std::fs::write(&file, "a { color: blue; }").unwrap();
692 assert!(content_changed(&mut cache, &file, &mut buffer));
693 }
694
695 #[test]
696 fn unchanged_save_can_retry_when_error_active() {
697 let dir = tempfile::tempdir().unwrap();
698 let file = dir.path().join("a.css");
699 std::fs::write(&file, "a { color: red; }").unwrap();
700 let mut cache = HashMap::new();
701 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
702
703 assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
704 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
705 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
706 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
707
708 std::fs::write(&file, "a { color: blue; }").unwrap();
709 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
710 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
711 }
712
713 #[test]
714 fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
715 let dir = tempfile::tempdir()?;
716 let file = dir.path().join("a.css");
717 let mut cache = HashMap::new();
718 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
719
720 for replacement in ["deleted", "directory", "oversized"] {
721 std::fs::write(&file, "original")?;
722 assert!(content_changed(&mut cache, &file, &mut buffer));
723 assert!(!content_changed(&mut cache, &file, &mut buffer));
724
725 std::fs::remove_file(&file)?;
726 match replacement {
727 "directory" => std::fs::create_dir(&file)?,
728 "oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
729 _ => {}
730 }
731 for _ in 0..2 {
732 assert!(content_changed(&mut cache, &file, &mut buffer));
733 assert!(!cache.contains_key(&file));
734 }
735 match replacement {
736 "directory" => std::fs::remove_dir(&file)?,
737 "oversized" => std::fs::remove_file(&file)?,
738 _ => {}
739 }
740 }
741 std::fs::write(&file, "original")?;
742 assert!(content_changed(&mut cache, &file, &mut buffer));
743 Ok(())
744 }
745
746 #[test]
747 fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
748 let dir = tempfile::tempdir()?;
749 let paths = [
750 dir.path().join("large.css"),
751 dir.path().join("empty.css"),
752 dir.path().join("small.css"),
753 ];
754 for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
755 std::fs::write(path, vec![b'x'; size])?;
756 }
757 let mut cache = HashMap::new();
758 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
759 for changed in [true, false, false] {
760 for path in &paths {
761 assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
762 }
763 }
764 Ok(())
765 }
766
767 #[cfg(unix)]
768 #[test]
769 fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
770 let dir = tempfile::tempdir()?;
771 let file = dir.path().join("unreadable.css");
772 std::fs::write(&file, "original")?;
773 let mut cache = HashMap::new();
774 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
775 assert!(content_changed(&mut cache, &file, &mut buffer));
776 std::fs::remove_file(&file)?;
777 std::os::unix::fs::symlink("unreadable.css", &file)?;
778 assert!(content_changed(&mut cache, &file, &mut buffer));
779 assert!(!cache.contains_key(&file));
780 std::fs::remove_file(&file)?;
781 std::fs::write(&file, "original")?;
782 assert!(content_changed(&mut cache, &file, &mut buffer));
783 Ok(())
784 }
785}