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