tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;

#[test]
fn refresh_from_disk_picks_up_new_file() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    assert_eq!(app.board.column_cards(0).len(), 0);
    let mut watcher = crate::watcher::FsWatcher::new(&dir);
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    let delta = watcher.poll().expect("the new file is reported");
    app.refresh_from_disk(&delta).unwrap();
    assert_eq!(app.board.column_cards(0).len(), 1);
}

#[test]
fn refresh_from_disk_reflects_external_status_move() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    let mut watcher = crate::watcher::FsWatcher::new(&dir);
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    let delta = watcher.poll().unwrap();
    app.refresh_from_disk(&delta).unwrap();
    assert_eq!(app.board.column_cards(0).len(), 1);

    write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
    let delta = watcher.poll().expect("the rewrite is reported");
    app.refresh_from_disk(&delta).unwrap();
    assert_eq!(app.board.column_cards(0).len(), 0);
    assert_eq!(app.board.column_cards(3).len(), 1);
}

#[test]
fn refresh_from_disk_keeps_board_on_bad_file() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    let mut watcher = crate::watcher::FsWatcher::new(&dir);
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    let delta = watcher.poll().unwrap();
    app.refresh_from_disk(&delta).unwrap();
    assert_eq!(app.board.column_cards(0).len(), 1);

    fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
    let delta = watcher.poll().expect("the malformed file is reported");
    assert!(app.refresh_from_disk(&delta).is_err());
    assert_eq!(
        app.board.column_cards(0).len(),
        1,
        "a malformed file must not wipe the board"
    );
}

/// An external edit of one issue must not re-read the whole tree: after a
/// delta, the corpus is exactly a rebuild over the files on disk.
#[test]
fn incremental_refresh_matches_a_full_corpus_rebuild() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    fs::create_dir_all(dir.join("alice")).unwrap();
    write_issue(&dir, "alice/2026-01-01-a.md", 1, "A", "todo", None);
    write_issue(&dir, "2026-01-02-b.md", 2, "B", "todo", None);
    app.refresh_corpus().unwrap();
    let mut watcher = crate::watcher::FsWatcher::new(&dir);

    write_issue(&dir, "alice/2026-01-01-a.md", 1, "A changed", "done", None);
    write_issue(&dir, "2026-01-03-c.md", 3, "C", "in progress", None);
    fs::remove_file(dir.join("2026-01-02-b.md")).unwrap();
    let delta = watcher.poll().expect("the burst is reported");
    app.refresh_from_disk(&delta).unwrap();

    let rebuilt = crate::search::Corpus::build(&app.store).unwrap();
    let incremental: Vec<(u32, String)> = app
        .corpus
        .issues
        .iter()
        .map(|i| (i.number, i.title.clone()))
        .collect();
    let expected: Vec<(u32, String)> = rebuilt
        .issues
        .iter()
        .map(|i| (i.number, i.title.clone()))
        .collect();
    assert_eq!(incremental, expected);
    assert_eq!(app.corpus.texts(), rebuilt.texts());
}

/// A write made through the store is applied from its journal, not by scanning
/// the tree again. An unrelated malformed file is invisible here because the
/// incremental path never opens it; a full rebuild would fail on it.
#[test]
fn an_internal_write_refreshes_from_the_write_journal() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    write_issue(&dir, "2026-01-02-b.md", 2, "B", "todo", None);
    app.refresh_corpus().unwrap();

    let mut issue = app
        .store
        .get(IssueSelector::Filename("2026-01-01-a.md".to_string()))
        .unwrap();
    issue.title = "A changed in process".to_string();
    app.store.update(&issue.filename, &issue).unwrap();

    fs::write(dir.join("2026-01-03-unreadable.md"), "not frontmatter").unwrap();
    app.refresh_corpus()
        .expect("the journal applies only the file the store wrote");
    assert!(
        app.corpus
            .issues
            .iter()
            .any(|issue| issue.title == "A changed in process"),
        "the store's write is visible"
    );

    fs::remove_file(dir.join("2026-01-03-unreadable.md")).unwrap();
    app.refresh_corpus().unwrap();
    let rebuilt = crate::search::Corpus::build(&app.store).unwrap();
    let incremental: Vec<(u32, String)> = app
        .corpus
        .issues
        .iter()
        .map(|issue| (issue.number, issue.title.clone()))
        .collect();
    let expected: Vec<(u32, String)> = rebuilt
        .issues
        .iter()
        .map(|issue| (issue.number, issue.title.clone()))
        .collect();
    assert_eq!(incremental, expected);
}

/// With the archived toggle off, an external archive leaves the active
/// board instead of adding the archived file to it.
#[test]
fn external_archive_leaves_the_active_board() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    fs::create_dir_all(dir.join("alice")).unwrap();
    write_issue(&dir, "alice/2026-01-01-a.md", 1, "A", "todo", None);
    app.refresh_corpus().unwrap();
    assert_eq!(app.board.column_cards(0).len(), 1);
    let mut watcher = crate::watcher::FsWatcher::new(&dir);

    // The archived marker is the leading dot of the file name (#1492).
    fs::rename(
        dir.join("alice/2026-01-01-a.md"),
        dir.join("alice/.2026-01-01-a.md"),
    )
    .unwrap();
    let delta = watcher.poll().expect("the rename is reported");
    app.refresh_from_disk(&delta).unwrap();
    assert_eq!(
        app.board.column_cards(0).len(),
        0,
        "an issue archived outside `tix` leaves the active board"
    );
    assert!(
        app.corpus.issues.is_empty(),
        "the active corpus does not gain the archived file"
    );
}

/// A burst of writes is applied through the coalescer as **one** rebuild.
#[test]
fn a_coalesced_burst_rebuilds_once() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    let mut watcher = crate::watcher::FsWatcher::new(&dir);
    let mut coalescer = crate::watcher::DeltaCoalescer::default();

    let mut applies = 0;
    // Three writes, a poll after each, then a quiet poll: each distinct
    // file is a separate delta, and the first quiet tick coalesces them.
    for (i, name) in ["a.md", "b.md", "c.md"].iter().enumerate() {
        write_issue(&dir, name, i as u32 + 1, "T", "todo", None);
        assert!(
            coalescer.observe(watcher.poll()).is_none(),
            "a pending delta is not applied until the tree settles"
        );
    }
    if let Some(delta) = coalescer.observe(None) {
        applies += 1;
        app.refresh_from_disk(&delta).unwrap();
    }
    assert_eq!(applies, 1, "a burst costs exactly one rebuild");
    assert_eq!(app.board.column_cards(0).len(), 3);
    assert!(coalescer.observe(None).is_none(), "nothing is left pending");
}

#[test]
fn refresh_rebuilds_board() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
    assert_eq!(app.board.column_cards(0).len(), 1);
    assert_eq!(app.status_msg, Some("Refreshed".to_string()));
}