tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Centralized, prunable backups of issue files (#1581).
//!
//! Every write that replaces an issue's on-disk content — the TUI's editor /
//! repair / conflict path and `tix import`'s overwrite of a newer remote issue —
//! first copies the previous bytes here, into a hidden `.tix-backups/`
//! directory **beside** the issues directory (the same sibling convention
//! [`crate::storage`]'s number-claim ledger uses). One directory collects every
//! backup, so they are discoverable in one place instead of scattered through
//! the `{assignee}/{type}/{status}` segments, and [`prune`] bounds how many are
//! kept.
//!
//! A backup is named `<stem>.<unix_nanos>.md`, where `<stem>` is the issue
//! file's basename without its `.md` extension: the timestamp both orders the
//! copies and keeps repeated saves of one issue distinct. `tix clean --keep N`
//! prunes the directory to the newest `N` per stem.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::error::Result;

/// The hidden directory beside `issues_dir` that holds every backup.
///
/// Derived from `issues_dir` rather than hardcoded, so a repo that points
/// `issues_dir` somewhere else still gets a ledger beside that — exactly how
/// [`crate::storage`] derives its number-claim directory.
pub fn dir(issues_dir: &Path) -> PathBuf {
    issues_dir
        .parent()
        .unwrap_or(issues_dir)
        .join(".tix-backups")
}

/// Copy `<issues_dir>/<filename>` into [`dir`] under a timestamped name,
/// returning the backup path.
///
/// Returns `None` when the issue cannot be read or the copy cannot be written: a
/// backup is best-effort, and the caller reports the path it gets rather than
/// failing the write it was protecting.
pub fn save(issues_dir: &Path, filename: &str) -> Option<PathBuf> {
    let path = issues_dir.join(filename);
    let bytes = std::fs::read(&path).ok()?;
    let stem = Path::new(filename)
        .file_stem()
        .map(|s| s.to_string_lossy().to_string())?;
    let backup_dir = dir(issues_dir);
    std::fs::create_dir_all(&backup_dir).ok()?;
    let backup = backup_dir.join(format!("{stem}.{}.md", stamp()));
    std::fs::write(&backup, bytes).ok()?;
    Some(backup)
}

/// Every backup under [`dir`], newest first by the timestamp in its name.
pub fn list(issues_dir: &Path) -> Vec<PathBuf> {
    let mut found: Vec<(u64, PathBuf)> = std::fs::read_dir(dir(issues_dir))
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|entry| {
            let path = entry.path();
            let (_, nanos) = split_name(&path)?;
            Some((nanos, path))
        })
        .collect();
    found.sort_by_key(|a| std::cmp::Reverse(a.0));
    found.into_iter().map(|(_, path)| path).collect()
}

/// Keep the newest `keep` backups per issue stem, deleting the rest.
///
/// Grouping by the `<stem>` key makes each issue's retention independent: a busy
/// issue never evicts a quiet one's only backup. `keep == 0` removes every
/// backup. Returns how many files were deleted; a missing directory is `0`.
pub fn prune(issues_dir: &Path, keep: usize) -> Result<usize> {
    let entries = match std::fs::read_dir(dir(issues_dir)) {
        Ok(entries) => entries,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
        Err(e) => return Err(e.into()),
    };
    let mut groups: BTreeMap<String, Vec<(u64, PathBuf)>> = BTreeMap::new();
    for entry in entries {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue;
        }
        if let Some((stem, nanos)) = split_name(&entry.path()) {
            groups.entry(stem).or_default().push((nanos, entry.path()));
        }
    }
    let mut removed = 0;
    for mut group in groups.into_values() {
        group.sort_by_key(|a| std::cmp::Reverse(a.0));
        for (_, path) in group.into_iter().skip(keep) {
            std::fs::remove_file(&path)?;
            removed += 1;
        }
    }
    Ok(removed)
}

/// The `<stem>` and timestamp of a backup file name, or `None` when the name is
/// not a backup (`<stem>.<nanos>.md`).
fn split_name(path: &Path) -> Option<(String, u64)> {
    let name = path.file_name()?.to_str()?;
    let stem_and_nanos = name.strip_suffix(".md")?;
    let (stem, nanos) = stem_and_nanos.rsplit_once('.')?;
    let nanos: u64 = nanos.parse().ok()?;
    Some((stem.to_string(), nanos))
}

/// The next unique backup stamp: wall-clock nanoseconds, forced strictly
/// increasing so two saves within one nanosecond (or under a coarse clock) never
/// collide into a single name.
fn stamp() -> u64 {
    static LAST: AtomicU64 = AtomicU64::new(0);
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let mut last = LAST.load(Ordering::Relaxed);
    loop {
        let next = now.max(last + 1);
        match LAST.compare_exchange_weak(last, next, Ordering::Relaxed, Ordering::Relaxed) {
            Ok(_) => return next,
            Err(observed) => last = observed,
        }
    }
}

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

    /// An isolated `(tmp, issues_dir)` pair: the issues directory is a child of
    /// the tempdir, so the backup directory lands inside the tempdir too.
    fn fixture() -> (TempDir, PathBuf) {
        let tmp = TempDir::new().unwrap();
        let issues = tmp.path().join("issues");
        fs::create_dir_all(&issues).unwrap();
        (tmp, issues)
    }

    #[test]
    fn save_writes_a_timestamped_copy_into_the_backup_dir() {
        let (_tmp, issues) = fixture();
        fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();

        let backup = save(&issues, "2026-01-01-fix.md").expect("a backup is written");

        assert_eq!(backup.parent(), Some(dir(&issues).as_path()));
        let name = backup.file_name().unwrap().to_string_lossy();
        let (stem, nanos) = split_name(&backup).expect("the name is `<stem>.<nanos>.md`");
        assert_eq!(stem, "2026-01-01-fix");
        assert!(nanos > 0);
        assert!(name.ends_with(".md"), "the copy keeps a .md name: {name}");
        assert_eq!(fs::read_to_string(&backup).unwrap(), "previous bytes");
    }

    /// A save never leaves a scattered `.tixbak`/`.md.bak` beside the issue; the
    /// only copy is the centralized one. Reverting `save` to the old
    /// `<name>.md.tixbak` location makes this fail.
    #[test]
    fn save_leaves_no_scattered_backup_beside_the_issue() {
        let (_tmp, issues) = fixture();
        fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();

        save(&issues, "2026-01-01-fix.md").unwrap();

        assert!(
            !issues.join("2026-01-01-fix.md.tixbak").exists(),
            "no .tixbak beside the issue"
        );
        assert!(
            !issues.join("2026-01-01-fix.md.bak").exists(),
            "no .md.bak beside the issue"
        );
        assert_eq!(list(&issues).len(), 1, "the copy is in the centralized dir");
    }

    #[test]
    fn list_returns_newest_first() {
        let (_tmp, issues) = fixture();
        fs::write(issues.join("2026-01-01-fix.md"), "v1").unwrap();
        let first = save(&issues, "2026-01-01-fix.md").unwrap();
        let second = save(&issues, "2026-01-01-fix.md").unwrap();
        let third = save(&issues, "2026-01-01-fix.md").unwrap();

        assert_eq!(list(&issues), vec![third, second, first]);
    }

    #[test]
    fn prune_keeps_the_newest_per_issue_key() {
        let (_tmp, issues) = fixture();
        fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
        fs::write(issues.join("2026-01-02-b.md"), "b").unwrap();
        let a1 = save(&issues, "2026-01-01-a.md").unwrap();
        let a2 = save(&issues, "2026-01-01-a.md").unwrap();
        let a3 = save(&issues, "2026-01-01-a.md").unwrap();
        let b1 = save(&issues, "2026-01-02-b.md").unwrap();
        let b2 = save(&issues, "2026-01-02-b.md").unwrap();

        // `tix clean --keep 1` (the acceptance criterion): one per stem, and the
        // issue files themselves are untouched.
        let removed = prune(&issues, 1).unwrap();

        assert_eq!(removed, 3, "a's older two and b's older one are removed");
        assert_eq!(list(&issues), vec![b2, a3]);
        assert!(!a1.exists() && !a2.exists() && !b1.exists());
        assert!(issues.join("2026-01-01-a.md").exists());
        assert!(issues.join("2026-01-02-b.md").exists());

        // A second run at `keep 1` is a no-op.
        assert_eq!(prune(&issues, 1).unwrap(), 0);
    }

    #[test]
    fn prune_zero_removes_every_backup() {
        let (tmp, issues) = fixture();
        fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
        save(&issues, "2026-01-01-a.md").unwrap();
        save(&issues, "2026-01-01-a.md").unwrap();

        assert_eq!(prune(&issues, 0).unwrap(), 2);
        assert_eq!(list(&issues), [] as [std::path::PathBuf; 0]);

        // A missing directory is not an error.
        assert_eq!(prune(&tmp.path().join("absent"), 5).unwrap(), 0);
    }
}