tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix clean`: bound the backups `tix` keeps and remove the scattered ones the
//! old layout left behind.
//!
//! Every write that replaces an issue first copies the previous content into the
//! centralized `.tix-backups/` directory beside the issues directory (#1581) —
//! the TUI (an editor save, a repair, a conflict resolution) and `tix import`
//! (a newer remote issue overwriting the local one) both go through it. This
//! command prunes that directory to the newest `N` per issue (`--keep`, default
//! 10), so it never grows without bound.
//!
//! Releases before the centralized directory wrote the copy *beside* the issue
//! (the TUI's `<name>.md.tixbak` and the import's `<name>.md.bak`). Those files
//! are still swept up here — each confirmed on the TTY, or all at once with
//! `--force` — so an existing project can migrate. That scattered-file removal
//! is deliberately the **migration path and can be deleted in a future
//! release** once no such files remain in the wild; `tix init` still gitignores
//! both suffixes for the same window.

use std::io::IsTerminal;
use std::path::{Path, PathBuf};

use crate::error::{Result, TixError};
use crate::storage::Store;

/// The suffix of the legacy backup the TUI wrote beside an issue it rewrote
/// (an editor save, a repair, or a conflict resolution): `<name>.md.tixbak`.
///
/// Only the migration sweep in [`run`] uses this now; new backups go through
/// [`crate::backup`].
pub const BACKUP_SUFFIX: &str = ".tixbak";

/// The suffix the legacy import path wrote beside a local issue a newer remote
/// one overwrote: the issue's own name plus `.bak`, i.e. `<name>.md.bak`.
///
/// Anchored on `.md` so the migration sweep removes **only** backups of tix
/// issue files — a stray `notes.txt.bak` or a hand-made `foo.bak` in the tree is
/// left alone, exactly as the `*.md.bak` line `tix init` writes into
/// `.gitignore` intends.
pub const ISSUE_BACKUP_SUFFIX: &str = ".md.bak";

/// Prune the centralized backup directory to the newest `keep` per issue, then
/// remove the legacy scattered backups under `store`'s issues directory.
///
/// Pruning is automatic — it keeps the newest `keep` per issue key and needs no
/// confirmation — while each legacy file is confirmed on the TTY unless `force`
/// is set, so a bare `tix clean` never deletes a user's old backup without
/// approval. The directory is walked recursively (the `{assignee}/{type}/{status}`
/// layout nests), and only files whose name ends in [`BACKUP_SUFFIX`] or
/// [`ISSUE_BACKUP_SUFFIX`] are considered — issue files and assets are never
/// touched. A missing issues directory is not an error: there is simply nothing
/// to clean.
pub fn run(store: &Store, force: bool, keep: usize) -> Result<()> {
    let pruned = crate::backup::prune(&store.issues_dir, keep)?;
    if pruned > 0 {
        println!(
            "Pruned {pruned} backup file{} from {}",
            plural(pruned),
            crate::backup::dir(&store.issues_dir).display()
        );
    }

    let files = collect_backups(&store.issues_dir)?;
    if files.is_empty() {
        if pruned == 0 {
            println!("No backup files to clean");
        }
        return Ok(());
    }
    if !force && !std::io::stdin().is_terminal() {
        return Err(TixError::InvalidInput(
            "tix clean confirms each file; run it interactively or pass --force to remove every backup without prompting".into(),
        ));
    }

    let mut removed = 0;
    for path in &files {
        let delete = force
            || is_yes(&crate::editor::read_tty_line(&format!(
                "Remove {}? [y/N] ",
                path.display()
            ))?);
        if delete {
            std::fs::remove_file(path)?;
            removed += 1;
        }
    }
    println!(
        "Removed {removed} of {} backup file{}",
        files.len(),
        plural(files.len())
    );
    Ok(())
}

/// Recursively collect the backup files under `dir`, in directory order.
fn collect_backups(dir: &Path) -> Result<Vec<PathBuf>> {
    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(e.into()),
    };
    let mut files = Vec::new();
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        if entry.file_type()?.is_dir() {
            files.extend(collect_backups(&path)?);
        } else if is_backup(&path) {
            files.push(path);
        }
    }
    Ok(files)
}

/// Whether `path`'s file name ends in [`BACKUP_SUFFIX`] or
/// [`ISSUE_BACKUP_SUFFIX`].
fn is_backup(path: &Path) -> bool {
    path.file_name().is_some_and(|name| {
        let name = name.to_string_lossy();
        name.ends_with(BACKUP_SUFFIX) || name.ends_with(ISSUE_BACKUP_SUFFIX)
    })
}

/// Whether a prompt answer means "yes" (`y`/`yes`, any case; the default on an
/// empty line is no).
fn is_yes(answer: &str) -> bool {
    answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")
}

/// `"s"` for a count that is not exactly one, `""` otherwise.
fn plural(n: usize) -> &'static str {
    if n == 1 { "" } else { "s" }
}

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

    fn store_in(dir: &Path) -> Store {
        let mut config = Config::starter();
        config.issues_dir = dir.to_path_buf();
        Store::open(&config).unwrap()
    }

    #[test]
    fn removes_backups_at_any_depth_and_leaves_other_files() {
        let tmp = TempDir::new().unwrap();
        let store = store_in(tmp.path());
        let nested = tmp.path().join("herbert").join("default").join("todo");
        fs::create_dir_all(&nested).unwrap();
        fs::write(nested.join("2026-01-01-a.md"), "an issue").unwrap();
        fs::write(nested.join("2026-01-01-a.md.tixbak"), "old").unwrap();
        fs::write(nested.join("2026-01-01-a.md.bak"), "old").unwrap();
        fs::write(tmp.path().join("loose.md.tixbak"), "old").unwrap();
        fs::write(tmp.path().join("keep.bak"), "not ours").unwrap();
        fs::write(tmp.path().join("notes.txt.bak"), "not ours").unwrap();

        run(&store, true, 10).unwrap();

        assert!(!nested.join("2026-01-01-a.md.tixbak").exists());
        assert!(!nested.join("2026-01-01-a.md.bak").exists());
        assert!(!tmp.path().join("loose.md.tixbak").exists());
        assert!(nested.join("2026-01-01-a.md").exists(), "issues are kept");
        assert!(tmp.path().join("keep.bak").exists(), "a plain .bak is kept");
        assert!(
            tmp.path().join("notes.txt.bak").exists(),
            "only .tixbak and issue backups (.md.bak) are removed"
        );
    }

    /// The two suffixes are exact: only a tix backup is a backup, so a plain
    /// `.bak` of something else is never swept up.
    #[test]
    fn only_tix_backups_match() {
        assert!(is_backup(Path::new("2026-01-01-a.md.tixbak")));
        assert!(is_backup(Path::new("2026-01-01-a.md.bak")));
        assert!(!is_backup(Path::new("2026-01-01-a.md")));
        assert!(!is_backup(Path::new("keep.bak")));
        assert!(!is_backup(Path::new("notes.txt.bak")));
        assert!(!is_backup(Path::new("a.tixbak.md")));
    }

    /// Only an explicit `y`/`yes` approves a delete; an empty line (just Enter)
    /// declines, and so does anything else.
    #[test]
    fn is_yes_is_explicit() {
        assert!(is_yes("y"));
        assert!(is_yes("Y"));
        assert!(is_yes("yes"));
        assert!(is_yes("Yes"));
        assert!(!is_yes(""));
        assert!(!is_yes("n"));
        assert!(!is_yes("no"));
        assert!(!is_yes("yeah"));
    }

    #[test]
    fn a_missing_issues_dir_is_not_an_error() {
        let tmp = TempDir::new().unwrap();
        let store = store_in(&tmp.path().join("does-not-exist"));
        run(&store, true, 10).unwrap();
    }

    /// `tix clean --keep 1` prunes the centralized directory to the newest
    /// backup per issue key, leaves the issue files untouched, and still removes
    /// the legacy scattered backup. Reverting either half turns it red.
    #[test]
    fn keep_one_prunes_central_backups_and_still_sweeps_legacy_files() {
        let tmp = TempDir::new().unwrap();
        let issues = tmp.path().join("issues");
        fs::create_dir_all(&issues).unwrap();
        let store = store_in(&issues);
        fs::write(issues.join("2026-01-01-a.md"), "an issue").unwrap();
        let first = crate::backup::save(&issues, "2026-01-01-a.md").unwrap();
        let second = crate::backup::save(&issues, "2026-01-01-a.md").unwrap();
        // A legacy backup still beside the issue from an older release.
        fs::write(issues.join("2026-01-01-a.md.tixbak"), "old").unwrap();

        run(&store, true, 1).unwrap();

        assert_eq!(
            crate::backup::list(&issues),
            vec![second],
            "keep the newest per issue key"
        );
        assert!(!first.exists(), "the older central backup is pruned");
        assert!(
            issues.join("2026-01-01-a.md").exists(),
            "issue files are untouched"
        );
        assert!(
            !issues.join("2026-01-01-a.md.tixbak").exists(),
            "the legacy scattered file is still swept"
        );
    }
}