tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix ready` and the default `tix list` scope (tix #1711), driven through the
//! compiled binary.
//!
//! Everything here is about **which** issues a listing selects and in what order,
//! which is the part a reader of the source cannot check by inspection: the
//! selection runs through the workflow service (categories), the relationship
//! service (open blockers) and the git identity, and only an end-to-end run proves
//! the three are wired together.

use assert_cmd::Command;
use predicates::prelude::*;
use std::path::Path;
use tempfile::TempDir;

fn tix() -> Command {
    Command::cargo_bin("tix").unwrap()
}

/// A fresh `tix init` repository. Its board starts with the `triage` inbox and a
/// `todo` column, and `tix create` files into the board's first column.
fn repo() -> TempDir {
    let tmp = TempDir::new().unwrap();
    tix().current_dir(tmp.path()).arg("init").assert().success();
    tmp
}

fn create(dir: &Path, title: &str, extra: &[&str]) -> String {
    let out = tix()
        .current_dir(dir)
        .args(["create", "--title", title])
        .args(extra)
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    String::from_utf8(out).unwrap()
}

fn stdout(dir: &Path, args: &[&str]) -> String {
    let out = tix().current_dir(dir).args(args).output().unwrap();
    assert!(
        out.status.success(),
        "{args:?} failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8(out.stdout).unwrap()
}

/// The issue numbers of a table's rows, in the order printed. The header line is
/// dropped; a row's first cell is the bare number.
fn row_numbers(text: &str) -> Vec<u32> {
    text.lines()
        .skip(1)
        .filter_map(|line| line.split_whitespace().next())
        .filter_map(|first| first.parse().ok())
        .collect()
}

/// `tix ready` answers "what do I start next": highest priority first, oldest
/// first among equals, and neither blocked work nor work already under way.
#[test]
fn ready_orders_startable_work_and_leaves_out_blocked_and_started_issues() {
    let tmp = repo();
    let dir = tmp.path();
    // Created in `triage` (the board's first column), which is `unstarted`.
    create(dir, "Alpha", &["--priority", "high"]);
    create(dir, "Bravo", &["--priority", "high"]);
    create(dir, "Charlie", &["--priority", "medium"]);
    // Already under way → `active`, so not startable.
    create(
        dir,
        "Delta",
        &["--priority", "high", "--status", "in progress"],
    );
    // Nothing blocks it, but #1 does.
    create(dir, "Echo", &["--priority", "high"]);
    tix()
        .current_dir(dir)
        .args(["link", "1", "5", "--type", "blocks"])
        .assert()
        .success();

    let ready = stdout(dir, &["ready"]);
    assert_eq!(
        row_numbers(&ready),
        vec![1, 2, 3],
        "high (#1, #2) before medium (#3), oldest first; #4 is under way and #5 is \
         blocked:\n{ready}"
    );
    assert_eq!(
        stdout(dir, &["ready", "--limit", "1"]).lines().count(),
        2,
        "the limit is applied after the ordering, so `--limit 1` is one row"
    );
    assert_eq!(
        row_numbers(&stdout(dir, &["ready", "--limit", "1"])),
        vec![1]
    );

    // The columns are the identity pair, the priority the list is ordered by, and
    // the title.
    let header = ready.lines().next().unwrap();
    assert_eq!(
        header.split_whitespace().collect::<Vec<_>>(),
        ["NUM", "ID", "PRIORITY", "TITLE"]
    );
}

/// "Blocked" means an **open** blocker: the same readiness the `blocked` column
/// reports, so finishing the blocker puts the issue back on the list.
#[test]
fn ready_returns_an_issue_once_its_blocker_is_complete() {
    let tmp = repo();
    let dir = tmp.path();
    create(dir, "Blocker", &[]);
    create(dir, "Blocked", &[]);
    tix()
        .current_dir(dir)
        .args(["link", "1", "2", "--type", "blocks"])
        .assert()
        .success();
    assert_eq!(row_numbers(&stdout(dir, &["ready"])), vec![1]);

    tix()
        .current_dir(dir)
        .args(["move", "1", "done", "--commit", "abc1234"])
        .assert()
        .success();
    assert_eq!(
        row_numbers(&stdout(dir, &["ready"])),
        vec![2],
        "a complete blocker no longer holds anything up"
    );
}

/// The default listing is scoped to **your** issues in the board's entry column —
/// the first column that is not the triage inbox, which is `todo`. `--all` is the
/// escape hatch, and an explicit status replaces the scope's.
#[test]
fn the_default_listing_is_scoped_and_all_restores_the_board() {
    let tmp = repo();
    let dir = tmp.path();
    create(dir, "Accepted work", &["--status", "todo"]);
    create(dir, "Work under way", &["--status", "in progress"]);
    create(dir, "Awaiting triage", &[]);

    let default = stdout(dir, &["list"]);
    assert_eq!(
        row_numbers(&default),
        vec![1],
        "only the entry column:\n{default}"
    );

    let all = stdout(dir, &["list", "--all"]);
    assert_eq!(row_numbers(&all), vec![1, 2, 3], "--all is the whole board");

    let started = stdout(dir, &["list", "--status", "in progress"]);
    assert_eq!(
        row_numbers(&started),
        vec![2],
        "an explicit --status replaces the scope's, it does not compose with it"
    );

    // The inbox is reachable by naming it, and the identity pair is what a row
    // carries: the number, then the canonical 8-character id.
    let inbox = stdout(dir, &["list", "--status", "triage", "--columns", "num,id"]);
    let id = inbox
        .lines()
        .nth(1)
        .and_then(|line| line.split_whitespace().nth(1))
        .unwrap_or_default()
        .to_string();
    assert_eq!(id.len(), 8, "the canonical id, not the number: {inbox}");
    assert!(id.chars().all(|c| c.is_ascii_alphanumeric()), "{inbox}");
}

/// `--format` writes the **displayed columns** as records under one root, in the
/// three formats that are offered — and refuses the fourth.
#[test]
fn the_structured_formats_carry_the_displayed_columns() {
    let tmp = repo();
    let dir = tmp.path();
    create(
        dir,
        "Formatted",
        &["--priority", "high", "--status", "todo"],
    );

    let toml = stdout(dir, &["ready", "--format", "toml"]);
    assert!(toml.contains("[[issues]]"), "{toml}");
    assert!(toml.contains(r#"title = "Formatted""#), "{toml}");
    assert!(toml.contains(r#"priority = "high""#), "{toml}");

    let yaml = stdout(dir, &["ready", "--format", "yml"]);
    assert!(yaml.starts_with("issues:\n"), "{yaml}");
    assert!(yaml.contains("  title: Formatted"), "{yaml}");

    let xml = stdout(dir, &["ready", "--format", "xml"]);
    assert!(
        xml.contains("<issues>") && xml.contains("</issues>"),
        "{xml}"
    );
    assert!(xml.contains("<title>Formatted</title>"), "{xml}");

    // `--columns` decides the fields in every format.
    let chosen = stdout(
        dir,
        &["ready", "--columns", "num,status", "--format", "toml"],
    );
    assert!(chosen.contains("num = "), "{chosen}");
    assert!(chosen.contains("status = "), "{chosen}");
    assert!(!chosen.contains("title = "), "{chosen}");

    // JSON is deliberately not one of the formats.
    tix()
        .current_dir(dir)
        .args(["ready", "--format", "json"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("unknown list format `json`"));
}

/// A title that does not fit is the *unit* test's job (a piped stdout has no
/// terminal width); this proves the width is not invented when there is none: a
/// piped listing prints the whole title rather than a cropped one.
#[test]
fn a_piped_listing_is_not_cropped() {
    let tmp = repo();
    let dir = tmp.path();
    let title = "A title that is far longer than any reasonable terminal width would \
                 ever allow in one line of a table";
    create(dir, title, &["--status", "todo"]);
    let listed = stdout(dir, &["list"]);
    assert!(
        listed.contains(title),
        "no terminal, no crop — the width is the table's own:\n{listed}"
    );
}