Skip to main content

tix/
watcher.rs

1//! Dependency-free polling filesystem watcher (issue #10).
2//!
3//! The TUI must reflect issue files edited outside `tix`: "Watch the files
4//! inside the docs/issues folder and update the kanban board once something
5//! changes." Rather than pull in a platform-specific watcher, the event loop
6//! already wakes every 200ms; this module fingerprints the issues tree on each
7//! tick and reports when it differs, so `App::run` can rebuild the board.
8
9use std::collections::hash_map::DefaultHasher;
10use std::hash::{Hash, Hasher};
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14/// A polling watcher over a directory tree.
15///
16/// The fingerprint covers every entry's relative path, kind (file/dir), size
17/// and modification time. Renames, additions, deletions and content edits all
18/// change it; a mere re-scan that finds no difference is a no-op.
19#[derive(Debug)]
20pub struct FsWatcher {
21    root: PathBuf,
22    fingerprint: u64,
23}
24
25impl FsWatcher {
26    /// Create a watcher over `root`, taking an initial fingerprint.
27    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    /// Re-fingerprint the tree. Returns `true` when it changed since the last
34    /// call, updating the stored fingerprint.
35    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
46/// A stable hash over the sorted list of entry descriptors in `root`.
47fn 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
58/// Recursively collect one descriptor string per directory entry.
59fn 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}