1use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, PoisonError};
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use notify::{
18 event::{AccessKind, AccessMode},
19 Event, EventKind,
20};
21use notify_debouncer_mini::{
22 new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, DebouncedEvent, Debouncer,
23};
24
25#[path = "watch_backend.rs"]
26mod backend;
27#[path = "watch_hash.rs"]
28mod hash;
29#[path = "watch_subscriptions.rs"]
30mod subscriptions;
31
32#[cfg(test)]
33#[path = "watch_hash_tests.rs"]
34mod hash_tests;
35
36use backend::ContentChangeWatcher;
37use hash::{hash_file, HASH_BUFFER_SIZE};
38use subscriptions::Subscriptions;
39
40pub struct WatcherHandle {
49 debouncer: Debouncer<ContentChangeWatcher>,
50 filter: Arc<Mutex<EventFilter>>,
51 subscriptions: Subscriptions,
52}
53
54impl WatcherHandle {
55 pub fn retarget(&mut self, targets: WatchTargets) -> Result<()> {
71 let WatchTargets {
72 paths,
73 explicit_files,
74 ignore,
75 } = targets;
76 let ignore = ignore
80 .into_iter()
81 .map(|path| std::fs::canonicalize(&path).unwrap_or(path))
82 .collect();
83 let explicit_files: Vec<PathBuf> = explicit_files
84 .iter()
85 .filter_map(|path| normalize_explicit_file(path))
86 .collect();
87 self.filter
90 .lock()
91 .unwrap_or_else(PoisonError::into_inner)
92 .reset(ignore, explicit_files.clone());
93 self.subscriptions
94 .retarget(self.debouncer.watcher(), &paths, &explicit_files)
95 }
96}
97
98fn is_read_only_access_event(event: &Event) -> bool {
99 matches!(
102 event.kind,
103 EventKind::Access(
104 AccessKind::Read
105 | AccessKind::Open(AccessMode::Any | AccessMode::Read)
106 | AccessKind::Close(AccessMode::Read)
107 )
108 )
109}
110
111pub struct WatchConfig {
113 pub paths: Vec<PathBuf>,
116 pub explicit_files: Vec<PathBuf>,
124 pub ignore: Vec<PathBuf>,
131 pub debounce: Duration,
134 pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
140}
141
142pub struct WatchTargets {
146 pub paths: Vec<PathBuf>,
149 pub explicit_files: Vec<PathBuf>,
152 pub ignore: Vec<PathBuf>,
154}
155
156struct EventFilter {
159 ignore: Vec<PathBuf>,
160 explicit_files: Vec<PathBuf>,
161 content_hashes: HashMap<PathBuf, u64>,
162 hash_buffer: [u8; HASH_BUFFER_SIZE],
163}
164
165impl EventFilter {
166 fn new() -> Self {
167 Self {
168 ignore: Vec::new(),
169 explicit_files: Vec::new(),
170 content_hashes: HashMap::new(),
171 hash_buffer: [0; HASH_BUFFER_SIZE],
172 }
173 }
174
175 fn reset(&mut self, ignore: Vec<PathBuf>, explicit_files: Vec<PathBuf>) {
178 self.ignore = ignore;
179 self.explicit_files = explicit_files;
180 self.content_hashes.clear();
181 for file in &self.explicit_files {
182 content_changed(&mut self.content_hashes, file, &mut self.hash_buffer);
183 }
184 }
185
186 fn changed_paths(&mut self, events: &[DebouncedEvent], retry_unchanged: bool) -> Vec<PathBuf> {
188 let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
191 let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
192 for event in events {
193 collect_event_paths(
194 &event.path,
195 &self.ignore,
196 &self.explicit_files,
197 &mut seen,
198 &mut paths,
199 );
200 }
201 paths.retain(|path| !path.is_dir());
206 paths.retain(|path| {
214 should_forward_path(
215 &mut self.content_hashes,
216 path,
217 retry_unchanged,
218 &mut self.hash_buffer,
219 )
220 });
221 paths
222 }
223}
224
225pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
248where
249 F: Fn(Vec<PathBuf>) + Send + 'static,
250{
251 let WatchConfig {
252 paths,
253 explicit_files,
254 ignore,
255 debounce,
256 retry_unchanged_when,
257 } = cfg;
258 let filter = Arc::new(Mutex::new(EventFilter::new()));
259 let events_filter = Arc::clone(&filter);
260 let notify_config = notify::Config::default().with_follow_symlinks(false);
261 let debouncer_config = DebouncerConfig::default()
262 .with_timeout(debounce)
263 .with_notify_config(notify_config);
264 let debouncer = new_debouncer_opt::<_, ContentChangeWatcher>(
265 debouncer_config,
266 move |res: DebounceEventResult| match res {
267 Ok(events) => {
268 let retry_unchanged = retry_unchanged_when
270 .as_ref()
271 .is_some_and(|predicate| predicate());
272 let paths = events_filter
273 .lock()
274 .unwrap_or_else(PoisonError::into_inner)
275 .changed_paths(&events, retry_unchanged);
276 if !paths.is_empty() {
277 on_event(paths);
278 }
279 }
280 Err(e) => {
281 eprintln!("watcher error: {e:?}");
282 }
283 },
284 )
285 .context("Cannot start file watcher")?;
286
287 let mut handle = WatcherHandle {
288 debouncer,
289 filter,
290 subscriptions: Subscriptions::default(),
291 };
292 handle.retarget(WatchTargets {
293 paths,
294 explicit_files,
295 ignore,
296 })?;
297 Ok(handle)
298}
299
300fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
301 if let Ok(canonical) = std::fs::canonicalize(path) {
302 return Some(canonical);
303 }
304 let path = std::path::absolute(path).ok()?;
305 for ancestor in path.ancestors().skip(1) {
306 if let Ok(canonical) = std::fs::canonicalize(ancestor) {
307 return Some(canonical.join(path.strip_prefix(ancestor).ok()?));
308 }
309 }
310 None
311}
312
313fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
314 let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
315 explicit_files.iter().any(|path| path == &normalized)
316}
317
318fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
319 is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
320}
321
322fn collect_event_paths(
323 event_path: &Path,
324 ignore: &[PathBuf],
325 explicit_files: &[PathBuf],
326 seen: &mut HashSet<PathBuf>,
327 paths: &mut Vec<PathBuf>,
328) {
329 let is_directory = event_path.is_dir();
330 if is_directory || !event_path.exists() {
331 if let Some(directory) = normalize_explicit_file(event_path) {
332 for file in explicit_files {
335 if file != &directory && file.starts_with(&directory) && seen.insert(file.clone()) {
336 paths.push(file.clone());
337 }
338 }
339 }
340 }
341 if !is_directory
342 && !should_ignore_event(event_path, ignore, explicit_files)
343 && seen.insert(event_path.to_path_buf())
344 {
345 paths.push(event_path.to_path_buf());
346 }
347}
348
349fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
362 if ignore.is_empty() {
363 return false;
364 }
365 let canon = std::fs::canonicalize(event_path).ok();
366 let candidate: &Path = canon.as_deref().unwrap_or(event_path);
367
368 for root in ignore {
369 if root.is_absolute() {
370 if candidate.starts_with(root) {
371 return true;
372 }
373 continue;
374 }
375 let mut components = root.components();
379 let first = components.next();
380 let only_one_component = components.next().is_none();
381 if let (Some(first), true) = (first, only_one_component) {
382 let name = first.as_os_str();
383 if event_path.components().any(|c| c.as_os_str() == name)
387 || candidate.components().any(|c| c.as_os_str() == name)
388 {
389 return true;
390 }
391 } else if candidate.starts_with(root) {
392 return true;
394 }
395 }
396 false
397}
398
399#[must_use]
404pub fn default_ignore_paths() -> Vec<PathBuf> {
405 vec![
406 PathBuf::from("node_modules"),
407 PathBuf::from(".git"),
408 PathBuf::from("target"),
409 ]
410}
411
412fn content_changed(
418 cache: &mut HashMap<PathBuf, u64>,
419 path: &Path,
420 buffer: &mut [u8; HASH_BUFFER_SIZE],
421) -> bool {
422 match hash_file(path, buffer) {
423 Some(hash) => match cache.insert(path.to_path_buf(), hash) {
424 Some(previous) => previous != hash,
425 None => true,
426 },
427 None => {
428 cache.remove(path);
429 true
430 }
431 }
432}
433
434fn should_forward_path(
435 cache: &mut HashMap<PathBuf, u64>,
436 path: &Path,
437 retry_unchanged: bool,
438 buffer: &mut [u8; HASH_BUFFER_SIZE],
439) -> bool {
440 content_changed(cache, path, buffer) || retry_unchanged
441}
442
443#[cfg(test)]
444#[allow(clippy::disallowed_methods)]
445mod tests {
446 use super::*;
447 use notify_debouncer_mini::DebouncedEventKind;
448 use std::sync::mpsc::{self, RecvTimeoutError};
449
450 fn event(path: &Path) -> DebouncedEvent {
451 DebouncedEvent::new(path.to_path_buf(), DebouncedEventKind::Any)
452 }
453
454 #[test]
455 fn reset_restarts_content_baselines_like_a_new_watcher() {
456 let dir = tempfile::tempdir().unwrap();
457 let exact = dir.path().join("state.json");
458 let source = dir.path().join("page.md");
459 std::fs::write(&exact, "1").unwrap();
460 std::fs::write(&source, "# Page").unwrap();
461 let mut filter = EventFilter::new();
462 filter.reset(Vec::new(), vec![exact.clone()]);
463
464 assert!(filter.changed_paths(&[event(&exact)], false).is_empty());
465 assert_eq!(
466 filter.changed_paths(&[event(&source)], false),
467 std::slice::from_ref(&source)
468 );
469 assert!(filter.changed_paths(&[event(&source)], false).is_empty());
470
471 std::fs::write(&exact, "2").unwrap();
472 filter.reset(Vec::new(), vec![exact.clone()]);
473 assert!(filter.changed_paths(&[event(&exact)], false).is_empty());
474 assert_eq!(filter.changed_paths(&[event(&source)], false), [source]);
475 }
476
477 #[test]
478 fn retarget_delivers_edits_observed_before_it() -> Result<()> {
479 let dir = tempfile::tempdir()?;
480 let source = dir.path().join("page.md");
481 std::fs::write(&source, "# One")?;
482 let (sender, receiver) = mpsc::channel();
483 let mut watcher = spawn_watcher(
484 WatchConfig {
485 paths: vec![dir.path().to_path_buf()],
486 explicit_files: Vec::new(),
487 ignore: Vec::new(),
488 debounce: Duration::from_millis(50),
489 retry_unchanged_when: None,
490 },
491 move |paths| {
492 let _ = sender.send(paths);
493 },
494 )?;
495
496 std::fs::write(&source, "# Two")?;
497 watcher.retarget(WatchTargets {
499 paths: vec![dir.path().to_path_buf()],
500 explicit_files: Vec::new(),
501 ignore: Vec::new(),
502 })?;
503 let changed = receiver.recv_timeout(Duration::from_secs(5))?;
504 let source = std::fs::canonicalize(&source)?;
505 assert!(
506 changed
507 .iter()
508 .any(|path| std::fs::canonicalize(path).is_ok_and(|path| path == source)),
509 "missing {} in {changed:?}",
510 source.display()
511 );
512 Ok(())
513 }
514
515 #[test]
516 fn retarget_moves_exact_file_subscriptions() -> Result<()> {
517 let dir = tempfile::tempdir()?;
518 let first = dir.path().join("target/first/state.json");
519 let second = dir.path().join("target/second/state.json");
520 for file in [&first, &second] {
521 std::fs::create_dir_all(file.parent().context("state parent")?)?;
522 std::fs::write(file, "1")?;
523 }
524 let ignore = || vec![PathBuf::from("target")];
525 let (sender, receiver) = mpsc::channel();
526 let mut watcher = spawn_watcher(
527 WatchConfig {
528 paths: Vec::new(),
529 explicit_files: vec![first.clone()],
530 ignore: ignore(),
531 debounce: Duration::from_millis(25),
532 retry_unchanged_when: None,
533 },
534 move |paths| {
535 let _ = sender.send(paths);
536 },
537 )?;
538
539 watcher.retarget(WatchTargets {
540 paths: Vec::new(),
541 explicit_files: vec![second.clone()],
542 ignore: ignore(),
543 })?;
544 std::fs::write(&first, "2")?;
545 let stale = receiver.recv_timeout(Duration::from_millis(500));
546 assert!(
547 matches!(stale, Err(RecvTimeoutError::Timeout)),
548 "a released include must not rebuild: {stale:?}"
549 );
550 std::fs::write(&second, "2")?;
551 assert_eq!(
552 receiver.recv_timeout(Duration::from_secs(5))?,
553 [std::fs::canonicalize(&second)?]
554 );
555 Ok(())
556 }
557
558 #[test]
559 fn is_ignored_matches_descendant() {
560 let dir = tempfile::tempdir().unwrap();
561 let dist = dir.path().join("dist");
562 std::fs::create_dir(&dist).unwrap();
563 let nested = dist.join("a/b.html");
564 std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
565 std::fs::write(&nested, "x").unwrap();
566
567 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
568 assert!(is_ignored(&nested, &ignore));
569 }
570
571 #[test]
572 fn explicit_manifest_bypasses_ignored_output_directory() {
573 let dir = tempfile::tempdir().unwrap();
574 let dist = dir.path().join("dist");
575 std::fs::create_dir(&dist).unwrap();
576 let manifest = dist.join("webui-projection.json");
577 let bundle = dist.join("index.js");
578 std::fs::write(&manifest, "{}").unwrap();
579 std::fs::write(&bundle, "export {};").unwrap();
580
581 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
582 let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
583 assert!(!should_ignore_event(&manifest, &ignore, &explicit));
584 assert!(should_ignore_event(&bundle, &ignore, &explicit));
585 }
586
587 #[test]
588 fn explicit_file_tracks_missing_parent_directories() -> std::io::Result<()> {
589 let dir = tempfile::tempdir()?;
590 let file = dir.path().join("target/new/state.json");
591 let normalized = normalize_explicit_file(&file)
592 .ok_or_else(|| std::io::Error::other("missing include was discarded"))?;
593 assert_eq!(
594 normalized,
595 std::fs::canonicalize(dir.path())?.join("target/new/state.json")
596 );
597 std::fs::create_dir_all(
598 file.parent()
599 .ok_or_else(|| std::io::Error::other("missing state parent"))?,
600 )?;
601 std::fs::write(&file, "{}")?;
602 assert!(!should_ignore_event(
603 &file,
604 &[PathBuf::from("target")],
605 &[normalized]
606 ));
607 Ok(())
608 }
609
610 #[test]
611 fn directory_event_rechecks_explicit_files_created_before_recursive_watch() {
612 let dir = tempfile::tempdir().unwrap();
613 let directory = dir.path().join("target/new");
614 let file = directory.join("state.json");
615 let explicit = vec![normalize_explicit_file(&file).unwrap()];
616 std::fs::create_dir_all(&directory).unwrap();
617 std::fs::write(&file, "{}").unwrap();
618 let mut seen = HashSet::new();
619 let mut paths = Vec::new();
620 collect_event_paths(
621 &directory,
622 &[PathBuf::from("target")],
623 &explicit,
624 &mut seen,
625 &mut paths,
626 );
627 assert_eq!(paths, explicit);
628 }
629
630 #[test]
631 fn directory_rechecks_dedupe_and_skip_unchanged_explicit_files() {
632 let dir = tempfile::tempdir().unwrap();
633 let directory = dir.path().join("target/new");
634 std::fs::create_dir_all(&directory).unwrap();
635 let file = directory.join("state.json");
636 std::fs::write(&file, "{}").unwrap();
637 let explicit = vec![normalize_explicit_file(&file).unwrap()];
638 let mut hashes = HashMap::new();
639 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
640 assert!(content_changed(&mut hashes, &explicit[0], &mut buffer));
641 let mut seen = HashSet::new();
642 let mut paths = Vec::new();
643 for _ in 0..2 {
644 collect_event_paths(
645 &directory,
646 &[PathBuf::from("target")],
647 &explicit,
648 &mut seen,
649 &mut paths,
650 );
651 }
652 assert_eq!(paths, explicit);
653 assert!(!should_forward_path(
654 &mut hashes,
655 &paths[0],
656 false,
657 &mut buffer
658 ));
659 std::fs::write(&file, "{\"count\":7}").unwrap();
660 assert!(should_forward_path(
661 &mut hashes,
662 &paths[0],
663 false,
664 &mut buffer
665 ));
666 }
667
668 #[test]
669 fn directory_events_ignore_unrelated_files_and_recheck_removed_parents() {
670 let dir = tempfile::tempdir().unwrap();
671 let directory = dir.path().join("target/new");
672 let unrelated = dir.path().join("target/other");
673 std::fs::create_dir_all(&directory).unwrap();
674 std::fs::create_dir_all(&unrelated).unwrap();
675 let file = directory.join("state.json");
676 std::fs::write(&file, "{}").unwrap();
677 let explicit = vec![normalize_explicit_file(&file).unwrap()];
678 let ignore = vec![PathBuf::from("target")];
679 let mut seen = HashSet::new();
680 let mut paths = Vec::new();
681 collect_event_paths(&unrelated, &ignore, &explicit, &mut seen, &mut paths);
682 assert!(paths.is_empty());
683 std::fs::remove_file(&file).unwrap();
684 std::fs::remove_dir(&directory).unwrap();
685 collect_event_paths(&directory, &ignore, &explicit, &mut seen, &mut paths);
686 assert_eq!(paths, explicit);
687 }
688
689 #[test]
690 fn is_ignored_misses_sibling() {
691 let dir = tempfile::tempdir().unwrap();
692 let dist = dir.path().join("dist");
693 let other = dir.path().join("src/a.md");
694 std::fs::create_dir(&dist).unwrap();
695 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
696 std::fs::write(&other, "x").unwrap();
697
698 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
699 assert!(!is_ignored(&other, &ignore));
700 }
701
702 #[test]
703 fn is_ignored_is_false_when_no_ignores() {
704 let p = Path::new("/anything/at/all");
705 assert!(!is_ignored(p, &[]));
706 }
707
708 #[test]
709 fn is_ignored_matches_relative_component_anywhere() {
710 let ignore = vec![PathBuf::from("target")];
714 assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
715 assert!(is_ignored(
716 Path::new("/repo/sub/crate/target/foo.rs"),
717 &ignore
718 ));
719 assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
720 }
721
722 #[test]
723 fn is_ignored_handles_node_modules_and_git() {
724 let ignore = default_ignore_paths();
725 assert!(is_ignored(
726 Path::new("/repo/node_modules/foo/index.js"),
727 &ignore
728 ));
729 assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
730 assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
731 }
732
733 #[test]
734 fn read_only_access_events_are_not_content_changes() {
735 use notify::event::ModifyKind;
736
737 for kind in [
738 AccessKind::Read,
739 AccessKind::Open(AccessMode::Any),
740 AccessKind::Open(AccessMode::Read),
741 AccessKind::Close(AccessMode::Read),
742 ] {
743 assert!(is_read_only_access_event(&Event::new(EventKind::Access(
744 kind
745 ))));
746 }
747 for kind in [
748 AccessKind::Any,
749 AccessKind::Open(AccessMode::Write),
750 AccessKind::Close(AccessMode::Write),
751 ] {
752 assert!(!is_read_only_access_event(&Event::new(EventKind::Access(
753 kind
754 ))));
755 }
756 assert!(!is_read_only_access_event(&Event::new(EventKind::Modify(
757 ModifyKind::Any
758 ))));
759 }
760
761 #[cfg(unix)]
762 #[test]
763 fn is_ignored_matches_node_modules_symlink_path() {
764 use std::os::unix::fs::symlink;
765
766 let dir = tempfile::tempdir().unwrap();
767 let package = dir.path().join("packages/example");
768 let node_modules = dir.path().join("node_modules");
769 std::fs::create_dir_all(&package).unwrap();
770 std::fs::create_dir(&node_modules).unwrap();
771 std::fs::write(package.join("index.js"), "export {};").unwrap();
772 symlink(&package, node_modules.join("example")).unwrap();
773
774 let linked_file = node_modules.join("example/index.js");
775 let canonical = std::fs::canonicalize(&linked_file).unwrap();
776 assert!(!canonical
777 .components()
778 .any(|component| component.as_os_str() == "node_modules"));
779 assert!(is_ignored(&linked_file, &[PathBuf::from("node_modules")]));
780 }
781
782 #[test]
783 fn content_changed_skips_identical_saves() {
784 let dir = tempfile::tempdir().unwrap();
785 let file = dir.path().join("a.css");
786 std::fs::write(&file, "a { color: red; }").unwrap();
787 let mut cache = HashMap::new();
788 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
789
790 assert!(content_changed(&mut cache, &file, &mut buffer));
792 assert!(!content_changed(&mut cache, &file, &mut buffer));
794 assert!(!content_changed(&mut cache, &file, &mut buffer));
795
796 std::fs::write(&file, "a { color: blue; }").unwrap();
798 assert!(content_changed(&mut cache, &file, &mut buffer));
799 assert!(!content_changed(&mut cache, &file, &mut buffer));
801
802 std::fs::remove_file(&file).unwrap();
804 assert!(content_changed(&mut cache, &file, &mut buffer));
805 std::fs::write(&file, "a { color: blue; }").unwrap();
807 assert!(content_changed(&mut cache, &file, &mut buffer));
808 }
809
810 #[test]
811 fn unchanged_save_can_retry_when_error_active() {
812 let dir = tempfile::tempdir().unwrap();
813 let file = dir.path().join("a.css");
814 std::fs::write(&file, "a { color: red; }").unwrap();
815 let mut cache = HashMap::new();
816 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
817
818 assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
819 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
820 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
821 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
822
823 std::fs::write(&file, "a { color: blue; }").unwrap();
824 assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
825 assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
826 }
827
828 #[test]
829 fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
830 let dir = tempfile::tempdir()?;
831 let file = dir.path().join("a.css");
832 let mut cache = HashMap::new();
833 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
834
835 for replacement in ["deleted", "directory", "oversized"] {
836 std::fs::write(&file, "original")?;
837 assert!(content_changed(&mut cache, &file, &mut buffer));
838 assert!(!content_changed(&mut cache, &file, &mut buffer));
839
840 std::fs::remove_file(&file)?;
841 match replacement {
842 "directory" => std::fs::create_dir(&file)?,
843 "oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
844 _ => {}
845 }
846 for _ in 0..2 {
847 assert!(content_changed(&mut cache, &file, &mut buffer));
848 assert!(!cache.contains_key(&file));
849 }
850 match replacement {
851 "directory" => std::fs::remove_dir(&file)?,
852 "oversized" => std::fs::remove_file(&file)?,
853 _ => {}
854 }
855 }
856 std::fs::write(&file, "original")?;
857 assert!(content_changed(&mut cache, &file, &mut buffer));
858 Ok(())
859 }
860
861 #[test]
862 fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
863 let dir = tempfile::tempdir()?;
864 let paths = [
865 dir.path().join("large.css"),
866 dir.path().join("empty.css"),
867 dir.path().join("small.css"),
868 ];
869 for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
870 std::fs::write(path, vec![b'x'; size])?;
871 }
872 let mut cache = HashMap::new();
873 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
874 for changed in [true, false, false] {
875 for path in &paths {
876 assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
877 }
878 }
879 Ok(())
880 }
881
882 #[cfg(unix)]
883 #[test]
884 fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
885 let dir = tempfile::tempdir()?;
886 let file = dir.path().join("unreadable.css");
887 std::fs::write(&file, "original")?;
888 let mut cache = HashMap::new();
889 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
890 assert!(content_changed(&mut cache, &file, &mut buffer));
891 std::fs::remove_file(&file)?;
892 std::os::unix::fs::symlink("unreadable.css", &file)?;
893 assert!(content_changed(&mut cache, &file, &mut buffer));
894 assert!(!cache.contains_key(&file));
895 std::fs::remove_file(&file)?;
896 std::fs::write(&file, "original")?;
897 assert!(content_changed(&mut cache, &file, &mut buffer));
898 Ok(())
899 }
900}