tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Dependency-free polling filesystem watcher (issue #10).
//!
//! The TUI must reflect issue files edited outside `tix`: "Watch the files
//! inside the docs/issues folder and update the kanban board once something
//! changes." Rather than pull in a platform-specific watcher, the event loop
//! already wakes every second (tix #1518); this module snapshots the issues
//! tree once and then diffs each tick, reporting **which** paths changed
//! instead of just "something changed" (issue #10 follow-up).
//!
//! The snapshot is a persistent `HashMap<PathBuf, u64>` of relative path →
//! signature (kind + size + mtime nanos). A tick walks the tree with one
//! reusable scratch `PathBuf`, looks every entry up by `&Path`, and moves the
//! untouched keys straight into the recycled map, so a steady-state tick
//! allocates nothing. The previous version rebuilt a `Vec<String>` and sorted
//! and hashed it every tick — a `format!` and a fresh `String` per entry, per
//! tick, over the whole tree.

use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// The paths that changed between two polls of the issues tree.
///
/// Every path is **relative to the watched root**, in the same form
/// [`crate::model::Issue::filename`] uses: `<segment>/<name>.md` for a grouped
/// issue, `<name>.md` for a legacy flat file (plus the directory paths that
/// contain them).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FsDelta {
    /// Paths that appeared or whose signature changed.
    pub changed: Vec<PathBuf>,
    /// Paths that disappeared.
    pub removed: Vec<PathBuf>,
}

impl FsDelta {
    /// Fold `next` into `self`, keeping each path in at most one list.
    ///
    /// A path can move between the two lists across ticks (a file added and
    /// then removed while a burst is still settling); the **later** observation
    /// wins, because that is the one that describes the tree as it is now.
    pub fn merge(&mut self, next: FsDelta) {
        for path in next.changed {
            self.removed.retain(|existing| existing != &path);
            if !self.changed.contains(&path) {
                self.changed.push(path);
            }
        }
        for path in next.removed {
            self.changed.retain(|existing| existing != &path);
            if !self.removed.contains(&path) {
                self.removed.push(path);
            }
        }
    }
}

/// Buffers a burst of deltas so the board is rebuilt once, not once per write
/// (issue #10 follow-up).
///
/// The event loop wakes every second (tix #1518). Feeding each poll result
/// through [`DeltaCoalescer::observe`] returns the merged delta only on the
/// first poll that reports **no** change while a delta is pending — i.e. once
/// the tree has stopped moving. That costs at most one idle tick (~1s) of extra
/// latency before an external edit appears, and turns an N-write burst into one
/// rebuild.
#[derive(Debug, Default)]
pub struct DeltaCoalescer {
    pending: Option<FsDelta>,
}

impl DeltaCoalescer {
    /// Feed one [`FsWatcher::poll`] result; returns the delta to apply now.
    pub fn observe(&mut self, polled: Option<FsDelta>) -> Option<FsDelta> {
        match polled {
            Some(delta) => {
                match &mut self.pending {
                    Some(pending) => pending.merge(delta),
                    None => self.pending = Some(delta),
                }
                None
            }
            None => self.pending.take(),
        }
    }

    /// Put a delta back after a failed apply, so the next settled tick retries
    /// it: a file caught mid-write is a parse error now and a good read once the
    /// writer finishes, and dropping the delta would lose the changes that were
    /// merged with it.
    pub fn retry(&mut self, delta: FsDelta) {
        match &mut self.pending {
            Some(pending) => pending.merge(delta),
            None => self.pending = Some(delta),
        }
    }
}

/// A polling watcher over a directory tree.
///
/// Holds the last observed signature of every entry; [`FsWatcher::poll`]
/// re-walks the tree and reports exactly what changed. Renames, additions,
/// deletions and content edits all show up; a re-scan that finds no difference
/// is a no-op.
#[derive(Debug)]
pub struct FsWatcher {
    root: PathBuf,
    /// Relative path → compact signature for the last observed tree.
    entries: HashMap<PathBuf, u64>,
    /// Recycled map [`FsWatcher::poll`] fills, so a steady-state tick reuses
    /// the previous table's capacity instead of allocating a new one.
    spare: HashMap<PathBuf, u64>,
}

impl FsWatcher {
    /// Create a watcher over `root`, snapshotting the tree.
    ///
    /// The initial snapshot is why the first [`FsWatcher::poll`] reports only
    /// changes made after construction.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        let root = root.into();
        let mut entries = HashMap::new();
        let mut rel = PathBuf::new();
        collect(&root, &mut rel, &mut |path, signature| {
            entries.insert(path.to_path_buf(), signature);
        });
        Self {
            root,
            entries,
            spare: HashMap::new(),
        }
    }

    /// Re-walk the tree and report what changed since the last call, updating
    /// the stored snapshot. `None` when nothing changed.
    pub fn poll(&mut self) -> Option<FsDelta> {
        let mut next = std::mem::take(&mut self.spare);
        next.clear();
        next.reserve(self.entries.len());
        let mut changed: Vec<PathBuf> = Vec::new();
        {
            // The closure moves untouched keys out of `entries` into `next`, so
            // an unchanged entry reuses its existing key allocation: the cost
            // is two hash-table probes, never a fresh `PathBuf` per entry.
            let entries = &mut self.entries;
            let mut rel = PathBuf::new();
            collect(
                &self.root,
                &mut rel,
                &mut |path, signature| match entries.remove_entry(path) {
                    Some((key, old)) if old == signature => {
                        next.insert(key, signature);
                    }
                    Some((key, _)) => {
                        changed.push(key.clone());
                        next.insert(key, signature);
                    }
                    None => {
                        let key = path.to_path_buf();
                        changed.push(key.clone());
                        next.insert(key, signature);
                    }
                },
            );
        }
        // Everything still in `entries` was not seen by the walk: it is gone.
        let mut removed: Vec<PathBuf> = self.entries.drain().map(|(path, _)| path).collect();
        self.spare = std::mem::replace(&mut self.entries, next);
        if changed.is_empty() && removed.is_empty() {
            return None;
        }
        // Sorted so equal deltas compare equal and logs read deterministically;
        // both lists are tiny (only the paths that changed).
        changed.sort();
        removed.sort();
        Some(FsDelta { changed, removed })
    }
}

/// A compact signature of one entry: kind, size and modification time mixed
/// into a single `u64`.
///
/// Any content edit, rename, addition or deletion changes at least one input.
/// `DefaultHasher` is the fixed, dependency-free mix `std` ships; one small
/// hash per entry is far cheaper than the descriptor `String` it replaced.
fn signature(meta: &std::fs::Metadata) -> u64 {
    let mtime = meta
        .modified()
        .ok()
        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let mut hasher = DefaultHasher::new();
    meta.is_dir().hash(&mut hasher);
    meta.len().hash(&mut hasher);
    mtime.hash(&mut hasher);
    hasher.finish()
}

/// Walk `dir` recursively, calling `visit` with the root-relative path and
/// signature of every entry (files **and** directories).
///
/// `rel` is a scratch buffer pushed and popped around each entry, so the walk
/// allocates nothing per entry: directories are the only paths joined, and
/// there are few of them. `visit` is a generic parameter rather than a
/// `dyn FnMut` so the per-entry call is direct even in an unoptimized build.
fn collect<F: FnMut(&Path, u64)>(dir: &Path, rel: &mut PathBuf, visit: &mut F) {
    let Ok(read) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in read.flatten() {
        let Ok(meta) = entry.metadata() else {
            continue;
        };
        rel.push(entry.file_name());
        visit(rel.as_path(), signature(&meta));
        if meta.is_dir() {
            collect(&entry.path(), rel, visit);
        }
        rel.pop();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn delta(changed: &[&str], removed: &[&str]) -> FsDelta {
        FsDelta {
            changed: changed.iter().map(PathBuf::from).collect(),
            removed: removed.iter().map(PathBuf::from).collect(),
        }
    }

    #[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().is_none(), "no change since construction");
        assert!(watcher.poll().is_none(), "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();
        let delta = watcher.poll().expect("new file detected");
        assert!(delta.changed.contains(&PathBuf::from("a.md")));
        assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
        assert!(watcher.poll().is_none(), "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();
        let delta = watcher.poll().expect("deletion detected");
        assert!(delta.removed.contains(&PathBuf::from("a.md")));
        assert!(!delta.changed.contains(&PathBuf::from("a.md")));
    }

    #[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();
        let delta = watcher.poll().expect("content change detected");
        assert!(delta.changed.contains(&PathBuf::from("a.md")));
        assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
    }

    #[test]
    fn nested_segment_paths_are_relative_to_the_root() {
        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();
        let delta = watcher.poll().expect("change inside a segment detected");
        assert!(delta.changed.contains(&PathBuf::from("alice/a.md")));
        assert!(
            !delta.changed.iter().any(|path| path.is_absolute()),
            "every reported path is root-relative: {:?}",
            delta.changed
        );
    }

    #[test]
    fn legacy_flat_files_are_reported_by_basename() {
        let tmp = TempDir::new().unwrap();
        let mut watcher = FsWatcher::new(tmp.path());
        fs::write(tmp.path().join("a.md"), "one").unwrap();
        let delta = watcher.poll().unwrap();
        assert!(delta.changed.contains(&PathBuf::from("a.md")));
        assert!(
            !delta
                .changed
                .iter()
                .any(|path| path.components().count() != 1)
        );
    }

    #[test]
    fn a_rename_is_a_remove_and_an_add() {
        let tmp = TempDir::new().unwrap();
        fs::write(tmp.path().join("a.md"), "one").unwrap();
        let mut watcher = FsWatcher::new(tmp.path());
        fs::rename(tmp.path().join("a.md"), tmp.path().join("b.md")).unwrap();
        let delta = watcher.poll().expect("rename detected");
        assert!(delta.removed.contains(&PathBuf::from("a.md")));
        assert!(delta.changed.contains(&PathBuf::from("b.md")));
        assert!(!delta.changed.contains(&PathBuf::from("a.md")));
    }

    #[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().is_none());
    }

    #[test]
    fn merge_keeps_the_later_observation() {
        let mut pending = delta(&["a.md"], &[]);
        // `a.md` reappears as removed, `b.md` as changed.
        pending.merge(delta(&["b.md"], &["a.md"]));
        assert_eq!(pending.changed, vec![PathBuf::from("b.md")]);
        assert_eq!(pending.removed, vec![PathBuf::from("a.md")]);
    }

    #[test]
    fn merge_deduplicates() {
        let mut pending = delta(&["a.md"], &["b.md"]);
        pending.merge(delta(&["a.md"], &["b.md"]));
        assert_eq!(pending.changed, vec![PathBuf::from("a.md")]);
        assert_eq!(pending.removed, vec![PathBuf::from("b.md")]);
    }

    #[test]
    fn a_burst_coalesces_into_one_apply() {
        let mut coalescer = DeltaCoalescer::default();
        assert!(coalescer.observe(Some(delta(&["a.md"], &[]))).is_none());
        assert!(coalescer.observe(Some(delta(&["b.md"], &[]))).is_none());
        let merged = coalescer
            .observe(None)
            .expect("the settled tick applies once");
        assert_eq!(
            merged.changed,
            vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
        );
        assert!(coalescer.observe(None).is_none(), "no second apply");
    }

    #[test]
    fn a_retried_delta_is_applied_again_on_the_next_quiet_tick() {
        let mut coalescer = DeltaCoalescer::default();
        assert!(coalescer.observe(Some(delta(&["a.md"], &[]))).is_none());
        let failed = coalescer.observe(None).unwrap();
        coalescer.retry(failed);
        let retried = coalescer
            .observe(None)
            .expect("a retried delta is still pending");
        assert_eq!(retried.changed, vec![PathBuf::from("a.md")]);
    }
}