1use std::collections::HashMap;
41use std::fs;
42use std::io;
43use std::path::{Path, PathBuf};
44use std::thread;
45use std::time::{Duration, SystemTime};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum ChangeKind {
59 Css,
61 Content,
63 Template,
65 Other,
67}
68
69#[must_use]
83pub fn classify_change(path: &Path) -> ChangeKind {
84 match path.extension().and_then(|e| e.to_str()) {
85 Some("css") => ChangeKind::Css,
86 Some("md" | "markdown") => ChangeKind::Content,
87 Some("html" | "jinja" | "jinja2" | "j2") => ChangeKind::Template,
88 _ => ChangeKind::Other,
89 }
90}
91
92#[derive(Debug, Clone)]
98pub struct WatchConfig {
99 directory: PathBuf,
101 poll_interval: Duration,
103}
104
105impl WatchConfig {
106 #[must_use]
124 pub const fn new(directory: PathBuf, poll_interval: Duration) -> Self {
125 Self {
126 directory,
127 poll_interval,
128 }
129 }
130
131 #[must_use]
144 pub fn directory(&self) -> &Path {
145 &self.directory
146 }
147
148 #[must_use]
161 pub const fn poll_interval(&self) -> Duration {
162 self.poll_interval
163 }
164}
165
166#[derive(Debug)]
176pub struct FileWatcher {
177 config: WatchConfig,
179 snapshots: HashMap<PathBuf, SystemTime>,
181}
182
183impl FileWatcher {
184 pub fn new(config: WatchConfig) -> io::Result<Self> {
202 let snapshots = Self::scan_directory(&config.directory)?;
203 Ok(Self { config, snapshots })
204 }
205
206 #[must_use]
221 pub const fn config(&self) -> &WatchConfig {
222 &self.config
223 }
224
225 pub fn check_for_changes(&mut self) -> io::Result<Vec<PathBuf>> {
250 let current = Self::scan_directory(&self.config.directory)?;
251 let mut changed: Vec<PathBuf> = Vec::new();
252
253 for (path, mtime) in ¤t {
255 match self.snapshots.get(path) {
256 Some(old_mtime) if old_mtime == mtime => {}
257 _ => changed.push(path.clone()),
258 }
259 }
260
261 for path in self.snapshots.keys() {
263 if !current.contains_key(path) {
264 changed.push(path.clone());
265 }
266 }
267
268 self.snapshots = current;
269 Ok(changed)
270 }
271
272 #[must_use]
287 pub fn tracked_file_count(&self) -> usize {
288 self.snapshots.len()
289 }
290
291 fn scan_directory(dir: &Path) -> io::Result<HashMap<PathBuf, SystemTime>> {
296 let mut map = HashMap::new();
297 if dir.is_dir() {
298 Self::walk_dir(dir, &mut map)?;
299 }
300 Ok(map)
301 }
302
303 fn walk_dir(
305 dir: &Path,
306 out: &mut HashMap<PathBuf, SystemTime>,
307 ) -> io::Result<()> {
308 for entry in fs::read_dir(dir)? {
309 let entry = next_entry(entry)?;
310 let path = entry.path();
311 let ft = entry_file_type(&entry)?;
312
313 if ft.is_dir() {
314 Self::walk_dir(&path, out)?;
315 } else if ft.is_file() {
316 out.extend(snapshot_mtime(&path).map(|mtime| (path, mtime)));
317 }
318 }
319 Ok(())
320 }
321}
322
323#[cfg(all(test, feature = "test-fault-injection"))]
329mod fault {
330 use std::cell::Cell;
331
332 thread_local! {
333 static ARMED: Cell<Option<&'static str>> = const { Cell::new(None) };
334 }
335
336 pub(super) fn arm(name: &'static str) -> ArmGuard {
339 ARMED.with(|a| a.set(Some(name)));
340 ArmGuard
341 }
342
343 pub(super) fn armed(name: &str) -> bool {
345 ARMED.with(|a| a.get() == Some(name))
346 }
347
348 #[derive(Debug)]
350 pub(super) struct ArmGuard;
351
352 impl Drop for ArmGuard {
353 fn drop(&mut self) {
354 ARMED.with(|a| a.set(None));
355 }
356 }
357}
358
359#[allow(clippy::missing_const_for_fn)]
368fn next_entry(entry: io::Result<fs::DirEntry>) -> io::Result<fs::DirEntry> {
369 #[cfg(all(test, feature = "test-fault-injection"))]
370 if fault::armed("watch::dir-entry") {
371 return Err(io::Error::other("injected: watch::dir-entry"));
372 }
373 entry
374}
375
376fn entry_file_type(entry: &fs::DirEntry) -> io::Result<fs::FileType> {
380 #[cfg(all(test, feature = "test-fault-injection"))]
381 if fault::armed("watch::entry-file-type") {
382 return Err(io::Error::other("injected: watch::entry-file-type"));
383 }
384 entry.file_type()
385}
386
387fn snapshot_mtime(path: &Path) -> Option<SystemTime> {
391 fs::metadata(path).ok()?.modified().ok()
392}
393
394pub const MAX_WATCH_ITERATIONS: usize = 1_000_000;
430
431pub fn watch_blocking<F>(watcher: &mut FileWatcher, callback: F)
452where
453 F: FnMut(&[PathBuf]) -> bool,
454{
455 watch_blocking_bounded(watcher, MAX_WATCH_ITERATIONS, callback);
456}
457
458fn watch_blocking_bounded<F>(
465 watcher: &mut FileWatcher,
466 max_iterations: usize,
467 mut callback: F,
468) where
469 F: FnMut(&[PathBuf]) -> bool,
470{
471 for _ in 0..max_iterations {
472 match watcher.check_for_changes() {
473 Ok(changes) if !changes.is_empty() => {
474 if !callback(&changes) {
475 return;
476 }
477 }
478 Ok(_) => {} Err(e) => {
480 eprintln!("watch error: {e}");
481 }
482 }
483 thread::sleep(watcher.config.poll_interval);
484 }
485}
486
487#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::fs::{self, File};
495 use std::io::Write;
496 use std::thread;
497 use std::time::Duration;
498
499 fn tmp_dir(name: &str) -> PathBuf {
501 let dir = std::env::temp_dir()
502 .join(format!("ssg_watch_test_{name}_{}", std::process::id()));
503 let _ = fs::remove_dir_all(&dir);
504 fs::create_dir_all(&dir).expect("create tmp dir");
505 dir
506 }
507
508 fn write_file(path: &Path, content: &str) {
510 let mut f = File::create(path).expect("create file");
511 f.write_all(content.as_bytes()).expect("write file");
512 }
513
514 #[test]
517 fn config_accessors() {
518 let dir = std::env::temp_dir().join("ssg_watch_fake");
519 let interval = Duration::from_millis(500);
520 let cfg = WatchConfig::new(dir.clone(), interval);
521 assert_eq!(cfg.directory(), dir.as_path());
522 assert_eq!(cfg.poll_interval(), interval);
523 }
524
525 #[test]
526 fn file_watcher_config_accessor_returns_stored_config() {
527 let dir = tmp_dir("watcher_config");
529 let interval = Duration::from_millis(250);
530 let cfg = WatchConfig::new(dir.clone(), interval);
531 let watcher = FileWatcher::new(cfg).expect("new watcher");
532 let returned = watcher.config();
533 assert_eq!(returned.directory(), dir.as_path());
534 assert_eq!(returned.poll_interval(), interval);
535 let _ = fs::remove_dir_all(&dir);
536 }
537
538 #[test]
539 fn new_watcher_snapshots_existing_files() {
540 let dir = tmp_dir("snapshot");
541 write_file(&dir.join("a.md"), "hello");
542 write_file(&dir.join("b.md"), "world");
543
544 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
545 let watcher = FileWatcher::new(cfg).expect("new watcher");
546
547 assert_eq!(watcher.tracked_file_count(), 2);
548 let _ = fs::remove_dir_all(&dir);
549 }
550
551 #[test]
552 fn no_changes_returns_empty() {
553 let dir = tmp_dir("nochange");
554 write_file(&dir.join("a.md"), "hello");
555
556 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
557 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
558
559 let changes = watcher.check_for_changes().expect("check");
560 assert!(changes.is_empty(), "expected no changes");
561 let _ = fs::remove_dir_all(&dir);
562 }
563
564 #[test]
565 fn detects_new_file() {
566 let dir = tmp_dir("newfile");
567 write_file(&dir.join("a.md"), "hello");
568
569 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
570 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
571
572 write_file(&dir.join("b.md"), "new");
574
575 let changes = watcher.check_for_changes().expect("check");
576 assert!(
577 changes.contains(&dir.join("b.md")),
578 "expected new file in changes"
579 );
580 let _ = fs::remove_dir_all(&dir);
581 }
582
583 #[test]
584 fn detects_modified_file() {
585 let dir = tmp_dir("modified");
586 write_file(&dir.join("a.md"), "v1");
587
588 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
589 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
590
591 thread::sleep(Duration::from_millis(1100));
593 write_file(&dir.join("a.md"), "v2");
594
595 let changes = watcher.check_for_changes().expect("check");
596 assert!(
597 changes.contains(&dir.join("a.md")),
598 "expected modified file in changes"
599 );
600 let _ = fs::remove_dir_all(&dir);
601 }
602
603 #[test]
604 fn detects_removed_file() {
605 let dir = tmp_dir("removed");
606 write_file(&dir.join("a.md"), "hello");
607 write_file(&dir.join("b.md"), "world");
608
609 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
610 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
611
612 fs::remove_file(dir.join("b.md")).expect("remove file");
613
614 let changes = watcher.check_for_changes().expect("check");
615 assert!(
616 changes.contains(&dir.join("b.md")),
617 "expected removed file in changes"
618 );
619 let _ = fs::remove_dir_all(&dir);
620 }
621
622 #[test]
623 fn tracks_files_in_subdirectories() {
624 let dir = tmp_dir("subdirs");
625 let sub = dir.join("posts");
626 fs::create_dir_all(&sub).expect("create subdir");
627 write_file(&sub.join("first.md"), "post");
628
629 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
630 let watcher = FileWatcher::new(cfg).expect("new watcher");
631
632 assert_eq!(watcher.tracked_file_count(), 1);
633 let _ = fs::remove_dir_all(&dir);
634 }
635
636 #[test]
637 fn check_clears_changes_after_read() {
638 let dir = tmp_dir("clear");
639 write_file(&dir.join("a.md"), "v1");
640
641 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
642 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
643
644 write_file(&dir.join("b.md"), "new");
646 let first = watcher.check_for_changes().expect("check");
647 assert!(!first.is_empty());
648
649 let second = watcher.check_for_changes().expect("check");
650 assert!(second.is_empty(), "changes should be cleared after read");
651 let _ = fs::remove_dir_all(&dir);
652 }
653
654 #[test]
655 fn watch_blocking_stops_on_false() {
656 let dir = tmp_dir("blocking");
657 write_file(&dir.join("a.md"), "v1");
658
659 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(10));
660 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
661
662 thread::sleep(Duration::from_millis(1100));
664 write_file(&dir.join("a.md"), "v2");
665
666 let mut invoked = false;
667 watch_blocking(&mut watcher, |_changes| {
668 invoked = true;
669 false });
671
672 assert!(invoked, "callback should have been invoked");
673 let _ = fs::remove_dir_all(&dir);
674 }
675
676 #[test]
677 fn watch_blocking_bounded_exhausts_iterations_without_early_return() {
678 let dir = tmp_dir("bounded_exhaust");
686 write_file(&dir.join("a.md"), "v1");
687 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
688 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
689
690 let mut calls = 0;
691 watch_blocking_bounded(&mut watcher, 3, |_changes| {
692 calls += 1;
693 true });
695
696 assert_eq!(calls, 0);
701 let _ = fs::remove_dir_all(&dir);
702 }
703
704 #[test]
705 fn watch_blocking_returns_after_callback_false_deterministic() {
706 let dir = tmp_dir("blocking_det");
712 write_file(&dir.join("a.md"), "v1");
713 write_file(&dir.join("b.md"), "v1");
714
715 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
716 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
717
718 watcher.snapshots.clear();
721
722 let mut call_count = 0;
723 watch_blocking(&mut watcher, |changes| {
724 call_count += 1;
725 assert!(!changes.is_empty());
726 false });
728
729 assert_eq!(
730 call_count, 1,
731 "callback should have been called exactly once"
732 );
733 let _ = fs::remove_dir_all(&dir);
734 }
735
736 #[test]
737 fn watch_blocking_no_changes_branch_executes() {
738 let dir = tmp_dir("no_changes_arm");
759 write_file(&dir.join("a.md"), "x");
760 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
761 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
762 let changes = watcher.check_for_changes().expect("check");
766 assert!(changes.is_empty());
767 let _ = fs::remove_dir_all(&dir);
768 }
769
770 #[test]
771 fn empty_directory_is_valid() {
772 let dir = tmp_dir("empty");
773
774 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
775 let watcher = FileWatcher::new(cfg).expect("new watcher");
776
777 assert_eq!(watcher.tracked_file_count(), 0);
778 let _ = fs::remove_dir_all(&dir);
779 }
780
781 #[test]
782 fn nonexistent_directory_errors() {
783 let dir = std::env::temp_dir().join("ssg_watch_test_nonexistent_99999");
784 let _ = fs::remove_dir_all(&dir);
785
786 let cfg = WatchConfig::new(dir, Duration::from_millis(50));
787 let watcher = FileWatcher::new(cfg);
790 assert!(watcher.is_ok());
791 assert_eq!(watcher.unwrap().tracked_file_count(), 0);
792 }
793
794 #[test]
795 fn watch_config_default_values() {
796 let dir = std::env::temp_dir().join("ssg_watch_defaults");
798 let poll = Duration::from_secs(2);
799 let debounce = Duration::from_millis(100);
800
801 let cfg = WatchConfig::new(dir.clone(), poll);
803
804 assert_eq!(cfg.poll_interval(), Duration::from_secs(2));
806 assert_eq!(cfg.directory(), dir.as_path());
807 assert_ne!(cfg.poll_interval(), debounce);
809 }
810
811 #[test]
812 fn file_watcher_empty_directory() {
813 let dir = tmp_dir("empty_watch");
815
816 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
818 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
819
820 assert_eq!(watcher.tracked_file_count(), 0);
822 let changes = watcher.check_for_changes().expect("check");
823 assert!(changes.is_empty(), "empty dir should have no changes");
824 let _ = fs::remove_dir_all(&dir);
825 }
826
827 #[test]
828 fn file_watcher_detects_new_file() {
829 let dir = tmp_dir("detect_new");
831 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
832 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
833 assert_eq!(watcher.tracked_file_count(), 0);
834
835 write_file(&dir.join("added.md"), "new content");
837 let changes = watcher.check_for_changes().expect("check");
838
839 assert_eq!(changes.len(), 1);
841 assert!(changes[0].ends_with("added.md"));
842 assert_eq!(watcher.tracked_file_count(), 1);
843 let _ = fs::remove_dir_all(&dir);
844 }
845
846 #[test]
847 #[cfg(unix)]
848 fn walk_dir_skips_entries_that_are_neither_file_nor_dir() {
849 let dir = tmp_dir("symlink_skip");
855 write_file(&dir.join("real.md"), "content");
856 std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md"))
857 .expect("create symlink");
858
859 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
860 let watcher = FileWatcher::new(cfg).expect("new watcher");
861
862 assert_eq!(watcher.tracked_file_count(), 1);
865 let _ = fs::remove_dir_all(&dir);
866 }
867
868 #[test]
869 fn scan_directory_nonexistent_returns_empty_map() {
870 let dir = PathBuf::from("/nonexistent_ssg_watch_test_dir");
873 let cfg = WatchConfig::new(dir, Duration::from_millis(50));
874 let watcher = FileWatcher::new(cfg).expect("should succeed");
875 assert_eq!(watcher.tracked_file_count(), 0);
876 }
877
878 #[test]
879 fn watch_config_clone() {
880 let dir = std::env::temp_dir().join("ssg_watch_clone");
881 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(100));
882 let cloned = cfg;
883 assert_eq!(cloned.directory(), dir.as_path());
884 assert_eq!(cloned.poll_interval(), Duration::from_millis(100));
885 }
886
887 #[test]
888 fn file_watcher_debug_output() {
889 let dir = tmp_dir("debug_out");
890 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
891 let watcher = FileWatcher::new(cfg).expect("new watcher");
892 let debug = format!("{watcher:?}");
893 assert!(debug.contains("FileWatcher"));
894 let _ = fs::remove_dir_all(&dir);
895 }
896
897 #[test]
898 fn file_watcher_nested_directory() {
899 let dir = tmp_dir("nested_watch");
901 let sub = dir.join("a/b/c");
902 fs::create_dir_all(&sub).expect("create nested dirs");
903 write_file(&sub.join("deep.md"), "deep content");
904 write_file(&dir.join("root.md"), "root content");
905
906 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
908 let watcher = FileWatcher::new(cfg).expect("new watcher");
909
910 assert_eq!(watcher.tracked_file_count(), 2);
912 let _ = fs::remove_dir_all(&dir);
913 }
914
915 #[test]
916 fn test_classify_css() {
917 assert_eq!(
918 classify_change(Path::new("styles/main.css")),
919 ChangeKind::Css
920 );
921 }
922
923 #[test]
924 fn test_classify_markdown() {
925 assert_eq!(
926 classify_change(Path::new("content/post.md")),
927 ChangeKind::Content
928 );
929 assert_eq!(
930 classify_change(Path::new("content/post.markdown")),
931 ChangeKind::Content
932 );
933 }
934
935 #[test]
936 fn test_classify_html() {
937 assert_eq!(
938 classify_change(Path::new("templates/base.html")),
939 ChangeKind::Template
940 );
941 assert_eq!(
942 classify_change(Path::new("templates/base.jinja")),
943 ChangeKind::Template
944 );
945 assert_eq!(
946 classify_change(Path::new("templates/base.jinja2")),
947 ChangeKind::Template
948 );
949 assert_eq!(
950 classify_change(Path::new("templates/base.j2")),
951 ChangeKind::Template
952 );
953 }
954
955 #[test]
956 fn test_classify_other() {
957 assert_eq!(
958 classify_change(Path::new("src/main.rs")),
959 ChangeKind::Other
960 );
961 assert_eq!(
962 classify_change(Path::new("config.toml")),
963 ChangeKind::Other
964 );
965 }
966
967 #[test]
968 fn test_classify_no_extension() {
969 assert_eq!(classify_change(Path::new("Makefile")), ChangeKind::Other);
970 }
971
972 #[test]
973 fn snapshot_mtime_returns_some_for_existing_file() {
974 let dir = tmp_dir("mtime_some");
975 let file = dir.join("a.md");
976 write_file(&file, "content");
977 assert!(snapshot_mtime(&file).is_some());
978 let _ = fs::remove_dir_all(&dir);
979 }
980
981 #[test]
982 fn snapshot_mtime_returns_none_for_missing_file() {
983 let missing = Path::new("/nonexistent_ssg_watch_mtime_test");
986 assert!(snapshot_mtime(missing).is_none());
987 }
988
989 #[test]
990 #[cfg(unix)]
991 fn new_watcher_errors_on_unreadable_subdirectory() {
992 use std::os::unix::fs::PermissionsExt;
993 let dir = tmp_dir("unreadable_new");
996 let locked = dir.join("locked");
997 fs::create_dir_all(&locked).expect("create locked dir");
998 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
999 .expect("chmod");
1000
1001 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
1002 let res = FileWatcher::new(cfg);
1003
1004 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1005 let _ = fs::remove_dir_all(&dir);
1006 assert!(res.is_err(), "unreadable subdirectory must fail the scan");
1007 }
1008
1009 #[test]
1010 #[cfg(unix)]
1011 fn check_for_changes_errors_when_directory_becomes_unreadable() {
1012 use std::os::unix::fs::PermissionsExt;
1013 let dir = tmp_dir("unreadable_check");
1014 write_file(&dir.join("a.md"), "x");
1015
1016 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
1017 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
1018
1019 fs::set_permissions(&dir, fs::Permissions::from_mode(0o000))
1020 .expect("chmod");
1021 let res = watcher.check_for_changes();
1022
1023 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o755));
1024 let _ = fs::remove_dir_all(&dir);
1025 assert!(res.is_err(), "unreadable root must fail the rescan");
1026 }
1027
1028 #[test]
1029 fn watch_blocking_keeps_polling_while_callback_returns_true() {
1030 let dir = tmp_dir("blocking_continue");
1034 write_file(&dir.join("a.md"), "v1");
1035
1036 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
1037 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
1038 watcher.snapshots.clear(); let mut calls = 0;
1041 let plant = dir.join("b.md");
1042 watch_blocking(&mut watcher, |_changes| {
1043 calls += 1;
1044 if calls == 1 {
1045 let mut f = File::create(&plant).expect("create planted");
1046 f.write_all(b"new").expect("write planted");
1047 true } else {
1049 false
1050 }
1051 });
1052
1053 assert_eq!(calls, 2, "callback should fire for the planted change");
1054 let _ = fs::remove_dir_all(&dir);
1055 }
1056
1057 #[test]
1058 fn watch_blocking_idles_through_no_change_polls() {
1059 let dir = tmp_dir("blocking_idle");
1062 write_file(&dir.join("a.md"), "v1");
1063
1064 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
1065 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
1066 watcher.snapshots.clear();
1067
1068 let planted = dir.join("late.md");
1069 let mut calls = 0;
1070 let mut writer: Option<thread::JoinHandle<()>> = None;
1071 watch_blocking(&mut watcher, |_changes| {
1072 calls += 1;
1073 if calls == 1 {
1074 let path = planted.clone();
1075 writer = Some(thread::spawn(move || {
1076 thread::sleep(Duration::from_millis(50));
1077 let mut f = File::create(&path).expect("create late");
1078 f.write_all(b"late").expect("write late");
1079 }));
1080 true } else {
1082 false
1083 }
1084 });
1085
1086 assert_eq!(calls, 2);
1087 if let Some(h) = writer {
1088 h.join().expect("writer thread");
1089 }
1090 let _ = fs::remove_dir_all(&dir);
1091 }
1092
1093 #[test]
1094 #[cfg(unix)]
1095 fn watch_blocking_reports_scan_errors_and_recovers() {
1096 use std::os::unix::fs::PermissionsExt;
1097 let dir = tmp_dir("blocking_error");
1101 write_file(&dir.join("a.md"), "v1");
1102
1103 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
1104 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
1105 watcher.snapshots.clear();
1106
1107 let mut calls = 0;
1108 let mut helper: Option<thread::JoinHandle<()>> = None;
1109 let root = dir.clone();
1110 watch_blocking(&mut watcher, |_changes| {
1111 calls += 1;
1112 if calls == 1 {
1113 let root = root.clone();
1114 helper = Some(thread::spawn(move || {
1115 fs::set_permissions(
1116 &root,
1117 fs::Permissions::from_mode(0o000),
1118 )
1119 .expect("lock dir");
1120 thread::sleep(Duration::from_millis(50));
1121 fs::set_permissions(
1122 &root,
1123 fs::Permissions::from_mode(0o755),
1124 )
1125 .expect("unlock dir");
1126 let mut f = File::create(root.join("late.md"))
1127 .expect("create late");
1128 f.write_all(b"late").expect("write late");
1129 }));
1130 true
1131 } else {
1132 false
1133 }
1134 });
1135
1136 assert_eq!(calls, 2, "loop must survive scan errors and recover");
1137 if let Some(h) = helper {
1138 h.join().expect("helper thread");
1139 }
1140 let _ = fs::remove_dir_all(&dir);
1141 }
1142
1143 #[cfg(feature = "test-fault-injection")]
1144 mod fault_injection {
1145 use super::*;
1146
1147 #[test]
1148 fn walk_dir_surfaces_injected_entry_error() {
1149 let dir = tmp_dir("fault_entry");
1150 write_file(&dir.join("a.md"), "x");
1151
1152 let guard = fault::arm("watch::dir-entry");
1153 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
1154 let err = FileWatcher::new(cfg)
1155 .expect_err("injected entry error must propagate");
1156 assert!(err.to_string().contains("watch::dir-entry"));
1157
1158 drop(guard);
1159 let _ = fs::remove_dir_all(&dir);
1160 }
1161
1162 #[test]
1163 fn walk_dir_surfaces_injected_file_type_error() {
1164 let dir = tmp_dir("fault_file_type");
1165 write_file(&dir.join("a.md"), "x");
1166
1167 let guard = fault::arm("watch::entry-file-type");
1168 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
1169 let err = FileWatcher::new(cfg)
1170 .expect_err("injected file-type error must propagate");
1171 assert!(err.to_string().contains("watch::entry-file-type"));
1172
1173 drop(guard);
1174 let _ = fs::remove_dir_all(&dir);
1175 }
1176 }
1177}