tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix archive <issue>…` / `tix restore <issue>…` (issue #1492).
//!
//! Archiving is a **rename**, not a delete: an issue's markdown file is
//! dot-prefixed in place (`docs/issues/<assignee>/x.md` →
//! `docs/issues/<assignee>/.x.md`), so its number, status and every other
//! frontmatter field survive and it never moves between directories. The
//! filename is the only marker; there is no `archived:` frontmatter key.
//!
//! A batch processes every selector independently. One item's failure — a
//! redundant operation, an ambiguous selector, a missing file — is reported on
//! its own and does **not** abort or roll back the others, which is why this
//! handler returns an [`ExitCode`] rather than a `Result<()>` and is routed by
//! `cli::run` rather than by `commands::dispatch`.

use crate::cli::ExitCode;
use crate::error::Result;
use crate::storage::Store;

/// Which direction of the operation a batch is performing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
    Archive,
    Restore,
}

impl Action {
    fn past_tense(self) -> &'static str {
        match self {
            Action::Archive => "Archived",
            Action::Restore => "Restored",
        }
    }
}

/// Run one archive/restore batch over `selectors` and report a process status.
///
/// Each selector produces exactly one outcome line on success (`Archived
/// alice/.2026-01-01-x.md`) or one diagnostic on stderr on failure. The
/// returned code is the first failure's, so a batch that partly succeeded still
/// exits non-zero; an unknown-error failure maps through
/// [`crate::cli::exit_code_for`] so a `NotFound` stays exit `2`.
pub fn run(store: &Store, action: Action, selectors: &[String]) -> Result<ExitCode> {
    let mut code = ExitCode::Success;
    for selector in selectors {
        // One transaction **per selector** (#1580). The batch contract is one
        // outcome per selector — a failure on one item must not roll back a
        // sibling — so this is deliberately not a transaction over the whole
        // batch. Within one selector, `set_archived` renames the file and then
        // writes its History event, and a failure between the two must leave the
        // file exactly where and as it was.
        let outcome = store.transaction(|| match action {
            Action::Archive => store.archive(selector),
            Action::Restore => store.restore(selector),
        });
        match outcome {
            Ok(issue) => println!("{} {}", action.past_tense(), issue.filename),
            Err(e) => {
                eprintln!("{}", crate::error::render_error(&e));
                if code == ExitCode::Success {
                    code = crate::cli::exit_code_for(&e);
                }
            }
        }
    }
    Ok(code)
}

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

    fn make_store() -> (TempDir, Store) {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("issues");
        fs::create_dir_all(dir.join("alice")).unwrap();
        let mut config = crate::config::Config {
            issues_dir: dir,
            ..crate::config::Config::default()
        };
        // The fixtures use bare filenames; keep the tests about archive semantics,
        // not auto-migration.
        config.layout.move_on_change = false;
        (tmp, Store::open(&config).unwrap())
    }

    fn write_issue(store: &Store, name: &str, number: u32, title: &str) {
        let contents = format!(
            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
             priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody\n"
        );
        fs::write(store.issues_dir.join(name), contents).unwrap();
    }

    #[test]
    fn one_failure_does_not_roll_back_another_item() {
        let (_tmp, store) = make_store();
        write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
        write_issue(&store, "alice/2026-01-01-b.md", 2, "B");

        // First selector is bogus, second is real: the batch must still archive #2.
        let code = run(&store, Action::Archive, &["999".into(), "2".into()]).unwrap();
        assert_ne!(code, ExitCode::Success, "the bogus item fails the batch");
        assert!(
            store.issues_dir.join("alice/.2026-01-01-b.md").is_file(),
            "the valid item is archived despite the earlier failure"
        );
        assert!(store.issues_dir.join("alice/2026-01-01-a.md").is_file());
    }

    #[test]
    fn redundant_operation_reports_but_other_items_proceed() {
        let (_tmp, store) = make_store();
        write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
        write_issue(&store, "alice/2026-01-01-b.md", 2, "B");
        // Archive #1 twice, then #2: the second #1 is redundant, #2 still runs.
        let code = run(
            &store,
            Action::Archive,
            &["1".into(), "1".into(), "2".into()],
        )
        .unwrap();
        assert_ne!(code, ExitCode::Success);
        assert!(store.issues_dir.join("alice/.2026-01-01-a.md").is_file());
        assert!(store.issues_dir.join("alice/.2026-01-01-b.md").is_file());
    }

    #[test]
    fn all_success_returns_success() {
        let (_tmp, store) = make_store();
        write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
        let code = run(&store, Action::Archive, &["1".into()]).unwrap();
        assert_eq!(code, ExitCode::Success);
        let code = run(&store, Action::Restore, &["1".into()]).unwrap();
        assert_eq!(code, ExitCode::Success);
        assert!(store.issues_dir.join("alice/2026-01-01-a.md").is_file());
    }
}