tix-tracker 0.4.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 200ms; this module fingerprints the issues tree on each
//! tick and reports when it differs, so `App::run` can rebuild the board.

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

/// A polling watcher over a directory tree.
///
/// The fingerprint covers every entry's relative path, kind (file/dir), size
/// and modification time. Renames, additions, deletions and content edits all
/// change it; a mere re-scan that finds no difference is a no-op.
#[derive(Debug)]
pub struct FsWatcher {
    root: PathBuf,
    fingerprint: u64,
}

impl FsWatcher {
    /// Create a watcher over `root`, taking an initial fingerprint.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        let root = root.into();
        let fingerprint = fingerprint(&root);
        Self { root, fingerprint }
    }

    /// Re-fingerprint the tree. Returns `true` when it changed since the last
    /// call, updating the stored fingerprint.
    pub fn poll(&mut self) -> bool {
        let now = fingerprint(&self.root);
        if now == self.fingerprint {
            false
        } else {
            self.fingerprint = now;
            true
        }
    }
}

/// A stable hash over the sorted list of entry descriptors in `root`.
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()
}

/// Recursively collect one descriptor string per directory entry.
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());
    }
}