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 second (tix #1518); this module snapshots the issues
7//! tree once and then diffs each tick, reporting **which** paths changed
8//! instead of just "something changed" (issue #10 follow-up).
9//!
10//! The snapshot is a persistent `HashMap<PathBuf, u64>` of relative path →
11//! signature (kind + size + mtime nanos). A tick walks the tree with one
12//! reusable scratch `PathBuf`, looks every entry up by `&Path`, and moves the
13//! untouched keys straight into the recycled map, so a steady-state tick
14//! allocates nothing. The previous version rebuilt a `Vec<String>` and sorted
15//! and hashed it every tick — a `format!` and a fresh `String` per entry, per
16//! tick, over the whole tree.
17
18use 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/// The paths that changed between two polls of the issues tree.
25///
26/// Every path is **relative to the watched root**, in the same form
27/// [`crate::model::Issue::filename`] uses: `<segment>/<name>.md` for a grouped
28/// issue, `<name>.md` for a legacy flat file (plus the directory paths that
29/// contain them).
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct FsDelta {
32    /// Paths that appeared or whose signature changed.
33    pub changed: Vec<PathBuf>,
34    /// Paths that disappeared.
35    pub removed: Vec<PathBuf>,
36}
37
38impl FsDelta {
39    /// Fold `next` into `self`, keeping each path in at most one list.
40    ///
41    /// A path can move between the two lists across ticks (a file added and
42    /// then removed while a burst is still settling); the **later** observation
43    /// wins, because that is the one that describes the tree as it is now.
44    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/// Buffers a burst of deltas so the board is rebuilt once, not once per write
61/// (issue #10 follow-up).
62///
63/// The event loop wakes every second (tix #1518). Feeding each poll result
64/// through [`DeltaCoalescer::observe`] returns the merged delta only on the
65/// first poll that reports **no** change while a delta is pending — i.e. once
66/// the tree has stopped moving. That costs at most one idle tick (~1s) of extra
67/// latency before an external edit appears, and turns an N-write burst into one
68/// rebuild.
69#[derive(Debug, Default)]
70pub struct DeltaCoalescer {
71    pending: Option<FsDelta>,
72}
73
74impl DeltaCoalescer {
75    /// Feed one [`FsWatcher::poll`] result; returns the delta to apply now.
76    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    /// Put a delta back after a failed apply, so the next settled tick retries
90    /// it: a file caught mid-write is a parse error now and a good read once the
91    /// writer finishes, and dropping the delta would lose the changes that were
92    /// merged with it.
93    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/// A polling watcher over a directory tree.
102///
103/// Holds the last observed signature of every entry; [`FsWatcher::poll`]
104/// re-walks the tree and reports exactly what changed. Renames, additions,
105/// deletions and content edits all show up; a re-scan that finds no difference
106/// is a no-op.
107#[derive(Debug)]
108pub struct FsWatcher {
109    root: PathBuf,
110    /// Relative path → compact signature for the last observed tree.
111    entries: HashMap<PathBuf, u64>,
112    /// Recycled map [`FsWatcher::poll`] fills, so a steady-state tick reuses
113    /// the previous table's capacity instead of allocating a new one.
114    spare: HashMap<PathBuf, u64>,
115}
116
117impl FsWatcher {
118    /// Create a watcher over `root`, snapshotting the tree.
119    ///
120    /// The initial snapshot is why the first [`FsWatcher::poll`] reports only
121    /// changes made after construction.
122    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    /// Re-walk the tree and report what changed since the last call, updating
137    /// the stored snapshot. `None` when nothing changed.
138    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            // The closure moves untouched keys out of `entries` into `next`, so
145            // an unchanged entry reuses its existing key allocation: the cost
146            // is two hash-table probes, never a fresh `PathBuf` per entry.
147            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        // Everything still in `entries` was not seen by the walk: it is gone.
169        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        // Sorted so equal deltas compare equal and logs read deterministically;
175        // both lists are tiny (only the paths that changed).
176        changed.sort();
177        removed.sort();
178        Some(FsDelta { changed, removed })
179    }
180}
181
182/// A compact signature of one entry: kind, size and modification time mixed
183/// into a single `u64`.
184///
185/// Any content edit, rename, addition or deletion changes at least one input.
186/// `DefaultHasher` is the fixed, dependency-free mix `std` ships; one small
187/// hash per entry is far cheaper than the descriptor `String` it replaced.
188fn 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
202/// Walk `dir` recursively, calling `visit` with the root-relative path and
203/// signature of every entry (files **and** directories).
204///
205/// `rel` is a scratch buffer pushed and popped around each entry, so the walk
206/// allocates nothing per entry: directories are the only paths joined, and
207/// there are few of them. `visit` is a generic parameter rather than a
208/// `dyn FnMut` so the per-entry call is direct even in an unoptimized build.
209fn 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        // `a.md` reappears as removed, `b.md` as changed.
337        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}