use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use tempfile::TempDir;
const BOARD: &str = r#"issues_dir = "ISSUES_DIR"
[kanban.default]
columns = ["triage", "todo", "in progress", "in review", "done", "wontfix"]
done_column = "done"
category_map = { triage = "backlog", todo = "unstarted", "in progress" = "active", "in review" = "active", done = "completed", wontfix = "canceled" }
"#;
const TRIAGE: &str = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n";
fn tix() -> Command {
let mut command = Command::cargo_bin("tix").expect("the tix binary must build");
command
.env("NO_COLOR", "1")
.env("TERM", "dumb")
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.invalid")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.invalid");
command
}
fn repo(section: &str) -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config = BOARD.replace("ISSUES_DIR", &dir.display().to_string());
fs::write(
tmp.path().join("tix.toml"),
format!("{config}{section}\n[layout]\nmove_on_change = false\n"),
)
.unwrap();
(tmp, dir)
}
fn write_issue(
dir: &Path,
file: &str,
number: u32,
title: &str,
status: &str,
entered_at: Option<&str>,
) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody text.");
fs::write(dir.join(file), text).unwrap();
}
fn inbox_repo() -> (TempDir, PathBuf) {
let (tmp, dir) = repo(TRIAGE);
write_issue(
&dir,
"2026-01-01-alpha.md",
1,
"Alpha waits",
"triage",
Some("2020-01-01T00:00:00Z"),
);
write_issue(
&dir,
"2026-01-01-bravo.md",
2,
"Bravo legacy",
"triage",
None,
);
write_issue(
&dir,
"2026-01-01-charlie.md",
3,
"Charlie recent",
"triage",
Some("2026-09-30T00:00:00Z"),
);
write_issue(
&dir,
"2026-01-01-delta.md",
4,
"Delta outside",
"in review",
Some("2020-01-01T00:00:00Z"),
);
(tmp, dir)
}
fn run(dir: &Path, args: &[&str]) -> std::process::Output {
tix()
.current_dir(dir)
.args(args)
.output()
.expect("the tix binary runs")
}
fn ok(dir: &Path, args: &[&str]) -> String {
let out = run(dir, args);
assert!(
out.status.success(),
"tix {args:?} exited {:?}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn code(dir: &Path, args: &[&str], want: i32) -> (String, String) {
let out = run(dir, args);
assert_eq!(
out.status.code(),
Some(want),
"tix {args:?} exited {:?}, wanted {want}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.insert(path.clone(), fs::read(&path).unwrap());
}
}
}
out
}
fn only_issue(dir: &Path) -> String {
let mut found: Vec<PathBuf> = Vec::new();
fn walk(cur: &Path, found: &mut Vec<PathBuf>) {
for entry in fs::read_dir(cur).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, found);
} else if path.extension().is_some_and(|ext| ext == "md") {
found.push(path);
}
}
}
walk(dir, &mut found);
assert_eq!(
found.len(),
1,
"expected exactly one issue, found {found:?}"
);
fs::read_to_string(&found[0]).unwrap()
}
fn history_events(text: &str) -> Vec<&str> {
text.lines()
.filter(|line| line.starts_with("- ") && line.contains(" — "))
.collect()
}
fn row<'a>(stdout: &'a str, num: &str) -> &'a str {
stdout
.lines()
.find(|line| line.split_whitespace().next() == Some(num))
.unwrap_or_else(|| panic!("no row starting with `{num}` in:\n{stdout}"))
}
#[test]
fn triage_cli_list_prints_one_row_per_entry_and_its_age() {
let (tmp, dir) = inbox_repo();
let stdout = ok(tmp.path(), &["triage", "list"]);
assert!(
stdout.contains("inbox: 3 entries (3 awaiting, 3 deferred)"),
"the header counts the entries, and only the inbox:\n{stdout}"
);
assert!(stdout.contains("NUM AGE TITLE"), "header:\n{stdout}");
let alpha = row(&stdout, "1");
assert!(alpha.contains("d "), "a stamped age is days: {alpha}");
assert!(alpha.contains("Alpha waits"), "{alpha}");
let bravo = row(&stdout, "2");
assert!(
bravo.contains("unknown"),
"a missing stamp is `unknown`, never 0 and never `created`:\n{bravo}"
);
assert!(
!stdout.contains("Delta outside"),
"`in review` is not an inbox column, so it is not a row:\n{stdout}"
);
let _ = dir;
}
#[test]
fn triage_cli_list_count_prints_the_summary_and_no_rows() {
let (tmp, _dir) = inbox_repo();
let stdout = ok(tmp.path(), &["triage", "list", "--count"]);
assert!(
stdout.contains("inbox: 3 entries (3 awaiting, 3 deferred)"),
"{stdout}"
);
assert!(!stdout.contains("Alpha waits"), "no rows:\n{stdout}");
assert!(!stdout.contains("NUM"), "and no table header:\n{stdout}");
}
#[test]
fn triage_cli_list_age_orders_oldest_first_and_leaves_unknown_last() {
let (tmp, _dir) = inbox_repo();
let natural = ok(tmp.path(), &["triage", "list"]);
assert!(
natural.find("Alpha waits").unwrap() < natural.find("Bravo legacy").unwrap(),
"snapshot order, not an age order:\n{natural}"
);
let by_age = ok(tmp.path(), &["triage", "list", "--age"]);
let alpha = by_age.find("Alpha waits").expect("alpha is listed");
let charlie = by_age.find("Charlie recent").expect("charlie is listed");
let bravo = by_age.find("Bravo legacy").expect("bravo is listed");
assert!(
alpha < charlie && charlie < bravo,
"oldest stamped first, newest stamped second, the unstamped issue last \
rather than sorted as if it had just arrived:\n{by_age}"
);
}
#[test]
fn triage_cli_status_names_every_decision_count_and_the_histogram() {
let (tmp, dir) = repo(&format!("{TRIAGE}deferred_status = \"wontfix\"\n"));
write_issue(&dir, "2026-01-01-one.md", 1, "One", "triage", None);
write_issue(&dir, "2026-01-01-two.md", 2, "Two", "triage", None);
write_issue(&dir, "2026-01-01-three.md", 3, "Three", "triage", None);
write_issue(&dir, "2026-01-01-four.md", 4, "Four", "in review", None);
let out = run(
tmp.path(),
&["triage", "decline", "1", "--reason", "not actionable"],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let out = run(
tmp.path(),
&[
"triage",
"duplicate",
"2",
"--of",
"3",
"--reason",
"same bug",
],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = ok(tmp.path(), &["triage", "status"]);
for expected in [
"inbox: 3 entries (2 awaiting, 1 deferred)",
" awaiting: 2",
" deferred: 1",
" accepted: 0",
" declined: 1",
" duplicated: 1",
" unknown age: 3",
" age: today 0, 1-2 0, 3-6 0, 7-13 0, 14-29 0, 30+ 0, unknown 3",
] {
assert!(
stdout.contains(expected),
"missing `{expected}` in:\n{stdout}"
);
}
}
#[test]
fn triage_cli_read_only_views_write_nothing() {
let (tmp, _dir) = inbox_repo();
let before = snapshot(tmp.path());
for args in [
vec!["triage", "list"],
vec!["triage", "list", "--age"],
vec!["triage", "list", "--count"],
vec!["triage", "status"],
] {
let out = run(tmp.path(), &args);
assert!(
out.status.success(),
"tix {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
assert_eq!(
before,
snapshot(tmp.path()),
"a queue view is read-only: no file, no History, no timestamp changed"
);
}
#[test]
fn triage_cli_without_a_triage_section_every_view_and_decision_refuses_the_same_way() {
let (tmp, _dir) = repo("");
let args: [&[&str]; 6] = [
&["triage", "list"],
&["triage", "status"],
&["triage", "accept", "1"],
&["triage", "decline", "1", "--reason", "x"],
&["triage", "duplicate", "1", "--of", "2", "--reason", "x"],
&["triage", "defer", "1", "--reason", "x"],
];
for args in args {
let (stdout, stderr) = code(tmp.path(), args, 1);
assert!(
stderr.contains("`tix triage` needs a `[triage]` section in tix.toml"),
"tix {args:?} must name the missing section, not some later failure: {stderr}"
);
assert!(
stdout.is_empty(),
"and it wrote nothing to stdout: {stdout}"
);
}
}
fn decision_repo() -> (TempDir, PathBuf) {
let (tmp, dir) = repo(TRIAGE);
for (number, slug) in [(1, "alpha"), (2, "bravo"), (3, "charlie"), (4, "delta")] {
write_issue(
&dir,
&format!("2026-01-01-{slug}.md"),
number,
&format!("Issue {slug}"),
"triage",
None,
);
}
(tmp, dir)
}
#[test]
fn triage_cli_each_decision_applies_and_records_exactly_one_event() {
let (tmp, dir) = decision_repo();
let stdout = ok(
tmp.path(),
&[
"triage",
"accept",
"1",
"--to",
"todo",
"--reason",
"shipped soon",
],
);
assert!(stdout.contains("#1 Issue alpha -> applied"), "{stdout}");
assert!(stdout.contains("history: triage_accepted"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-alpha.md")).unwrap();
assert!(text.contains("status: todo"), "accept moved it:\n{text}");
assert_eq!(history_events(&text).len(), 1, "exactly one event:\n{text}");
let stdout = ok(
tmp.path(),
&["triage", "decline", "2", "--reason", "not actionable"],
);
assert!(stdout.contains("history: triage_declined"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-bravo.md")).unwrap();
assert!(text.contains("status: wontfix"), "decline:\n{text}");
assert!(
text.contains("reason: not actionable"),
"the reason is in History:\n{text}"
);
assert_eq!(history_events(&text).len(), 1, "{text}");
assert!(dir.join("2026-01-01-bravo.md").is_file(), "never deleted");
let stdout = ok(
tmp.path(),
&["triage", "defer", "3", "--reason", "waiting on reporter"],
);
assert!(stdout.contains("history: triage_deferred"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-charlie.md")).unwrap();
assert!(
text.contains("status: triage"),
"defer does not move it:\n{text}"
);
assert!(text.contains("source: defer"), "and it re-enters:\n{text}");
assert_eq!(history_events(&text).len(), 1, "{text}");
let target_before = fs::read(dir.join("2026-01-01-alpha.md")).unwrap();
let source_before = fs::read(dir.join("2026-01-01-delta.md")).unwrap();
let stdout = ok(
tmp.path(),
&[
"triage",
"duplicate",
"4",
"--of",
"1",
"--reason",
"same bug",
],
);
assert!(stdout.contains("history: triage_duplicated"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-delta.md")).unwrap();
assert!(text.contains("kind: duplicates"), "the edge:\n{text}");
assert!(
text.contains("status: triage"),
"duplicate moves nothing:\n{text}"
);
assert_eq!(history_events(&text).len(), 1, "{text}");
assert_ne!(
source_before,
fs::read(dir.join("2026-01-01-delta.md")).unwrap(),
"the source gained the edge and the event"
);
assert_eq!(
target_before,
fs::read(dir.join("2026-01-01-alpha.md")).unwrap(),
"and the duplicate's *target* was only read — #190 owns merging, so a \
duplicate decision writes exactly one file"
);
}
#[test]
fn triage_cli_missing_reason_is_refused_before_anything_is_written() {
let (tmp, dir) = decision_repo();
for args in [
vec!["triage", "decline", "1"],
vec!["triage", "defer", "2"],
vec!["triage", "duplicate", "3", "--of", "4"],
] {
let before = snapshot(&dir);
let (stdout, _stderr) = code(tmp.path(), &args, 1);
assert!(
stdout.contains("refused: missing_reason") && stdout.contains("requires a reason"),
"tix {args:?} must name the contract's kind, in the per-item shape the \
batch uses (tix #1488): {stdout}"
);
assert_eq!(
before,
snapshot(&dir),
"tix {args:?} refused before any write"
);
}
}
#[test]
fn triage_cli_missing_actor_is_refused_and_nothing_is_written() {
let (tmp, dir) = decision_repo();
let before = snapshot(&dir);
let (stdout, _stderr) = code(
tmp.path(),
&["triage", "decline", "1", "--reason", "x", "--actor", ""],
1,
);
assert!(
stdout.contains("refused: missing_actor") && stdout.contains("never recorded anonymously"),
"{stdout}"
);
assert_eq!(before, snapshot(&dir), "refused before any write");
}
#[test]
fn triage_cli_an_issue_already_decided_is_refused_by_its_own_kind() {
let (tmp, dir) = decision_repo();
ok(tmp.path(), &["move", "1", "in progress"]);
let before = snapshot(&dir);
let (stdout, _stderr) = code(tmp.path(), &["triage", "decline", "1", "--reason", "x"], 1);
assert!(
stdout.contains("refused: not_in_triage"),
"a never-queued issue is `not_in_triage`, not `already_decided`: {stdout}"
);
assert_eq!(before, snapshot(&dir));
let (tmp, dir) = decision_repo();
ok(
tmp.path(),
&["triage", "decline", "1", "--reason", "not actionable"],
);
let before = snapshot(&dir);
let (stdout, _stderr) = code(
tmp.path(),
&["triage", "defer", "1", "--reason", "one more look"],
1,
);
assert!(
stdout.contains("refused: already_decided") && stdout.contains("normal status command"),
"{stdout}"
);
assert_eq!(before, snapshot(&dir));
}
#[test]
fn triage_cli_accept_target_is_validated_by_the_transition_policy() {
let (tmp, dir) = decision_repo();
let before = snapshot(&dir);
let (stdout, _stderr) = code(tmp.path(), &["triage", "accept", "1", "--to", "nope"], 1);
assert!(
stdout.contains("refused: unknown_column"),
"the single-decision refusal reports the batch's per-item shape (tix #1488): {stdout}"
);
assert!(
stdout.contains("is not a column on board `default`"),
"and the board service names the real columns: {stdout}"
);
assert_eq!(before, snapshot(&dir), "nothing moved");
let stdout = ok(tmp.path(), &["triage", "accept", "1", "--to", "todo"]);
assert!(stdout.contains("#1 Issue alpha -> applied"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-alpha.md")).unwrap();
assert!(text.contains("status: todo"), "{text}");
}
#[test]
fn triage_cli_repeating_a_decision_is_a_noop_that_appends_nothing() {
let (tmp, dir) = decision_repo();
ok(
tmp.path(),
&["triage", "defer", "1", "--reason", "waiting on reporter"],
);
let after_first = snapshot(&dir);
let stdout = ok(
tmp.path(),
&["triage", "defer", "1", "--reason", "waiting on reporter"],
);
assert!(
stdout.contains("already in that state"),
"an identical repeat is a no-op, not a second event:\n{stdout}"
);
assert!(
!stdout.contains("history:"),
"a no-op appended nothing, so naming an event would be a lie:\n{stdout}"
);
assert_eq!(
after_first,
snapshot(&dir),
"the repeat wrote not one byte (NFR-002's other half)"
);
}
fn batch_repo() -> (TempDir, PathBuf) {
let (tmp, dir) = repo(TRIAGE);
for number in 1..=5 {
write_issue(
&dir,
&format!("2026-01-01-issue{number}.md"),
number,
&format!("Issue{number}"),
if number == 5 { "in review" } else { "triage" },
None,
);
}
(tmp, dir)
}
#[test]
fn triage_cli_batch_previews_by_default_and_writes_nothing() {
let (tmp, dir) = batch_repo();
let before = snapshot(&dir);
let stdout = ok(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1,2",
"--decision",
"decline",
"--reason",
"stale",
],
);
assert!(
stdout.contains("selection: 2 issues (selector, active scope)"),
"{stdout}"
);
assert!(stdout.contains("decision: decline"), "{stdout}");
assert!(
stdout.contains("2 of 2 would change"),
"a preview speaks in the conditional:\n{stdout}"
);
assert!(
!stdout.contains("-> applied"),
"and never claims it applied anything:\n{stdout}"
);
assert_eq!(
before,
snapshot(&dir),
"a preview is read-only by construction, not by promise"
);
}
#[test]
fn triage_cli_batch_apply_changes_every_selected_issue() {
let (tmp, dir) = batch_repo();
let before = snapshot(&dir);
let stdout = ok(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1,2,3,4",
"--decision",
"decline",
"--reason",
"stale",
"--apply",
],
);
assert_ne!(before, snapshot(&dir), "--apply must actually write");
assert!(
stdout.contains("4 of 4 applied; 0 already in that state; 0 refused"),
"{stdout}"
);
for number in 1..=4 {
let text = fs::read_to_string(dir.join(format!("2026-01-01-issue{number}.md"))).unwrap();
assert!(text.contains("status: wontfix"), "issue {number}:\n{text}");
assert!(
text.contains("triage_declined"),
"one event per applied decision (NFR-002):\n{text}"
);
assert_eq!(history_events(&text).len(), 1, "{text}");
}
assert_eq!(
stdout.matches("status: \"triage\" -> wontfix").count(),
4,
"and the report says where each of them went:\n{stdout}"
);
}
#[test]
fn triage_cli_batch_partial_apply_keeps_the_applied_items_and_exits_non_zero() {
let (tmp, dir) = batch_repo();
let refused_before = fs::read(dir.join("2026-01-01-issue5.md")).unwrap();
let (stdout, _stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1,5",
"--decision",
"decline",
"--reason",
"stale",
"--apply",
],
1,
);
assert!(
stdout.contains("#1 Issue1 -> applied"),
"the item that could be applied still was:\n{stdout}"
);
assert!(
stdout.contains("#5 Issue5 -> refused: not_in_triage"),
"the refused item is named, with its kind:\n{stdout}"
);
assert!(
stdout.contains("1 of 2 applied; 0 already in that state; 1 refused"),
"every count, including the zeros:\n{stdout}"
);
let applied = fs::read_to_string(dir.join("2026-01-01-issue1.md")).unwrap();
assert!(applied.contains("status: wontfix"), "{applied}");
let refused = fs::read_to_string(dir.join("2026-01-01-issue5.md")).unwrap();
assert_eq!(
refused_before,
fs::read(dir.join("2026-01-01-issue5.md")).unwrap(),
"a refused item is not touched, let alone moved:\n{refused}"
);
assert!(
!refused.contains("triage_declined"),
"a refused item appends no event:\n{refused}"
);
}
#[test]
fn triage_cli_batch_gives_every_selected_issue_exactly_one_outcome() {
let (tmp, _dir) = batch_repo();
ok(tmp.path(), &["triage", "decline", "2", "--reason", "stale"]);
ok(tmp.path(), &["move", "3", "todo"]);
let (stdout, _stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1,2,3",
"--decision",
"decline",
"--reason",
"stale",
"--apply",
],
1,
);
let outcomes: Vec<&str> = stdout
.lines()
.filter(|line| line.starts_with('#'))
.collect();
assert_eq!(
outcomes.len(),
3,
"every selected issue receives exactly one outcome (C-005):\n{stdout}"
);
assert!(outcomes[0].contains("#1 Issue1 -> applied"), "{stdout}");
assert!(
outcomes[1].contains("#2 Issue2 -> already in that state"),
"{stdout}"
);
assert!(outcomes[2].contains("#3 Issue3 -> refused"), "{stdout}");
assert!(
stdout.contains("1 of 3 applied; 1 already in that state; 1 refused"),
"{stdout}"
);
}
#[test]
fn triage_cli_batch_reapplying_is_a_noop_and_still_exits_zero() {
let (tmp, dir) = batch_repo();
let args = [
"triage",
"batch",
"--select",
"1,2",
"--decision",
"decline",
"--reason",
"stale",
"--apply",
];
ok(tmp.path(), &args);
let after_first = snapshot(&dir);
let (stdout, stderr) = code(tmp.path(), &args, 0);
assert!(
stdout.contains("0 of 2 applied; 2 already in that state; 0 refused"),
"{stdout}"
);
assert!(stderr.is_empty(), "{stderr}");
assert_eq!(
after_first,
snapshot(&dir),
"the re-run appended no second event (spec §4 invariant)"
);
}
#[test]
fn triage_cli_batch_needs_exactly_one_selection_grammar() {
let (tmp, _dir) = batch_repo();
let (_, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1",
"--filter",
"status:triage",
"--decision",
"decline",
"--reason",
"x",
],
1,
);
assert!(
stderr.contains("--select") && stderr.contains("--filter"),
"{stderr}"
);
assert!(stderr.contains("will not guess"), "{stderr}");
let (_, stderr) = code(
tmp.path(),
&["triage", "batch", "--decision", "decline", "--reason", "x"],
1,
);
assert!(
stderr.contains("--select") && stderr.contains("--filter"),
"{stderr}"
);
let (_, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
",",
"--decision",
"decline",
"--reason",
"x",
],
1,
);
assert!(stderr.contains("names no issue"), "{stderr}");
}
#[test]
fn triage_cli_batch_refuses_a_target_flag_the_decision_cannot_use() {
let (tmp, _dir) = batch_repo();
let (_, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1",
"--decision",
"decline",
"--to",
"todo",
"--reason",
"x",
],
1,
);
assert!(
stderr.contains("`--to` names an accept target") && stderr.contains("decline decision"),
"{stderr}"
);
let (_, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1",
"--decision",
"defer",
"--of",
"2",
"--reason",
"x",
],
1,
);
assert!(
stderr.contains("a duplicate target was named") && stderr.contains("a defer"),
"and the shared sentence names the argument by meaning, not by a CLI flag \
the MCP surface does not have (tix #1488): {stderr}"
);
let (_, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1",
"--decision",
"declinee",
"--reason",
"x",
],
1,
);
assert!(
stderr.contains("unknown triage decision `declinee`"),
"{stderr}"
);
}
#[test]
fn triage_cli_batch_duplicate_without_a_target_is_a_per_item_refusal() {
let (tmp, dir) = batch_repo();
let before = snapshot(&dir);
let (stdout, _stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1,2",
"--decision",
"duplicate",
"--reason",
"x",
],
1,
);
assert!(
stdout.contains("refused [decision]: invalid_duplicate_target"),
"{stdout}"
);
assert!(
stdout.contains("0 of 2 would change; 2 refused"),
"{stdout}"
);
assert_eq!(before, snapshot(&dir));
}
#[test]
fn triage_cli_duplicate_keeps_both_files_and_creates_only_a_relation() {
let (tmp, dir) = repo(TRIAGE);
write_issue(&dir, "2026-01-01-alpha.md", 1, "Alpha", "triage", None);
write_issue(&dir, "2026-01-01-bravo.md", 2, "Bravo", "triage", None);
let target_before = fs::read(dir.join("2026-01-01-bravo.md")).unwrap();
let stdout = ok(
tmp.path(),
&[
"triage",
"duplicate",
"1",
"--of",
"2",
"--reason",
"same underlying bug",
],
);
assert!(stdout.contains("#1 Alpha -> applied"), "{stdout}");
assert!(stdout.contains("history: triage_duplicated"), "{stdout}");
assert!(
dir.join("2026-01-01-bravo.md").is_file(),
"the source file is never absorbed"
);
assert!(
dir.join("2026-01-01-alpha.md").is_file(),
"and the duplicate is never deleted"
);
assert_eq!(
target_before,
fs::read(dir.join("2026-01-01-bravo.md")).unwrap(),
"the target file is untouched — no merge, no move, no re-serialisation"
);
let text = fs::read_to_string(dir.join("2026-01-01-alpha.md")).unwrap();
assert!(text.contains("kind: duplicates"), "one typed edge:\n{text}");
assert!(text.contains("from: 1"), "{text}");
assert!(text.contains("to: 2"), "{text}");
assert_eq!(
history_events(&text).len(),
1,
"one event, one edge, and no second file write:\n{text}"
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-bravo.md"))
.unwrap()
.matches("## History")
.count(),
0,
"the target gained no History of its own"
);
}
const ROUTING_BOARD: &str = "columns = [\"todo\", \"triage\", \"in progress\", \"done\", \"wontfix\"]\ndone_column = \"done\"\ncategory_map = { triage = \"backlog\", todo = \"unstarted\", \"in progress\" = \"active\", done = \"completed\", wontfix = \"canceled\" }\n";
const GITLAB_LIST: &str = r#"[{"iid":10,"title":"Remote bug","description":"from remote","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-12T08:00:00Z","assignees":[]}]"#;
#[test]
fn triage_cli_import_help_exposes_the_triage_policy_flag() {
let (tmp, _dir) = repo(TRIAGE);
let stdout = ok(tmp.path(), &["import", "--help"]);
assert!(stdout.contains("--triage-policy"), "{stdout}");
assert!(
stdout.contains("entry|remote"),
"the two legal values are spelled: {stdout}"
);
assert!(
stdout.contains("refuses a project with\n no `[triage]` section")
|| stdout.contains("`[triage]` section"),
"the help says what `entry` does on a project without an inbox:\n{stdout}"
);
assert!(
stdout.contains("reports the mapping") || stdout.contains("report names the mapping"),
"{stdout}"
);
assert!(stdout.contains("--target"), "{stdout}");
}
#[test]
fn triage_cli_import_refuses_an_unknown_triage_policy_before_touching_the_remote() {
let (tmp, _dir) = repo(TRIAGE);
let (stdout, stderr) = code(
tmp.path(),
&["import", "--target", "gitlab", "--triage-policy", "nope"],
1,
);
assert!(
stderr.contains("unknown --triage-policy 'nope' — use entry or remote"),
"{stderr}"
);
assert!(stdout.is_empty(), "{stdout}");
}
fn import_with_policy(dir: &Path, policy: Option<&str>) -> (String, String, bool) {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
let bin_dir = PathBuf::from(manifest).join("tests").join("bin");
let record = dir.join("records.txt");
let mut command = tix();
let mut args = vec![
"import".to_string(),
"--target".to_string(),
"gitlab".to_string(),
];
if let Some(policy) = policy {
args.push("--triage-policy".to_string());
args.push(policy.to_string());
}
let out = command
.current_dir(dir)
.args(&args)
.env(
"PATH",
format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.env("TIX_FAKE_LIST", GITLAB_LIST)
.env("TIX_FAKE_RECORD", &record)
.env_remove("GITLAB_TOKEN")
.env_remove("LINEAR_TOKEN")
.output()
.expect("`tix import` must run");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn import_repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&dir).unwrap();
fs::write(
tmp.path().join("tix.toml"),
format!(
"issues_dir = \"docs/issues\"\n\n[kanban.default]\n{ROUTING_BOARD}\n\
[export.gitlab]\nproject = \"acme/tix\"\ntoken = \"gl-file-token\"\n{TRIAGE}"
),
)
.unwrap();
(tmp, dir)
}
#[test]
fn triage_cli_import_policy_entry_is_routed_through_wp07() {
let (tmp, issues_dir) = import_repo();
let (stdout, stderr, succeeded) = import_with_policy(tmp.path(), Some("entry"));
assert!(succeeded, "stderr: {stderr}\nstdout: {stdout}");
assert!(
stdout.contains("(triage entry: remote `opened` requested `todo`, landed `triage`)"),
"the report names the mapping the flag asked for: {stdout}"
);
let text = only_issue(&issues_dir);
assert!(text.contains("status: triage"), "into the inbox:\n{text}");
assert!(
text.contains("source: import") && text.contains("entered_at:"),
"entry metadata travelled with it:\n{text}"
);
}
#[test]
fn triage_cli_import_policy_remote_opts_out_and_says_so() {
let (tmp, issues_dir) = import_repo();
let (stdout, stderr, succeeded) = import_with_policy(tmp.path(), Some("remote"));
assert!(succeeded, "stderr: {stderr}\nstdout: {stdout}");
assert!(
stdout.contains("(import policy remote:"),
"an explicit opt-out is reported too, never silent: {stdout}"
);
let text = only_issue(&issues_dir);
assert!(
text.contains("status: todo"),
"the column the remote mapping named:\n{text}"
);
assert!(
!text.contains("source: import"),
"nothing entered triage, so no entry stamp claims it did:\n{text}"
);
}
#[test]
fn triage_cli_existing_commands_keep_their_meanings() {
let (tmp, _dir) = inbox_repo();
let stdout = ok(tmp.path(), &["list", "--all"]);
assert!(stdout.contains("Alpha waits"), "{stdout}");
assert!(stdout.contains("Delta outside"), "{stdout}");
let stdout = ok(tmp.path(), &["list", "--filter", "scope:triage"]);
assert!(stdout.contains("Alpha waits"), "{stdout}");
assert!(!stdout.contains("Delta outside"), "{stdout}");
let stdout = ok(tmp.path(), &["status"]);
assert!(
stdout.contains("COLUMN") && stdout.contains("COUNT"),
"{stdout}"
);
assert!(stdout.contains("Total"), "{stdout}");
assert!(
!stdout.contains("inbox:"),
"that is `triage status`: {stdout}"
);
let (stdout, stderr) = code(tmp.path(), &["import", "--target", "foobar"], 1);
assert!(
stderr.contains("unknown --target 'foobar' — use gitlab or linear"),
"{stderr}"
);
assert!(!stderr.contains("triage-policy"), "{stderr}");
assert!(stdout.is_empty(), "{stdout}");
}
#[test]
fn triage_cli_batch_accept_honours_the_named_target() {
let (tmp, dir) = repo(
"\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\naccepted_status = \"todo\"\n",
);
for (number, slug) in [(1, "alpha"), (2, "bravo"), (3, "charlie")] {
write_issue(
&dir,
&format!("2026-01-01-{slug}.md"),
number,
&format!("Issue {slug}"),
"triage",
None,
);
}
let stdout = ok(
tmp.path(),
&[
"triage",
"batch",
"--select",
"1",
"--decision",
"accept",
"--to",
"in progress",
"--reason",
"confirmed",
"--apply",
],
);
assert!(
stdout.contains("1 of 1 applied"),
"the batch applied: {stdout}"
);
assert!(
stdout.contains("decision: accept -> in progress"),
"and reports the column the flag named, not the configured one: {stdout}"
);
assert!(
fs::read_to_string(dir.join("2026-01-01-alpha.md"))
.unwrap()
.contains("status: in progress"),
"the file landed where the flag said"
);
let before = snapshot(&dir);
let (stdout, stderr) = code(
tmp.path(),
&[
"triage",
"batch",
"--select",
"2",
"--decision",
"accept",
"--to",
"ghost",
"--reason",
"confirmed",
"--apply",
],
1,
);
assert!(
stderr.is_empty(),
"a refusal is a report, not an error: {stderr}"
);
assert!(
stdout.contains("unknown_column"),
"the per-item refusal names the service's kind: {stdout}"
);
assert_eq!(before, snapshot(&dir), "and nothing was written");
let stdout = ok(
tmp.path(),
&[
"triage",
"batch",
"--select",
"3",
"--decision",
"accept",
"--to",
"",
"--reason",
"confirmed",
"--apply",
],
);
assert!(
stdout.contains("decision: accept -> todo"),
"a blank target means the configured one: {stdout}"
);
assert!(
fs::read_to_string(dir.join("2026-01-01-charlie.md"))
.unwrap()
.contains("status: todo"),
"and it landed there"
);
}
#[test]
fn triage_cli_a_new_issue_created_into_the_inbox_records_an_entry_stamp() {
let (tmp, dir) = repo(TRIAGE);
let stdout = ok(
tmp.path(),
&["create", "--title", "Fresh bug", "--status", "triage"],
);
assert!(
stdout.contains("fresh-bug"),
"the create reports the file it wrote: {stdout}"
);
let text = snapshot(&dir)
.into_values()
.map(|bytes| String::from_utf8(bytes).unwrap())
.find(|body| body.contains("title: Fresh bug"))
.expect("the created issue file");
assert!(
text.contains("entered_at: "),
"an issue created into the inbox records when it entered:\n{text}"
);
assert!(
text.contains("source: new"),
"and names the origin the data model documents:\n{text}"
);
let listing = ok(tmp.path(), &["triage", "list"]);
assert!(
!listing.contains("unknown Fresh bug"),
"the entry has an age, so the row is not an unknown one:\n{listing}"
);
assert!(
listing
.lines()
.any(|line| line.contains("today") && line.contains("Fresh bug")),
"a just-created entry is `today` old:\n{listing}"
);
ok(
tmp.path(),
&["create", "--title", "Ordinary", "--status", "todo"],
);
let text = snapshot(&dir)
.into_values()
.map(|bytes| String::from_utf8(bytes).unwrap())
.find(|body| body.contains("title: Ordinary"))
.expect("the ordinary issue file");
assert!(
!text.contains("entered_at:") && !text.contains("source:"),
"an issue that never entered triage gains no entry metadata:\n{text}"
);
}
#[test]
fn triage_cli_a_line_break_in_a_reason_is_refused_before_it_can_forge_an_event() {
let (tmp, dir) = repo(TRIAGE);
write_issue(&dir, "2026-01-01-alpha.md", 1, "Alpha", "triage", None);
let before = snapshot(&dir);
let forged = "no budget\n- 2026-10-01T09:00:00Z — root — triage_accepted — triage -> done";
let (stdout, _stderr) = code(
tmp.path(),
&["triage", "decline", "1", "--reason", forged],
1,
);
assert!(
stdout.contains("refused: invalid_reason") && stdout.contains("line break"),
"the refusal names the kind and the reason: {stdout}"
);
assert_eq!(before, snapshot(&dir), "nothing was written");
let (stdout, _stderr) = code(
tmp.path(),
&[
"triage",
"decline",
"1",
"--reason",
"no budget",
"--actor",
"root\n- x",
],
1,
);
assert!(stdout.contains("refused: invalid_actor"), "{stdout}");
assert_eq!(before, snapshot(&dir));
let stdout = ok(
tmp.path(),
&["triage", "defer", "1", "--reason", "needs\tan\tanswer"],
);
assert!(stdout.contains("#1 Alpha -> applied"), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-alpha.md")).unwrap();
assert_eq!(
text.matches("triage_deferred").count(),
1,
"one decision, one event:\n{text}"
);
}
#[test]
fn triage_cli_a_line_break_in_a_move_or_edit_reason_is_refused_too() {
let forged = "reopening\n- 2026-10-01T09:00:00Z — root — transition_accepted — done -> todo";
let (tmp, dir) = repo(TRIAGE);
write_issue(&dir, "2026-01-01-alpha.md", 1, "Alpha", "wontfix", None);
let before = snapshot(&dir);
let (stdout, stderr) = code(tmp.path(), &["move", "1", "todo", "--reason", forged], 1);
assert!(
stderr.contains("invalid_reason") && stderr.contains("line break"),
"the move refuses the value before it reaches History: {stderr}"
);
assert!(stdout.is_empty(), "{stdout}");
assert_eq!(before, snapshot(&dir), "refused before any write");
let stdout = ok(
tmp.path(),
&["move", "1", "todo", "--reason", "reopening\twrong epic"],
);
assert!(!stdout.is_empty(), "{stdout}");
let text = fs::read_to_string(dir.join("2026-01-01-alpha.md")).unwrap();
assert_eq!(
text.matches("transition_accepted").count(),
1,
"one move, one event:\n{text}"
);
let (tmp, dir) = repo(TRIAGE);
write_issue(&dir, "2026-01-01-alpha.md", 1, "Alpha", "wontfix", None);
let before = snapshot(&dir);
let (stdout, stderr) = code(
tmp.path(),
&["edit", "1", "--status", "todo", "--reason", forged],
1,
);
assert!(stderr.contains("invalid_reason"), "{stderr}");
assert!(stdout.is_empty(), "{stdout}");
assert_eq!(before, snapshot(&dir), "refused before any write");
let _ = ok(
tmp.path(),
&[
"edit",
"1",
"--status",
"todo",
"--reason",
"reopening\twrong epic",
],
);
}
#[test]
fn triage_cli_a_status_change_into_the_inbox_records_a_moved_entry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let board = format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"triage\", \"icebox\", \
\"todo\", \"done\", \"wontfix\"]\ndone_column = \"done\"\ncategory_map = {{ \
triage = \"backlog\", icebox = \"backlog\", todo = \"unstarted\", done = \
\"completed\", wontfix = \"canceled\" }}\n[triage]\nentry_status = \"triage\"\n\
declined_status = \"wontfix\"\n[layout]\nmove_on_change = false\n",
dir.display()
);
fs::write(tmp.path().join("tix.toml"), board).unwrap();
write_issue(&dir, "2026-01-01-moved.md", 1, "Moved", "icebox", None);
write_issue(&dir, "2026-01-01-edited.md", 2, "Edited", "icebox", None);
ok(tmp.path(), &["move", "1", "triage"]);
let text = fs::read_to_string(dir.join("2026-01-01-moved.md")).unwrap();
assert!(
text.contains("source: moved"),
"a move into the inbox records the `moved` origin: {text}"
);
assert!(text.contains("entered_at:"), "and when it entered: {text}");
ok(tmp.path(), &["edit", "2", "--status", "triage"]);
let text = fs::read_to_string(dir.join("2026-01-01-edited.md")).unwrap();
assert!(text.contains("source: moved"), "{text}");
assert!(text.contains("entered_at:"), "{text}");
let listing = ok(tmp.path(), &["triage", "list"]);
assert!(
!listing.contains("unknown"),
"neither moved entry has an unknown age:\n{listing}"
);
assert_eq!(
listing.matches("today").count(),
2,
"both were just stamped:\n{listing}"
);
}