tix-tracker 0.7.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix clean`: remove the backup files `tix` leaves beside an issue when it
//! rewrites one.
//!
//! Two paths write a backup: the TUI copies the previous content to
//! `<name>.md.tixbak` before an editor save, a repair, or a conflict
//! resolution (so the change is always recoverable), and `tix import` copies a
//! local issue to `<name>.md.bak` before a newer remote issue overwrites it.
//! Once they are no longer wanted the files are pure litter. `tix init` keeps
//! `*.tixbak` and `*.md.bak` in `.gitignore` so they are never committed, and
//! this command removes the ones already on disk.
//!
//! By default each file is confirmed on the TTY; `--force` removes them all
//! without prompting.

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

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

/// The suffix of every backup `tix` writes beside an issue it rewrites through
/// the TUI (an editor save, a repair, or a conflict resolution):
/// `<name>.md.tixbak`.
pub const BACKUP_SUFFIX: &str = ".tixbak";

/// The suffix `tix import` writes when a newer remote issue overwrites a local
/// one: the issue's own name plus `.bak`, i.e. `<name>.md.bak`.
///
/// Anchored on `.md` so `clean` 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";

/// Remove the backup files under `store`'s issues directory.
///
/// With `force`, every backup is removed without prompting; otherwise each file
/// is confirmed on the TTY (`Remove <path>? [y/N]`), so a bare `tix clean`
/// never deletes anything the user did not approve. 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) -> Result<()> {
    let files = collect_backups(&store.issues_dir)?;
    if files.is_empty() {
        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 {
    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).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).unwrap();
    }
}