1use std::collections::hash_map::DefaultHasher;
10use std::hash::{Hash, Hasher};
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14#[derive(Debug)]
20pub struct FsWatcher {
21 root: PathBuf,
22 fingerprint: u64,
23}
24
25impl FsWatcher {
26 pub fn new(root: impl Into<PathBuf>) -> Self {
28 let root = root.into();
29 let fingerprint = fingerprint(&root);
30 Self { root, fingerprint }
31 }
32
33 pub fn poll(&mut self) -> bool {
36 let now = fingerprint(&self.root);
37 if now == self.fingerprint {
38 false
39 } else {
40 self.fingerprint = now;
41 true
42 }
43 }
44}
45
46fn fingerprint(root: &Path) -> u64 {
48 let mut entries = Vec::new();
49 collect(root, root, &mut entries);
50 entries.sort();
51 let mut hasher = DefaultHasher::new();
52 for entry in &entries {
53 entry.hash(&mut hasher);
54 }
55 hasher.finish()
56}
57
58fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) {
60 let Ok(read) = std::fs::read_dir(dir) else {
61 return;
62 };
63 for entry in read.flatten() {
64 let path = entry.path();
65 let rel = path
66 .strip_prefix(root)
67 .unwrap_or(&path)
68 .to_string_lossy()
69 .to_string();
70 let Ok(meta) = entry.metadata() else {
71 continue;
72 };
73 if meta.is_dir() {
74 out.push(format!("d:{rel}"));
75 collect(root, &path, out);
76 } else {
77 let mtime = meta
78 .modified()
79 .ok()
80 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
81 .map(|d| d.as_nanos())
82 .unwrap_or(0);
83 out.push(format!("f:{rel}:{}:{mtime}", meta.len()));
84 }
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use std::fs;
92 use tempfile::TempDir;
93
94 #[test]
95 fn a_fresh_watcher_reports_no_change() {
96 let tmp = TempDir::new().unwrap();
97 fs::write(tmp.path().join("a.md"), "one").unwrap();
98 let mut watcher = FsWatcher::new(tmp.path());
99 assert!(!watcher.poll(), "no change since construction");
100 assert!(!watcher.poll(), "still no change");
101 }
102
103 #[test]
104 fn adding_a_file_is_detected_once() {
105 let tmp = TempDir::new().unwrap();
106 let mut watcher = FsWatcher::new(tmp.path());
107 fs::write(tmp.path().join("a.md"), "one").unwrap();
108 assert!(watcher.poll(), "new file detected");
109 assert!(!watcher.poll(), "reported only once");
110 }
111
112 #[test]
113 fn removing_a_file_is_detected() {
114 let tmp = TempDir::new().unwrap();
115 let file = tmp.path().join("a.md");
116 fs::write(&file, "one").unwrap();
117 let mut watcher = FsWatcher::new(tmp.path());
118 fs::remove_file(&file).unwrap();
119 assert!(watcher.poll(), "deletion detected");
120 }
121
122 #[test]
123 fn changing_a_file_size_is_detected() {
124 let tmp = TempDir::new().unwrap();
125 let file = tmp.path().join("a.md");
126 fs::write(&file, "one").unwrap();
127 let mut watcher = FsWatcher::new(tmp.path());
128 fs::write(&file, "a much longer body").unwrap();
129 assert!(watcher.poll(), "content change detected");
130 }
131
132 #[test]
133 fn nested_segment_changes_are_detected() {
134 let tmp = TempDir::new().unwrap();
135 fs::create_dir_all(tmp.path().join("alice")).unwrap();
136 let mut watcher = FsWatcher::new(tmp.path());
137 fs::write(tmp.path().join("alice").join("a.md"), "one").unwrap();
138 assert!(watcher.poll(), "change inside a subdirectory detected");
139 }
140
141 #[test]
142 fn missing_root_is_not_an_error() {
143 let tmp = TempDir::new().unwrap();
144 let mut watcher = FsWatcher::new(tmp.path().join("does-not-exist"));
145 assert!(!watcher.poll());
146 }
147}