tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use std::fs;

use tempfile::TempDir;
use tix::cli::MigrateAction;
use tix::commands::migrate;
use tix::config::Config;
use tix::id::IssueRef;
use tix::storage::{IssueSelector, Store};

fn project() -> (TempDir, Store) {
    let tmp = TempDir::new().unwrap();
    let issues = tmp.path().join("issues");
    fs::create_dir_all(&issues).unwrap();
    let mut config = Config {
        issues_dir: issues,
        ..Config::default()
    };
    config.layout.move_on_change = false;
    config.save(&tmp.path().join("tix.toml")).unwrap();
    let loaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
    let store = Store::open(&loaded).unwrap();
    (tmp, store)
}

fn write(store: &Store, name: &str, number: u32, id: Option<&str>, extra: &str) {
    let id = id.map_or_else(String::new, |id| format!("id: {id}\n"));
    let path = store.issues_dir.join(name);
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(
        path,
        format!(
            "---\nnumber: {number}\n{id}title: \"Issue {number}\"\nstatus: \"todo\"\n\
             labels: []\npriority: \"medium\"\nfiles: []\n{extra}\
             created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody {number}.\n\
             \n## History\n\n- 2026-01-01T00:00:00Z — tester — created — made\n"
        ),
    )
    .unwrap();
}

fn snapshot(store: &Store) -> Vec<(String, Vec<u8>)> {
    let mut out = Vec::new();
    for issue in store.list_all().unwrap() {
        let bytes = fs::read(store.issues_dir.join(&issue.filename)).unwrap();
        out.push((issue.filename, bytes));
    }
    out.sort();
    out
}

#[test]
fn graph_migration_preview_apply_and_second_apply_are_idempotent() {
    let (_tmp, store) = project();
    write(&store, "parent.md", 1, Some("A1B2C3D4"), "");
    write(
        &store,
        "child.md",
        2,
        None,
        "custom_metadata: keep-me\nparent: 1\nrelationships:\n- id: rel-one\n  from: 2\n  to: 1\n  \
         kind: blocks\n  display: blocks #1\n",
    );

    let before = snapshot(&store);
    migrate::run(&store, &MigrateAction::IssueGraph { apply: false }).unwrap();
    assert_eq!(snapshot(&store), before, "preview writes nothing");

    migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
    let issues = store.list_all().unwrap();
    let parent = issues.iter().find(|issue| issue.number == 1).unwrap();
    let child = issues.iter().find(|issue| issue.number == 2).unwrap();
    let parent_id = parent.id.as_deref().unwrap();
    let child_id = child.id.as_deref().unwrap();
    assert_eq!(child.parent, IssueRef::id(parent_id));
    assert_eq!(child.relationships[0].from, IssueRef::id(child_id).unwrap());
    assert_eq!(child.relationships[0].to, IssueRef::id(parent_id).unwrap());
    let text = fs::read_to_string(store.issues_dir.join(&child.filename)).unwrap();
    assert!(text.contains("custom_metadata: keep-me"), "{text}");
    assert!(text.contains("Body 2."), "{text}");

    let after = snapshot(&store);
    migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
    assert_eq!(snapshot(&store), after, "a second apply is a no-op");
}

#[test]
fn graph_migration_refuses_ambiguous_and_unresolved_aliases_without_writing() {
    let (_tmp, store) = project();
    write(
        &store,
        "a.md",
        7,
        Some("A1B2C3D4"),
        "parent: 7\nrelationships:\n- id: rel-a\n  from: 7\n  to: 99\n  \
         kind: related\n  display: related\n",
    );
    write(&store, "b.md", 7, Some("B1B2C3D4"), "");
    let before = snapshot(&store);

    let error = migrate::run(&store, &MigrateAction::IssueGraph { apply: true })
        .expect_err("ambiguous and unresolved aliases refuse the migration");
    assert!(error.to_string().contains("refused"), "{error}");
    assert_eq!(
        snapshot(&store),
        before,
        "a refused migration writes nothing"
    );
}

#[test]
fn graph_migration_assigns_a_distinct_id_when_none_exists() {
    let (_tmp, store) = project();
    write(&store, "one.md", 1, None, "");
    migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
    let issue = store.get(IssueSelector::Number(1)).unwrap();
    let id = issue.id.expect("an id was backfilled");
    assert!(tix::id::is_issue_id(&id), "{id}");
    assert!(
        fs::read_to_string(store.issues_dir.join(&issue.filename))
            .unwrap()
            .contains(&format!("id: {id}"))
    );
}

#[cfg(unix)]
#[test]
fn graph_migration_rolls_back_earlier_writes_when_a_later_write_fails() {
    use std::os::unix::fs::PermissionsExt;

    struct Restore(std::path::PathBuf);
    impl Drop for Restore {
        fn drop(&mut self) {
            let mut permissions = fs::metadata(&self.0).unwrap().permissions();
            permissions.set_mode(0o755);
            let _ = fs::set_permissions(&self.0, permissions);
        }
    }

    let (_tmp, store) = project();
    write(&store, "a/one.md", 1, None, "");
    write(&store, "b/two.md", 2, None, "");
    let before = snapshot(&store);

    let blocked = store.issues_dir.join("b");
    let mut permissions = fs::metadata(&blocked).unwrap().permissions();
    permissions.set_mode(0o555);
    fs::set_permissions(&blocked, permissions).unwrap();
    let _restore = Restore(blocked);

    let error = migrate::run(&store, &MigrateAction::IssueGraph { apply: true })
        .expect_err("the later generated-id write must fail");
    assert!(
        error
            .to_string()
            .to_ascii_lowercase()
            .contains("permission denied"),
        "{error}"
    );
    assert_eq!(
        snapshot(&store),
        before,
        "the first file's generated id must be rolled back"
    );
}