1use std::collections::HashMap;
19use std::collections::hash_map::DefaultHasher;
20use std::hash::{Hash, Hasher};
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct FsDelta {
32 pub changed: Vec<PathBuf>,
34 pub removed: Vec<PathBuf>,
36}
37
38impl FsDelta {
39 pub fn merge(&mut self, next: FsDelta) {
45 for path in next.changed {
46 self.removed.retain(|existing| existing != &path);
47 if !self.changed.contains(&path) {
48 self.changed.push(path);
49 }
50 }
51 for path in next.removed {
52 self.changed.retain(|existing| existing != &path);
53 if !self.removed.contains(&path) {
54 self.removed.push(path);
55 }
56 }
57 }
58}
59
60#[derive(Debug, Default)]
70pub struct DeltaCoalescer {
71 pending: Option<FsDelta>,
72}
73
74impl DeltaCoalescer {
75 pub fn observe(&mut self, polled: Option<FsDelta>) -> Option<FsDelta> {
77 match polled {
78 Some(delta) => {
79 match &mut self.pending {
80 Some(pending) => pending.merge(delta),
81 None => self.pending = Some(delta),
82 }
83 None
84 }
85 None => self.pending.take(),
86 }
87 }
88
89 pub fn retry(&mut self, delta: FsDelta) {
94 match &mut self.pending {
95 Some(pending) => pending.merge(delta),
96 None => self.pending = Some(delta),
97 }
98 }
99}
100
101#[derive(Debug)]
108pub struct FsWatcher {
109 root: PathBuf,
110 entries: HashMap<PathBuf, u64>,
112 spare: HashMap<PathBuf, u64>,
115}
116
117impl FsWatcher {
118 pub fn new(root: impl Into<PathBuf>) -> Self {
123 let root = root.into();
124 let mut entries = HashMap::new();
125 let mut rel = PathBuf::new();
126 collect(&root, &mut rel, &mut |path, signature| {
127 entries.insert(path.to_path_buf(), signature);
128 });
129 Self {
130 root,
131 entries,
132 spare: HashMap::new(),
133 }
134 }
135
136 pub fn poll(&mut self) -> Option<FsDelta> {
139 let mut next = std::mem::take(&mut self.spare);
140 next.clear();
141 next.reserve(self.entries.len());
142 let mut changed: Vec<PathBuf> = Vec::new();
143 {
144 let entries = &mut self.entries;
148 let mut rel = PathBuf::new();
149 collect(
150 &self.root,
151 &mut rel,
152 &mut |path, signature| match entries.remove_entry(path) {
153 Some((key, old)) if old == signature => {
154 next.insert(key, signature);
155 }
156 Some((key, _)) => {
157 changed.push(key.clone());
158 next.insert(key, signature);
159 }
160 None => {
161 let key = path.to_path_buf();
162 changed.push(key.clone());
163 next.insert(key, signature);
164 }
165 },
166 );
167 }
168 let mut removed: Vec<PathBuf> = self.entries.drain().map(|(path, _)| path).collect();
170 self.spare = std::mem::replace(&mut self.entries, next);
171 if changed.is_empty() && removed.is_empty() {
172 return None;
173 }
174 changed.sort();
177 removed.sort();
178 Some(FsDelta { changed, removed })
179 }
180}
181
182fn signature(meta: &std::fs::Metadata) -> u64 {
189 let mtime = meta
190 .modified()
191 .ok()
192 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
193 .map(|d| d.as_nanos() as u64)
194 .unwrap_or(0);
195 let mut hasher = DefaultHasher::new();
196 meta.is_dir().hash(&mut hasher);
197 meta.len().hash(&mut hasher);
198 mtime.hash(&mut hasher);
199 hasher.finish()
200}
201
202fn collect<F: FnMut(&Path, u64)>(dir: &Path, rel: &mut PathBuf, visit: &mut F) {
210 let Ok(read) = std::fs::read_dir(dir) else {
211 return;
212 };
213 for entry in read.flatten() {
214 let Ok(meta) = entry.metadata() else {
215 continue;
216 };
217 rel.push(entry.file_name());
218 visit(rel.as_path(), signature(&meta));
219 if meta.is_dir() {
220 collect(&entry.path(), rel, visit);
221 }
222 rel.pop();
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 #[allow(clippy::wildcard_imports)]
229 use super::*;
230 use std::fs;
231 use tempfile::TempDir;
232
233 fn delta(changed: &[&str], removed: &[&str]) -> FsDelta {
234 FsDelta {
235 changed: changed.iter().map(PathBuf::from).collect(),
236 removed: removed.iter().map(PathBuf::from).collect(),
237 }
238 }
239
240 #[test]
241 fn a_fresh_watcher_reports_no_change() {
242 let tmp = TempDir::new().unwrap();
243 fs::write(tmp.path().join("a.md"), "one").unwrap();
244 let mut watcher = FsWatcher::new(tmp.path());
245 assert!(watcher.poll().is_none(), "no change since construction");
246 assert!(watcher.poll().is_none(), "still no change");
247 }
248
249 #[test]
250 fn adding_a_file_is_detected_once() {
251 let tmp = TempDir::new().unwrap();
252 let mut watcher = FsWatcher::new(tmp.path());
253 fs::write(tmp.path().join("a.md"), "one").unwrap();
254 let delta = watcher.poll().expect("new file detected");
255 assert!(delta.changed.contains(&PathBuf::from("a.md")));
256 assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
257 assert!(watcher.poll().is_none(), "reported only once");
258 }
259
260 #[test]
261 fn removing_a_file_is_detected() {
262 let tmp = TempDir::new().unwrap();
263 let file = tmp.path().join("a.md");
264 fs::write(&file, "one").unwrap();
265 let mut watcher = FsWatcher::new(tmp.path());
266 fs::remove_file(&file).unwrap();
267 let delta = watcher.poll().expect("deletion detected");
268 assert!(delta.removed.contains(&PathBuf::from("a.md")));
269 assert!(!delta.changed.contains(&PathBuf::from("a.md")));
270 }
271
272 #[test]
273 fn changing_a_file_size_is_detected() {
274 let tmp = TempDir::new().unwrap();
275 let file = tmp.path().join("a.md");
276 fs::write(&file, "one").unwrap();
277 let mut watcher = FsWatcher::new(tmp.path());
278 fs::write(&file, "a much longer body").unwrap();
279 let delta = watcher.poll().expect("content change detected");
280 assert!(delta.changed.contains(&PathBuf::from("a.md")));
281 assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
282 }
283
284 #[test]
285 fn nested_segment_paths_are_relative_to_the_root() {
286 let tmp = TempDir::new().unwrap();
287 fs::create_dir_all(tmp.path().join("alice")).unwrap();
288 let mut watcher = FsWatcher::new(tmp.path());
289 fs::write(tmp.path().join("alice").join("a.md"), "one").unwrap();
290 let delta = watcher.poll().expect("change inside a segment detected");
291 assert!(delta.changed.contains(&PathBuf::from("alice/a.md")));
292 assert!(
293 !delta.changed.iter().any(|path| path.is_absolute()),
294 "every reported path is root-relative: {:?}",
295 delta.changed
296 );
297 }
298
299 #[test]
300 fn legacy_flat_files_are_reported_by_basename() {
301 let tmp = TempDir::new().unwrap();
302 let mut watcher = FsWatcher::new(tmp.path());
303 fs::write(tmp.path().join("a.md"), "one").unwrap();
304 let delta = watcher.poll().unwrap();
305 assert!(delta.changed.contains(&PathBuf::from("a.md")));
306 assert!(
307 !delta
308 .changed
309 .iter()
310 .any(|path| path.components().count() != 1)
311 );
312 }
313
314 #[test]
315 fn a_rename_is_a_remove_and_an_add() {
316 let tmp = TempDir::new().unwrap();
317 fs::write(tmp.path().join("a.md"), "one").unwrap();
318 let mut watcher = FsWatcher::new(tmp.path());
319 fs::rename(tmp.path().join("a.md"), tmp.path().join("b.md")).unwrap();
320 let delta = watcher.poll().expect("rename detected");
321 assert!(delta.removed.contains(&PathBuf::from("a.md")));
322 assert!(delta.changed.contains(&PathBuf::from("b.md")));
323 assert!(!delta.changed.contains(&PathBuf::from("a.md")));
324 }
325
326 #[test]
327 fn missing_root_is_not_an_error() {
328 let tmp = TempDir::new().unwrap();
329 let mut watcher = FsWatcher::new(tmp.path().join("does-not-exist"));
330 assert!(watcher.poll().is_none());
331 }
332
333 #[test]
334 fn merge_keeps_the_later_observation() {
335 let mut pending = delta(&["a.md"], &[]);
336 pending.merge(delta(&["b.md"], &["a.md"]));
338 assert_eq!(pending.changed, vec![PathBuf::from("b.md")]);
339 assert_eq!(pending.removed, vec![PathBuf::from("a.md")]);
340 }
341
342 #[test]
343 fn merge_deduplicates() {
344 let mut pending = delta(&["a.md"], &["b.md"]);
345 pending.merge(delta(&["a.md"], &["b.md"]));
346 assert_eq!(pending.changed, vec![PathBuf::from("a.md")]);
347 assert_eq!(pending.removed, vec![PathBuf::from("b.md")]);
348 }
349
350 #[test]
351 fn a_burst_coalesces_into_one_apply() {
352 let mut coalescer = DeltaCoalescer::default();
353 assert!(coalescer.observe(Some(delta(&["a.md"], &[]))).is_none());
354 assert!(coalescer.observe(Some(delta(&["b.md"], &[]))).is_none());
355 let merged = coalescer
356 .observe(None)
357 .expect("the settled tick applies once");
358 assert_eq!(
359 merged.changed,
360 vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
361 );
362 assert!(coalescer.observe(None).is_none(), "no second apply");
363 }
364
365 #[test]
366 fn a_retried_delta_is_applied_again_on_the_next_quiet_tick() {
367 let mut coalescer = DeltaCoalescer::default();
368 assert!(coalescer.observe(Some(delta(&["a.md"], &[]))).is_none());
369 let failed = coalescer.observe(None).unwrap();
370 coalescer.retry(failed);
371 let retried = coalescer
372 .observe(None)
373 .expect("a retried delta is still pending");
374 assert_eq!(retried.changed, vec![PathBuf::from("a.md")]);
375 }
376}