use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug)]
pub struct FsWatcher {
root: PathBuf,
fingerprint: u64,
}
impl FsWatcher {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let fingerprint = fingerprint(&root);
Self { root, fingerprint }
}
pub fn poll(&mut self) -> bool {
let now = fingerprint(&self.root);
if now == self.fingerprint {
false
} else {
self.fingerprint = now;
true
}
}
}
fn fingerprint(root: &Path) -> u64 {
let mut entries = Vec::new();
collect(root, root, &mut entries);
entries.sort();
let mut hasher = DefaultHasher::new();
for entry in &entries {
entry.hash(&mut hasher);
}
hasher.finish()
}
fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_dir() {
out.push(format!("d:{rel}"));
collect(root, &path, out);
} else {
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
out.push(format!("f:{rel}:{}:{mtime}", meta.len()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn a_fresh_watcher_reports_no_change() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.md"), "one").unwrap();
let mut watcher = FsWatcher::new(tmp.path());
assert!(!watcher.poll(), "no change since construction");
assert!(!watcher.poll(), "still no change");
}
#[test]
fn adding_a_file_is_detected_once() {
let tmp = TempDir::new().unwrap();
let mut watcher = FsWatcher::new(tmp.path());
fs::write(tmp.path().join("a.md"), "one").unwrap();
assert!(watcher.poll(), "new file detected");
assert!(!watcher.poll(), "reported only once");
}
#[test]
fn removing_a_file_is_detected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("a.md");
fs::write(&file, "one").unwrap();
let mut watcher = FsWatcher::new(tmp.path());
fs::remove_file(&file).unwrap();
assert!(watcher.poll(), "deletion detected");
}
#[test]
fn changing_a_file_size_is_detected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("a.md");
fs::write(&file, "one").unwrap();
let mut watcher = FsWatcher::new(tmp.path());
fs::write(&file, "a much longer body").unwrap();
assert!(watcher.poll(), "content change detected");
}
#[test]
fn nested_segment_changes_are_detected() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("alice")).unwrap();
let mut watcher = FsWatcher::new(tmp.path());
fs::write(tmp.path().join("alice").join("a.md"), "one").unwrap();
assert!(watcher.poll(), "change inside a subdirectory detected");
}
#[test]
fn missing_root_is_not_an_error() {
let tmp = TempDir::new().unwrap();
let mut watcher = FsWatcher::new(tmp.path().join("does-not-exist"));
assert!(!watcher.poll());
}
}