use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn issue_file(dir: &std::path::Path) -> PathBuf {
let issues = dir.join("docs").join("issues");
let mut md: Vec<PathBuf> = Vec::new();
walk(&issues, &mut md);
assert_eq!(
md.len(),
1,
"expected exactly one issue file in {}",
issues.display()
);
md.swap_remove(0)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with('.') || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
continue;
}
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
}
fn issue_files_under(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk(dir, &mut out);
out.sort();
out
}
fn created_relative(out: &[u8]) -> String {
let stdout = String::from_utf8(out.to_vec()).unwrap();
stdout
.lines()
.map(str::trim)
.find_map(|l| l.strip_prefix("Created "))
.unwrap_or_else(|| panic!("no `Created ` line in {stdout:?}"))
.to_string()
}
fn git(dir: &std::path::Path, args: &[&str]) {
let status = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("`git` must be available to run the CLI tests");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn cli_list_columns_selects_order_and_rejects_invalid_values() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Selected title"])
.assert()
.success();
let output = tix()
.current_dir(dir)
.args(["list", "--all", "--columns", "title, num"])
.assert()
.success()
.get_output()
.stdout
.clone();
let output = String::from_utf8(output).unwrap();
let header = output.lines().next().unwrap();
assert!(header.starts_with("TITLE"));
assert!(!header.contains("STATUS"));
assert!(output.contains("Selected title"));
tix()
.current_dir(dir)
.args(["list", "--all", "--columns", "title, bogus"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown list column"));
}
#[test]
fn cli_reads_the_same_issues_from_any_subdirectory() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Nested read"])
.assert()
.success();
let nested = dir.join("a").join("b");
fs::create_dir_all(&nested).unwrap();
tix()
.current_dir(dir)
.arg("render")
.assert()
.success()
.stdout(predicate::str::contains("Nested read"));
tix()
.current_dir(&nested)
.arg("render")
.assert()
.success()
.stdout(predicate::str::contains("Nested read"));
assert!(
!nested.join("docs").exists(),
"the store must resolve to the project root, not create a tree under cwd"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn cli_happy_path_end_to_end() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix()
.current_dir(dir)
.arg("init")
.assert()
.success()
.stdout(predicate::str::contains("Created"));
assert!(dir.join("tix.toml").is_file());
tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Fix login bug"])
.args(["--body", "Users can't log in"])
.args(["--priority", "high"])
.args(["--labels", "bug, auth"])
.args(["--assignee", "@alice"])
.args(["--estimate", "2"])
.assert()
.success()
.stdout(predicate::str::contains("Created "));
let file = issue_file(dir);
let filename = file.file_name().unwrap().to_string_lossy().to_string();
assert!(
filename.ends_with("-fix-login-bug.md"),
"expected slug filename, got {filename}"
);
let out = tix()
.current_dir(dir)
.args(["list", "--all"])
.assert()
.success()
.get_output()
.stdout
.clone();
let list = String::from_utf8(out).unwrap();
let first = list.lines().next().unwrap();
assert!(
first.contains("NUM") && first.contains("ID") && first.contains("TITLE"),
"header row must be first: {list}"
);
assert!(
list.lines().any(|l| l.contains("Fix login bug")),
"issue row missing: {list}"
);
let out = tix()
.current_dir(dir)
.args(["list", "--status", "done"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(String::from_utf8(out).unwrap().lines().count(), 1);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::starts_with("---\n").and(
predicate::str::contains("number: 1")
.and(predicate::str::contains("title: Fix login bug"))
.and(predicate::str::contains("status: triage"))
.and(predicate::str::contains("priority: high"))
.and(predicate::str::contains("labels:"))
.and(predicate::str::contains("assignee:"))
.and(predicate::str::contains("@alice"))
.and(predicate::str::contains("estimate:"))
.and(predicate::str::contains("Users can't log in")),
),
);
let out = tix()
.current_dir(dir)
.arg("status")
.assert()
.success()
.get_output()
.stdout
.clone();
let status = String::from_utf8(out).unwrap();
let header = status.lines().next().unwrap();
assert!(
header.contains("COLUMN") && header.contains("COUNT"),
"status header first: {status}"
);
assert!(
status
.lines()
.any(|l| l.trim().starts_with("triage") && l.contains('1')),
"triage count missing (create lands in the board's first column): {status}"
);
assert!(
status
.lines()
.any(|l| l.trim().starts_with("Total") && l.contains('1')),
"total row missing: {status}"
);
tix()
.current_dir(dir)
.args(["move", "1", "done"])
.arg("--commit")
.arg("abc123")
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("status: done").and(predicate::str::contains("abc123")));
tix()
.current_dir(dir)
.args(["move", "1", "in progress"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("invalid_transition"));
tix()
.current_dir(dir)
.args(["move", "1", "in progress", "--reason", "reopened"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains("status: in progress")
.and(predicate::str::contains("abc123").not()),
);
tix()
.current_dir(dir)
.args(["move", "1", "done"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("--commit"));
tix()
.current_dir(dir)
.args(["move", "1", "bogus"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("valid columns"));
tix()
.current_dir(dir)
.args(["edit", "1"])
.args(["--priority", "bogus"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("invalid priority"));
tix()
.current_dir(dir)
.args(["edit", "1"])
.args(["--priority", "medium"])
.args(["--title", "Fix login bug v2"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains("priority: medium")
.and(predicate::str::contains("Fix login bug v2")),
);
tix()
.current_dir(dir)
.args(["edit", "1", "--commit", "abc123"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("abc123"));
tix()
.current_dir(dir)
.args(["edit", "1", "--commit", "clear"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("abc123").not());
tix()
.current_dir(dir)
.args(["delete", "1"])
.arg("--yes")
.assert()
.success()
.stdout(predicate::str::contains("Deleted "));
assert!(!file.exists());
let out = tix()
.current_dir(dir)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(String::from_utf8(out).unwrap().lines().count(), 1);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("not found"));
}
#[test]
fn list_group_cycle_shows_progress_rollups() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["cycle", "create", "--name", "Alpha"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["cycle", "create", "--name", "Beta"])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"A",
"--cycles",
"Alpha",
"--due-date",
"2000-01-01",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"B",
"--status",
"done",
"--cycles",
"Alpha",
"--due-date",
"2999-01-01",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"C",
"--cycles",
"Beta",
"--due-date",
"2999-01-01",
])
.assert()
.success();
let out = tix()
.current_dir(dir)
.args(["list", "--all", "--filter", "group:cycle sort:cycle"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("-- cycle: Alpha (1/2 done, 50%) --"),
"missing Alpha rollup:\n{text}"
);
assert!(
text.contains("-- cycle: Beta (0/1 done, 0%) --"),
"missing Beta rollup:\n{text}"
);
}
#[test]
fn cycle_create_warns_on_stderr_about_an_overlapping_window() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args([
"cycle",
"create",
"--name",
"October",
"--start-date",
"2026-10-01",
"--due-date",
"2026-10-31",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"cycle",
"create",
"--name",
"Late October",
"--start-date",
"2026-10-20",
"--due-date",
"2026-11-10",
])
.assert()
.success()
.stderr(predicate::str::contains(
"warning[W-tix-cycle]: cycle `Late October` overlaps existing cycle(s): October",
));
let out = tix()
.current_dir(dir)
.args(["cycle", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("Late October\t\t2026-10-20\t2026-11-10\t0"),
"the warned cycle is still created:\n{text}"
);
}
#[test]
fn status_filter_overdue_reports_overdue_row_and_group_rollup() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["cycle", "create", "--name", "Alpha"])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"A",
"--cycles",
"Alpha",
"--due-date",
"2000-01-01",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"B",
"--status",
"done",
"--cycles",
"Alpha",
"--due-date",
"2999-01-01",
])
.assert()
.success();
let out = tix()
.current_dir(dir)
.args(["status", "--filter", "group:cycle overdue"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("Overdue"), "missing Overdue row:\n{text}");
assert!(
text.contains("cycle Alpha: 0/1 complete (0%)"),
"missing cycle group rollup:\n{text}"
);
}
#[test]
fn list_and_status_filters_do_not_persist_derived_fields() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["cycle", "create", "--name", "Alpha"])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Derived check A",
"--cycles",
"Alpha",
"--due-date",
"2000-01-01",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Derived check B",
"--status",
"done",
"--cycles",
"Alpha",
"--due-date",
"2999-01-01",
])
.assert()
.success();
let issues_dir = dir.join("docs").join("issues");
let before: Vec<(PathBuf, String)> = issue_files_under(&issues_dir)
.into_iter()
.map(|p| {
let text = fs::read_to_string(&p).unwrap();
(p, text)
})
.collect();
tix()
.current_dir(dir)
.args(["list", "--filter", "group:cycle overdue sort:cycle"])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"status",
"--filter",
"group:cycle overdue",
"--sort",
"cycle",
])
.assert()
.success();
for (path, original) in before {
let now = fs::read_to_string(&path).unwrap();
assert_eq!(
original, now,
"derived list/status output must not mutate issue file {path:?}"
);
}
}
#[test]
fn export_ics_to_stdout_emits_calendar_with_cycle_and_issue_events() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args([
"cycle",
"create",
"--name",
"Release",
"--start-date",
"2026-09-01",
"--due-date",
"2026-09-10",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Ship, now",
"--cycles",
"Release",
"--start-date",
"2026-09-03",
"--due-date",
"2026-09-05",
])
.assert()
.success();
let out = tix()
.current_dir(dir)
.args(["export", "--target", "ics", "--cycle", "Release"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("BEGIN:VCALENDAR\r\n"));
assert!(text.contains("PRODID:-//tix//tix-tracker//EN\r\n"));
assert!(text.contains("UID:cycle-release-2026-09-01-2026-09-10@tix\r\n"));
assert!(text.contains("UID:issue-1-Release@tix\r\n"));
assert!(text.contains("SUMMARY:[triage] Ship\\, now\r\n"));
}
#[test]
fn export_ics_output_writes_file_and_prints_path() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args([
"cycle",
"create",
"--name",
"Release",
"--start-date",
"2026-09-01",
"--due-date",
"2026-09-10",
])
.assert()
.success();
let out_path = dir.join("out").join("calendar.ics");
let out_path_s = out_path.to_string_lossy().to_string();
tix()
.current_dir(dir)
.args([
"export",
"--target",
"ics",
"--cycle",
"Release",
"--output",
&out_path_s,
])
.assert()
.success()
.stdout(predicate::str::contains(&out_path_s));
let contents = fs::read_to_string(&out_path).unwrap();
assert!(contents.contains("BEGIN:VCALENDAR\r\n"));
assert!(contents.contains("UID:cycle-release-2026-09-01-2026-09-10@tix\r\n"));
}
#[test]
fn export_ics_unknown_cycle_fails_nonzero() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["export", "--target", "ics", "--cycle", "Nope"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("cycle `Nope`"));
}
#[test]
fn export_ics_requires_cycle_argument() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["export", "--target", "ics"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("missing --cycle"));
}
#[test]
fn export_ics_is_deterministic_across_runs_and_orders_events() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args([
"cycle",
"create",
"--name",
"Release",
"--start-date",
"2026-09-01",
"--due-date",
"2026-09-10",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Issue B",
"--cycles",
"Release",
"--start-date",
"2026-09-03",
"--due-date",
"2026-09-05",
])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Issue A",
"--cycles",
"Release",
"--start-date",
"2026-09-02",
"--due-date",
"2026-09-04",
])
.assert()
.success();
let first = tix()
.current_dir(dir)
.args(["export", "--target", "ics", "--cycle", "Release"])
.assert()
.success()
.get_output()
.stdout
.clone();
let second = tix()
.current_dir(dir)
.args(["export", "--target", "ics", "--cycle", "Release"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(first, second, "ICS export must be byte-stable");
let text = String::from_utf8(first).unwrap();
let cycle_uid = "UID:cycle-release-2026-09-01-2026-09-10@tix\r\n";
let issue1_uid = "UID:issue-1-Release@tix\r\n";
let issue2_uid = "UID:issue-2-Release@tix\r\n";
let cycle_pos = text.find(cycle_uid).expect("cycle uid present");
let issue1_pos = text.find(issue1_uid).expect("issue 1 uid present");
let issue2_pos = text.find(issue2_uid).expect("issue 2 uid present");
assert!(
cycle_pos < issue1_pos && issue1_pos < issue2_pos,
"unexpected event order:\n{text}"
);
}
#[test]
fn init_refuses_overwrite() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("init")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("refusing to overwrite"));
let toml = fs::read_to_string(dir.join("tix.toml")).unwrap();
assert!(toml.contains("issues_dir"));
}
#[test]
fn missing_config_notice_on_stderr_but_subcommand_succeeds() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix()
.current_dir(dir)
.arg("list")
.assert()
.success()
.code(0)
.stdout(predicate::str::contains("NUM"))
.stderr(predicate::str::contains(
"no tix.toml found (run 'tix init' to create one)",
));
}
#[test]
fn no_argv_without_config_is_contract_error() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix()
.current_dir(dir)
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains(
"no tix.toml found — run \"tix init\" first",
));
}
#[test]
fn create_without_title_on_pipe_is_usage_error() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("create")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("--title"));
}
#[cfg(unix)]
#[test]
fn edit_invokes_real_editor_on_copy_and_commits_changes() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Editor target"])
.args(["--priority", "high"])
.assert()
.success();
let editor = dir.join("editor.sh");
fs::write(
&editor,
"#!/bin/sh\nsed -i 's/priority: high/priority: low/' \"$1\"\n",
)
.unwrap();
fs::set_permissions(&editor, fs::Permissions::from_mode(0o755)).unwrap();
tix()
.current_dir(dir)
.args(["edit", "1"])
.env("EDITOR", editor.to_str().unwrap())
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("priority: low"));
}
#[test]
fn view_by_slug_prefix_and_title_fragment_resolve() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Refactor auth module"])
.assert()
.success();
let file = issue_file(dir);
let stem = file.file_stem().unwrap().to_string_lossy().to_string();
tix()
.current_dir(dir)
.arg("view")
.arg("refactor-auth")
.assert()
.success()
.stdout(predicate::str::contains("title: Refactor auth module"));
tix()
.current_dir(dir)
.arg("view")
.arg("auth")
.assert()
.success()
.stdout(predicate::str::contains("title: Refactor auth module"));
let _ = stem;
}
#[test]
fn create_without_assignee_without_git_identity_lands_in_unassigned() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let out = tix()
.current_dir(dir)
.arg("create")
.args(["--title", "No git task"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.assert()
.success()
.get_output()
.stdout
.clone();
let rel = created_relative(&out);
assert!(
rel.starts_with("unassigned/"),
"expected unassigned/<stem>.md relative path, got {rel}"
);
assert!(
rel.ends_with("-no-git-task.md"),
"expected slug stem, got {rel}"
);
assert!(
dir.join("docs").join("issues").join(&rel).is_file(),
"file must exist at docs/issues/{rel}"
);
}
#[test]
fn two_creates_with_the_same_title_both_survive() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
for priority in ["high", "low", "medium"] {
tix()
.current_dir(dir)
.args(["create", "--title", "Same title", "--priority", priority])
.assert()
.success();
}
let files: Vec<PathBuf> = issue_files_under(&dir.join("docs").join("issues"))
.into_iter()
.filter(|p| {
p.to_string_lossy().contains("herbert-maschke")
&& p.extension().is_some_and(|x| x == "md")
})
.collect();
assert_eq!(
files.len(),
3,
"three creates must leave three files, found {:?}",
files
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
);
let mut priorities: Vec<String> = files
.iter()
.map(|p| {
let text = fs::read_to_string(p).unwrap();
text.lines()
.find_map(|l| l.strip_prefix("priority:"))
.unwrap_or("<none>")
.trim()
.to_string()
})
.collect();
priorities.sort();
assert_eq!(
priorities,
["high", "low", "medium"],
"all three priorities must survive, found {priorities:?}"
);
let listed = tix()
.current_dir(dir)
.args(["list", "--all"])
.assert()
.success();
let out = String::from_utf8_lossy(&listed.get_output().stdout).to_string();
for number in 1..4 {
assert!(
out.contains(&format!("{number} ")),
"issue #{number} must exist on the board:\n{out}"
);
}
}
#[test]
fn concurrent_creates_get_distinct_numbers() {
const N: usize = 24;
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let bin = assert_cmd::cargo::cargo_bin("tix");
let mut children: Vec<std::process::Child> = (1..=N)
.map(|i| {
std::process::Command::new(&bin)
.current_dir(dir)
.args(["create", "--title", &format!("Concurrent {i}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap()
})
.collect();
for child in children.iter_mut() {
child.wait().unwrap();
}
let segment = dir.join("docs").join("issues").join("herbert-maschke");
let files: Vec<PathBuf> = issue_files_under(&segment)
.into_iter()
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.collect();
assert_eq!(files.len(), N, "every create must land its own file");
let mut numbers: Vec<u32> = files
.iter()
.map(|p| {
let text = fs::read_to_string(p).unwrap();
text.lines()
.find_map(|l| l.strip_prefix("number:"))
.unwrap_or_else(|| panic!("no number: in {}", p.display()))
.trim()
.parse()
.unwrap()
})
.collect();
numbers.sort_unstable();
let distinct = {
let mut d = numbers.clone();
d.dedup();
d
};
assert_eq!(
distinct.len(),
N,
"every issue must have its own number, found {numbers:?}"
);
let claim_dir = dir.join("docs").join(".tix-numbers");
let claim_entries = fs::read_dir(&claim_dir).unwrap_or_else(|e| {
panic!(
"the claim ledger must exist at {} — if it is missing, the number was \
never reserved and nothing was serialised: {e}",
claim_dir.display()
)
});
let claims: Vec<u32> = claim_entries
.filter_map(|e| e.ok())
.filter_map(|e| e.path().file_stem()?.to_str()?.parse::<u32>().ok())
.collect();
assert_eq!(
claims.len(),
N,
"one claim per created issue, found {}",
claims.len()
);
let mut sorted_claims = claims;
sorted_claims.sort_unstable();
assert_eq!(
sorted_claims, numbers,
"the claim ledger and the numbers on disk must agree"
);
let mut seen: Vec<String> = Vec::new();
for number in &numbers {
let out = tix()
.current_dir(dir)
.args(["view", &number.to_string()])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8_lossy(&out).to_string();
let title = text
.lines()
.find_map(|l| l.strip_prefix("title:"))
.unwrap_or_else(|| panic!("no title for #{number}:\n{text}"))
.trim()
.to_string();
assert!(
title.starts_with("Concurrent "),
"#{number} must address a Concurrent issue, got {title:?}"
);
seen.push(title);
}
seen.sort();
seen.dedup();
assert_eq!(
seen.len(),
N,
"each number must address a different issue, found {seen:?}"
);
let issues_entries: Vec<String> = fs::read_dir(dir.join("docs").join("issues"))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n != "herbert-maschke")
.collect();
assert!(
issues_entries.is_empty(),
"docs/issues/ must contain only assignee segments, also found {issues_entries:?}"
);
}
#[test]
fn create_without_assignee_defaults_to_git_identity_segment() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Herb Mason"]);
git(dir, &["config", "user.email", "herb@example.com"]);
tix().current_dir(dir).arg("init").assert().success();
let out = tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Git task"])
.assert()
.success()
.get_output()
.stdout
.clone();
let rel = created_relative(&out);
assert!(
rel.starts_with("herb-mason/"),
"expected identity segment, got {rel}"
);
assert!(
dir.join("docs").join("issues").join(&rel).is_file(),
"file must exist at docs/issues/{rel}"
);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains("assignee:")
.and(predicate::str::contains("Herb Mason <herb@example.com>"))
.and(predicate::str::contains("@Herb").not()),
);
}
#[test]
fn edit_assignee_moves_file_and_keeps_number() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let created = tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Reassign me"])
.args(["--assignee", "@alice"])
.assert()
.success()
.get_output()
.stdout
.clone();
let rel = created_relative(&created);
assert!(
rel.starts_with("alice/"),
"explicit --assignee must win, got {rel}"
);
let issues = dir.join("docs").join("issues");
assert!(issues.join(&rel).is_file(), "{rel} must exist before edit");
tix()
.current_dir(dir)
.args(["edit", "1", "--assignee", "bob"])
.assert()
.success();
assert!(
!issues.join(&rel).exists(),
"old path {rel} must be gone after reassign"
);
let new_rel = rel.replacen("alice/", "bob/", 1);
assert!(
issues.join(&new_rel).is_file(),
"new path {new_rel} must exist after reassign"
);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains("number: 1")
.and(predicate::str::contains("assignee:"))
.and(predicate::str::contains("bob")),
);
}
#[test]
fn edit_assignee_empty_moves_to_unassigned() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let created = tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Clear me"])
.args(["--assignee", "alice"])
.assert()
.success()
.get_output()
.stdout
.clone();
let rel = created_relative(&created);
let issues = dir.join("docs").join("issues");
assert!(issues.join(&rel).is_file());
tix()
.current_dir(dir)
.args(["edit", "1", "--assignee", ""])
.assert()
.success();
assert!(
!issues.join(&rel).exists(),
"old path {rel} must be gone after clearing the assignee"
);
let new_rel = rel.replacen("alice/", "unassigned/", 1);
assert!(
issues.join(&new_rel).is_file(),
"new path {new_rel} must exist under unassigned/"
);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains("number: 1").and(predicate::str::contains("assignee").not()),
);
}
fn with_store() -> (TempDir, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let cfg = dir.join("tix.toml");
let mut text = std::fs::read_to_string(&cfg).unwrap();
text.push_str("\n[layout]\nmove_on_change = false\n");
std::fs::write(&cfg, text).unwrap();
let issues = dir.join("docs").join("issues");
std::fs::create_dir_all(&issues).unwrap();
(tmp, issues)
}
fn write_frontmatter(path: &std::path::Path, fm: &str) {
let contents = format!("---\n{fm}\n---\n\nbody text\n");
std::fs::write(path, contents).unwrap();
}
const GOOD_FM: &[&str] = &[
"number: '1'",
"title: \"Synthetic issue\"",
"labels: []",
"status: \"todo\"",
"priority: \"medium\"",
"created: \"2026-01-01\"",
"updated: \"2026-01-01\"",
];
fn fm_line(number: u32, title: &str, extra: &[(&str, &str)]) -> Vec<String> {
let mut parts: Vec<String> = vec![format!("number: {number}"), format!("title: \"{title}\"")];
for line in GOOD_FM {
if line.starts_with("number:") || line.starts_with("title:") {
continue;
}
parts.push(line.to_string());
}
for (key, val) in extra {
let rendered = if val.starts_with('[') || val.starts_with('{') || *val == "null" {
format!("{key}: {val}")
} else {
format!("{key}: '{val}'")
};
if let Some(slot) = parts.iter_mut().find(|l| l.starts_with(&format!("{key}:"))) {
*slot = rendered;
} else {
parts.push(rendered);
}
}
parts
}
#[test]
fn search_matches_assignee_field() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-01-alice-task.md"),
&fm_line(1, "Config work", &[("assignee", "@alice")]).join("\n"),
);
tix()
.current_dir(issues.parent().unwrap())
.args(["search", "alice"])
.assert()
.success()
.code(0)
.stdout(
predicate::str::contains("#1 Config work — todo (2026-01-01-alice-task.md)")
.and(predicate::str::contains("@alice")),
);
}
#[test]
fn search_matches_comment_text_only() {
let (_tmp, issues) = with_store();
let contents = format!(
"---\n{}\n---\n\nbody\n\n## Comments\n\n### alice (2026-01-03)\n\nNeed a hotfix for the timeout.\n",
fm_line(1, "Timeout investigation", &[]).join("\n")
);
std::fs::write(issues.join("2026-01-01-timeout.md"), contents).unwrap();
tix()
.current_dir(issues.parent().unwrap())
.args(["search", "hotfix"])
.assert()
.success()
.code(0)
.stdout(
predicate::str::contains("#1 Timeout investigation — todo (2026-01-01-timeout.md)")
.and(predicate::str::contains("hotfix for the timeout")),
);
}
#[test]
fn search_matches_frontmatter_label() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-02-bug-repro.md"),
&fm_line(1, "Repro steps", &[("labels", "[bug]")]).join("\n"),
);
tix()
.current_dir(issues.parent().unwrap())
.args(["search", "bug"])
.assert()
.success()
.stdout(predicate::str::contains("#1 Repro steps"));
}
#[test]
fn search_is_case_insensitive() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-01-case-test.md"),
&fm_line(1, "Login flow", &[]).join("\n"),
);
let out = tix()
.current_dir(issues.parent().unwrap())
.args(["search", "LOGIN"])
.assert()
.success()
.code(0)
.get_output()
.stdout
.clone();
assert!(String::from_utf8(out).unwrap().contains("#1 Login flow"));
}
#[test]
fn search_orders_hits_by_number() {
let (_tmp, issues) = with_store();
let shared = [("labels", "[shared-word]")];
write_frontmatter(
&issues.join("2026-01-01-c.md"),
&fm_line(3, "Third", &shared).join("\n"),
);
write_frontmatter(
&issues.join("2026-01-02-a.md"),
&fm_line(1, "First", &shared).join("\n"),
);
write_frontmatter(
&issues.join("2026-01-03-b.md"),
&fm_line(2, "Second", &shared).join("\n"),
);
let out = tix()
.current_dir(issues.parent().unwrap())
.args(["search", "shared-word"])
.assert()
.success()
.code(0)
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(out).unwrap();
let idx1 = stdout.find("#1 First").expect("hit #1 missing");
let idx2 = stdout.find("#2 Second").expect("hit #2 missing");
let idx3 = stdout.find("#3 Third").expect("hit #3 missing");
assert!(idx1 < idx2 && idx2 < idx3, "hits not ordered: {stdout}");
}
#[test]
fn search_zero_matches_prints_message_and_exits_zero() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-01-foo.md"),
&fm_line(1, "Foo", &[]).join("\n"),
);
tix()
.current_dir(issues.parent().unwrap())
.args(["search", "zzz-nothing"])
.assert()
.success()
.code(0)
.stdout("No issues found.\n");
}
#[test]
fn search_empty_query_is_usage_error_exit_2() {
let (_tmp, issues) = with_store();
tix()
.current_dir(issues.parent().unwrap())
.args(["search", ""])
.assert()
.failure()
.code(2);
}
#[test]
fn search_store_io_failure_exits_1() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
std::fs::create_dir_all(dir.join("docs")).unwrap();
std::fs::write(dir.join("docs/issues"), "not a directory").unwrap();
let assert = tix()
.current_dir(dir)
.args(["search", "anything"])
.assert()
.failure()
.code(1);
assert!(
!assert.get_output().stderr.is_empty(),
"io failure must surface on stderr"
);
}
#[test]
fn lint_flags_bad_status_with_valid_columns() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Bad status", &[("status", "donex")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-bad-status.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("2026-01-01-bad-status.md"))
.and(predicate::str::contains("donex"))
.and(predicate::str::contains(
"valid options: triage, todo, in progress, in review, done, wontfix",
)),
);
}
#[test]
fn an_unknown_top_level_frontmatter_key_survives_an_unrelated_edit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let cfg = dir.join("tix.toml");
let mut text = std::fs::read_to_string(&cfg).unwrap();
text.push_str("\n[layout]\nmove_on_change = false\n");
std::fs::write(&cfg, text).unwrap();
let segment = dir.join("docs").join("issues").join("herbert-maschke");
std::fs::create_dir_all(&segment).unwrap();
let path = segment.join("2026-01-01-extras.md");
std::fs::write(
&path,
"---\n\
number: 1\n\
title: Has extras\n\
status: todo\n\
labels: []\n\
priority: medium\n\
assignee: Herbert Maschke <thyseus@pm.me>\n\
files: []\n\
created: 2026-01-01\n\
updated: 2026-01-01\n\
custom_field: keep me\n\
list_of_things:\n\
- one\n\
- two\n\
nested:\n\
\x20 a: 1\n\
\x20 b: two\n\
---\n\n\
body text\n",
)
.unwrap();
tix()
.current_dir(dir)
.args(["edit", "1", "--priority", "high"])
.assert()
.success();
let after = std::fs::read_to_string(&path).unwrap();
for expected in [
"custom_field: keep me",
"list_of_things:\n- one\n- two",
"nested:\n a: 1\n b: two",
] {
assert!(
after.contains(expected),
"the unknown key must survive byte-identically, expected {expected:?} in:\n{after}"
);
}
assert!(
after.contains("priority: high"),
"and the edit itself must have applied: {after}"
);
let once = after.clone();
tix()
.current_dir(dir)
.args(["edit", "1", "--priority", "high"])
.assert()
.success();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
once,
"a second unrelated write must produce no diff"
);
}
#[test]
fn an_unknown_top_level_key_warns_without_failing_the_lint() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Typo", &[("titel", "probably a typo")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-typo.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.code(0)
.stderr(
predicate::str::contains("warning[W-tix-lint]")
.and(predicate::str::contains(
"unknown top-level frontmatter key `titel`",
))
.and(predicate::str::contains("[custom_attributes]"))
.and(predicate::str::contains("probably a typo")),
)
.stdout(predicate::str::contains("Lint OK with 2 warnings"));
}
#[test]
fn a_declared_custom_attribute_does_not_warn() {
let (tmp, issues) = with_store();
let fm = fm_line(1, "Declared", &[("build_id", "ci-4471")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-declared.md"), &fm);
let config = tmp.path().join("tix.toml");
let mut text = std::fs::read_to_string(&config).unwrap();
let at = text
.find("\n[")
.expect("tix.toml must have at least one table");
text.insert_str(at, "\n[custom_attributes]\nkeys = [\"build_id\"]\n");
std::fs::write(&config, text).unwrap();
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.code(0)
.stdout(predicate::str::contains("Lint OK"))
.stderr(predicate::str::contains("build_id").not());
drop(tmp);
}
#[test]
fn a_file_with_only_known_keys_lints_silently() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Clean", &[]).join("\n");
write_frontmatter(&issues.join("2026-01-01-clean.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.code(0)
.stdout(predicate::str::contains("Lint OK"))
.stderr(predicate::str::contains("unknown top-level frontmatter key").not());
}
#[test]
fn an_error_alongside_a_warning_still_fails_the_lint() {
let (_tmp, issues) = with_store();
let fm = fm_line(
1,
"Both",
&[("titel", "probably a typo"), ("status", "nonsense")],
)
.join("\n");
write_frontmatter(&issues.join("2026-01-01-both.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("warning[W-tix-lint]")),
);
}
#[test]
fn the_retired_links_key_is_still_dropped_and_reported_once() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Retired", &[("links", "\n- 2")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-retired.md"), &fm);
let out = tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.get_output()
.stderr
.clone();
let err = String::from_utf8_lossy(&out).to_string();
assert!(
err.contains("retired") || err.contains("links"),
"the retired key must still be reported by its own finding:\n{err}"
);
assert!(
!err.contains("unknown top-level frontmatter key `links`"),
"and must NOT also be reported as an unknown key — one problem, one finding:\n{err}"
);
tix()
.current_dir(issues.parent().unwrap())
.args(["edit", "1", "--priority", "high"])
.assert()
.success();
let after = std::fs::read_to_string(issues.join("2026-01-01-retired.md")).unwrap();
assert!(
!after.contains("\nlinks:"),
"the retired key must still be dropped on write:\n{after}"
);
}
#[test]
fn lint_flags_unknown_priority() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Urgent", &[("priority", "urgent")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-urgent.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("priority"))
.and(predicate::str::contains("urgent"))
.and(predicate::str::contains("low, medium, high")),
);
}
#[test]
fn lint_flags_malformed_created_date() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Date issue", &[("created", "2026-13-99")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-date.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("created"))
.and(predicate::str::contains("2026-13-99"))
.and(predicate::str::contains("YYYY-MM-DD")),
);
}
#[test]
fn lint_flags_an_unreadable_estimate() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Estimate", &[("estimate", "a couple of days")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-estimate.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("estimate"))
.and(predicate::str::contains("a couple of days")),
);
}
#[test]
fn lint_accepts_durations_ranges_and_annotations() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Estimate", &[("estimate", "~300 LOC, 0.5-1 day")]).join("\n");
write_frontmatter(&issues.join("2026-01-01-estimate.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.stdout(predicate::str::contains("Lint OK"));
}
#[test]
fn lint_flags_non_integer_number() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-01-bad-number.md"),
"number: 'abc'\ntitle: \"Bad number\"",
);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("error[E-tix-lint]"));
}
#[test]
fn lint_warns_about_an_unknown_key_but_still_succeeds() {
let (_tmp, issues) = with_store();
let fm = fm_line(1, "Custom", &[("custom_key", "custom_value")]).join("\n");
write_frontmatter(&issues.join("2026-01-03-clean.md"), &fm);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.code(0)
.stdout(predicate::str::contains(
"Lint OK with 2 warnings: 1 issues checked.",
))
.stderr(predicate::str::contains("warning[W-tix-lint]"));
}
#[test]
fn lint_clean_store_summary() {
let (_tmp, issues) = with_store();
write_frontmatter(
&issues.join("2026-01-01-a.md"),
&fm_line(1, "A", &[]).join("\n"),
);
write_frontmatter(
&issues.join("2026-01-02-b.md"),
&fm_line(2, "B", &[]).join("\n"),
);
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.success()
.code(0)
.stdout(predicate::str::contains("Lint OK"))
.stdout(predicate::str::contains("2 issues checked."));
}
#[test]
fn lint_missing_frontmatter_diagnostic_no_crash() {
let (_tmp, issues) = with_store();
std::fs::write(issues.join("2026-01-01-naked.md"), "no frontmatter here\n").unwrap();
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("2026-01-01-naked.md")),
);
}
#[test]
fn lint_unparsable_yaml_diagnostic_no_crash() {
let (_tmp, issues) = with_store();
write_frontmatter(&issues.join("2026-01-01-broken.md"), "number 0x\n");
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(
predicate::str::contains("error[E-tix-lint]")
.and(predicate::str::contains("2026-01-01-broken.md")),
);
}
#[test]
fn lint_non_utf8_diagnostic_no_crash() {
let (_tmp, issues) = with_store();
std::fs::write(issues.join("2026-01-01-binary.md"), b"\xff\xfe\x00\x01\xff").unwrap();
tix()
.current_dir(issues.parent().unwrap())
.arg("lint")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("error[E-tix-lint]"));
}
#[test]
fn edit_assignee_same_segment_is_noop_success() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let created = tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Stay put"])
.args(["--assignee", "alice"])
.assert()
.success()
.get_output()
.stdout
.clone();
let rel = created_relative(&created);
let issues = dir.join("docs").join("issues");
tix()
.current_dir(dir)
.args(["edit", "1", "--assignee", "@alice"])
.assert()
.success();
assert!(
issues.join(&rel).is_file(),
"file must stay at {rel} when the segment is unchanged"
);
}
fn head_of(dir: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(["rev-parse", "HEAD"])
.output()
.expect("`git` must be available to run the CLI tests");
assert!(out.status.success(), "git rev-parse HEAD failed");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn init_repo(dir: &std::path::Path) {
git(dir, &["init"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "Test"]);
}
#[test]
fn move_into_done_auto_captures_head() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Ship it"])
.assert()
.success();
init_repo(dir);
git(dir, &["add", "-A"]);
git(dir, &["commit", "-m", "work"]);
let head = head_of(dir);
tix()
.current_dir(dir)
.args(["move", "1", "done"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("status: done").and(predicate::str::contains(&head)));
}
#[test]
fn move_batch_previews_without_yes_and_writes_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
for title in ["Alpha", "Beta"] {
tix()
.current_dir(dir)
.args(["create", "--title", title, "--status", "todo"])
.assert()
.success();
}
tix()
.current_dir(dir)
.args(["move", "1,2", "in progress"])
.assert()
.failure()
.code(1)
.stdout(
predicate::str::contains("would move 2 issue(s) to in progress")
.and(predicate::str::contains("re-run with --yes")),
);
for number in ["1", "2"] {
tix()
.current_dir(dir)
.args(["view", number])
.assert()
.success()
.stdout(predicate::str::contains("status: todo"));
}
}
#[test]
fn move_batch_applies_every_selected_issue() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
for title in ["Alpha", "Beta"] {
tix()
.current_dir(dir)
.args(["create", "--title", title])
.assert()
.success();
}
tix()
.current_dir(dir)
.args(["move", "1,2", "in progress", "--yes"])
.assert()
.success()
.stdout(
predicate::str::contains("#1 moved to in progress")
.and(predicate::str::contains("#2 moved to in progress"))
.and(predicate::str::contains("2 moved, 0 refused")),
);
for number in ["1", "2"] {
tix()
.current_dir(dir)
.args(["view", number])
.assert()
.success()
.stdout(predicate::str::contains("status: in progress"));
}
}
#[test]
fn move_batch_filter_dry_run_lists_the_filtered_set_and_writes_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Alpha", "--status", "todo"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Beta", "--status", "todo"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Gamma", "--status", "in progress"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["move", "--filter", "status:todo", "done", "--dry-run"])
.assert()
.success()
.stdout(
predicate::str::contains("selection: 2 issues")
.and(predicate::str::contains("#1 Alpha"))
.and(predicate::str::contains("#2 Beta"))
.and(predicate::str::contains("dry run: nothing written")),
);
tix()
.current_dir(dir)
.args(["view", "3"])
.assert()
.success()
.stdout(predicate::str::contains("status: in progress"));
tix()
.current_dir(dir)
.args(["move", "1", "done", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("would move 1 issue(s) to done"));
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("status: todo"));
}
#[test]
fn move_batch_partial_refusal_exits_nonzero_and_applies_the_rest() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Done one"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["move", "1", "done", "--commit", "abc123"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Todo one"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["move", "1,2", "in progress", "--yes"])
.assert()
.failure()
.code(1)
.stdout(
predicate::str::contains("#1 refused:")
.and(predicate::str::contains("1 moved, 1 refused")),
);
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(predicate::str::contains("status: done"));
tix()
.current_dir(dir)
.args(["view", "2"])
.assert()
.success()
.stdout(predicate::str::contains("status: in progress"));
}
#[test]
fn move_batch_terminal_exit_reason_is_recorded_on_every_item() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
for title in ["Alpha", "Beta"] {
tix()
.current_dir(dir)
.args(["create", "--title", title])
.assert()
.success();
tix()
.current_dir(dir)
.args(["move", title, "done", "--commit", "abc123"])
.assert()
.success();
}
tix()
.current_dir(dir)
.args([
"move",
"1,2",
"in progress",
"--yes",
"--reason",
"reopened",
])
.assert()
.success()
.stdout(predicate::str::contains("2 moved, 0 refused"));
for number in ["1", "2"] {
tix()
.current_dir(dir)
.args(["view", number])
.assert()
.success()
.stdout(
predicate::str::contains("status: in progress")
.and(predicate::str::contains("reopened")),
);
}
}
#[test]
fn edit_commit_auto_resolves_the_closing_commit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.arg("create")
.args(["--title", "Close me"])
.assert()
.success();
init_repo(dir);
git(dir, &["add", "-A"]);
git(dir, &["commit", "-m", "work"]);
tix()
.current_dir(dir)
.args(["move", "1", "done", "--commit", "placeholder"])
.assert()
.success();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-m", "close ticket"]);
let closing = head_of(dir);
tix()
.current_dir(dir)
.args(["edit", "1", "--commit", "auto"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1"])
.assert()
.success()
.stdout(
predicate::str::contains(format!("commit: {closing}"))
.and(predicate::str::contains("placeholder").not()),
);
}
fn repo_with_issues(count: usize) -> (TempDir, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
tix().current_dir(&dir).arg("init").assert().success();
for i in 0..count {
tix()
.current_dir(&dir)
.args(["create", "--title", &format!("Issue {i}")])
.assert()
.success();
}
(tmp, dir)
}
fn repo_with_named_issues(count: usize) -> (TempDir, std::path::PathBuf) {
let names = [
"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India",
];
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
tix().current_dir(&dir).arg("init").assert().success();
for name in names.iter().take(count) {
tix()
.current_dir(&dir)
.args(["create", "--title", &format!("Issue {name}")])
.assert()
.success();
}
(tmp, dir)
}
fn issue_snapshot(dir: &std::path::Path) -> Vec<(String, Vec<u8>)> {
let issues = dir.join("docs").join("issues");
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
let mut stack = vec![issues.clone()];
while let Some(current) = stack.pop() {
if !current.is_dir() {
continue;
}
for entry in fs::read_dir(¤t).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
let rel = path
.strip_prefix(&issues)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
fn id_from_list(stdout: &str, needle: &str) -> String {
stdout
.lines()
.find(|l| l.contains(needle))
.and_then(|l| l.rsplit_once('['))
.map(|(_, rest)| rest.trim_end_matches(']').to_string())
.unwrap_or_else(|| panic!("no relationship line mentioning {needle:?} in:\n{stdout}"))
}
#[test]
fn wp03_link_requires_type_and_names_the_flag() {
let (_tmp, dir) = repo_with_issues(2);
let before = issue_snapshot(&dir);
let assert = tix()
.current_dir(&dir)
.args(["link", "1", "2"])
.assert()
.failure();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("--type"),
"the rejection must name the missing flag, got: {stderr}"
);
for spelling in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(
stderr.contains(spelling),
"the rejection must list `{spelling}`, got: {stderr}"
);
}
assert_eq!(
issue_snapshot(&dir),
before,
"a rejected link must leave every file byte-identical"
);
}
#[test]
fn wp03_link_rejects_an_unknown_kind() {
let (_tmp, dir) = repo_with_issues(2);
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "depends-on"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown_kind"))
.stderr(predicate::str::contains("depends-on"));
assert_eq!(issue_snapshot(&dir), before, "no file may change");
}
#[test]
fn wp03_link_rejects_an_empty_type_rather_than_defaulting() {
let (_tmp, dir) = repo_with_issues(2);
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", ""])
.assert()
.failure()
.stderr(predicate::str::contains("--type"));
assert_eq!(issue_snapshot(&dir), before, "no file may change");
}
#[test]
fn wp03_the_retired_untype_spellings_are_gone() {
let (_tmp, dir) = repo_with_issues(2);
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["create", "--title", "Fresh", "--links", "0"])
.assert()
.failure()
.stderr(predicate::str::contains("--links"));
tix()
.current_dir(&dir)
.args(["edit", "1", "--links", "2"])
.assert()
.failure()
.stderr(predicate::str::contains("--links"));
tix()
.current_dir(&dir)
.args(["unlink", "1", "2"])
.assert()
.failure()
.stderr(predicate::str::contains("--type"));
assert_eq!(
issue_snapshot(&dir),
before,
"no retired spelling may write anything"
);
}
#[test]
fn link_list_renders_a_multi_word_target_title_in_full() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
for title in [
"First issue here",
"Second one",
"A very long multi word title indeed",
] {
tix()
.current_dir(dir)
.args(["create", "--title", title])
.assert()
.success();
}
tix()
.current_dir(dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["link", "1", "3", "--type", "blocks"])
.assert()
.success();
let out = tix()
.current_dir(dir)
.args(["link", "list", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
for title in ["Second one", "A very long multi word title indeed"] {
assert!(
out.contains(title),
"`tix link list` must show the whole title {title:?}:\n{out}"
);
}
assert!(
!out.contains("#1 one "),
"the title must not be truncated to its last word:\n{out}"
);
let chain = tix()
.current_dir(dir)
.args(["chain", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8(chain)
.unwrap()
.contains("First issue here"),
"the chain line must also show the whole title"
);
}
#[test]
fn wp03_link_creates_a_typed_edge_and_lists_it_deterministically() {
let (_tmp, dir) = repo_with_issues(3);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success()
.stdout(predicate::str::contains("blocks"));
tix()
.current_dir(&dir)
.args(["link", "1", "3", "--type", "duplicates"])
.assert()
.success();
let first = tix()
.current_dir(&dir)
.args(["link", "list", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let first = String::from_utf8(first).unwrap();
let second = tix()
.current_dir(&dir)
.args(["link", "list", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
first,
String::from_utf8(second).unwrap(),
"listing must be byte-comparable across runs"
);
let blocks_at = first.find("blocks").unwrap();
let dupes_at = first.find("duplicates").unwrap();
assert!(
blocks_at < dupes_at,
"ordered by (kind, target), got:\n{first}"
);
}
#[test]
fn wp03_blocked_by_stores_the_canonical_blocks_edge() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocked_by"])
.assert()
.success();
let out = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("blocks"), "the stored form is `blocks`: {out}");
assert!(
!out.contains("blocked_by"),
"`blocked_by` is an input spelling and is never persisted: {out}"
);
tix()
.current_dir(&dir)
.args(["link", "list", "--kind", "blocked_by"])
.assert()
.success()
.stdout(predicate::str::contains("blocks").and(predicate::str::contains("rel_")));
tix()
.current_dir(&dir)
.args(["link", "list", "--kind", "related"])
.assert()
.success()
.stdout(predicate::str::contains("No related relationships"));
}
#[test]
fn wp03_a_reciprocal_pair_is_rejected_as_a_cycle() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "blocks"])
.assert()
.failure()
.stderr(predicate::str::contains("cycle"));
assert_eq!(
issue_snapshot(&dir),
before,
"a cycle-closing edge must change nothing (SC-005)"
);
}
#[test]
fn wp03_a_self_link_and_a_missing_target_are_both_refused() {
let (_tmp, dir) = repo_with_issues(2);
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "1", "1", "--type", "blocks"])
.assert()
.failure()
.stderr(predicate::str::contains("self"));
tix()
.current_dir(&dir)
.args(["link", "1", "4243", "--type", "blocks"])
.assert()
.failure();
assert_eq!(issue_snapshot(&dir), before, "no file may change");
}
#[test]
fn wp03_link_edit_keeps_the_id_and_retargets() {
let (_tmp, dir) = repo_with_issues(3);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "#1 --blocks--> #2");
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--to", "3"])
.assert()
.success();
let after = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let after = String::from_utf8(after).unwrap();
assert!(
after.contains(&"#1 --blocks--> #3".to_string()),
"the retarget took effect and the id survived: {after}"
);
assert!(after.contains(&id), "the same id is still listed: {after}");
}
#[test]
fn wp03_link_remove_is_idempotent_and_writes_nothing_the_second_time() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "#1 --blocks--> #2");
tix()
.current_dir(&dir)
.args(["link", "remove", &id])
.assert()
.success()
.stdout(predicate::str::contains("Removed"));
let after_first = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "remove", &id])
.assert()
.success()
.stdout(predicate::str::contains("no change"));
assert_eq!(
issue_snapshot(&dir),
after_first,
"a repeated remove must not write a file or append History"
);
}
#[test]
fn wp03_removing_an_absent_id_is_a_no_op_not_an_error() {
let (_tmp, dir) = repo_with_issues(1);
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "remove", "rel_does_not_exist"])
.assert()
.success()
.stdout(predicate::str::contains("no change"));
assert_eq!(issue_snapshot(&dir), before, "nothing may be written");
}
#[test]
fn wp03_a_rejected_edit_changes_nothing() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "#1 --blocks--> #2");
let before = issue_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--to", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("self"));
assert_eq!(issue_snapshot(&dir), before, "no file may change (SC-005)");
}
#[test]
fn wp03_unlink_with_type_removes_the_same_edge_as_link_remove() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "related"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["unlink", "1", "2", "--type", "related"])
.assert()
.success()
.stdout(predicate::str::contains("Unlinked"));
tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.stdout(predicate::str::contains("No relationships"));
}
#[test]
fn wp03_reapplying_the_same_create_is_idempotent() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let after_first = tix()
.current_dir(&dir)
.args(["view", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let after_first = String::from_utf8(after_first).unwrap();
let events_first = after_first.matches("relationship_added").count();
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success()
.stdout(predicate::str::contains("no change"));
let after_second = tix()
.current_dir(&dir)
.args(["view", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let after_second = String::from_utf8(after_second).unwrap();
assert_eq!(
after_second.matches("relationship_added").count(),
events_first,
"a re-applied create appends no second History event:\n{after_second}"
);
let listed = tix()
.current_dir(&dir)
.args(["link", "list", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let listed = String::from_utf8(listed).unwrap();
assert_eq!(
listed.lines().filter(|l| l.contains("rel_")).count(),
1,
"one edge, not two:\n{listed}"
);
}
#[test]
fn wp03_chain_prints_a_three_node_chain_in_order() {
let (_tmp, dir) = repo_with_issues(3);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["link", "2", "3", "--type", "blocks"])
.assert()
.success();
let out = tix()
.current_dir(&dir)
.args(["chain", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
let at = |n: &str| {
out.find(n)
.unwrap_or_else(|| panic!("{n} missing from:\n{out}"))
};
assert!(at("#1") < at("#2"), "order 1 then 2:\n{out}");
assert!(at("#2") < at("#3"), "order 2 then 3:\n{out}");
assert!(out.contains("Issue 0"), "display names appear:\n{out}");
assert!(
!out.contains("truncated"),
"a three-node chain is not truncated:\n{out}"
);
}
#[test]
fn wp03_chain_names_the_empty_case() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["chain", "1"])
.assert()
.success()
.stdout(predicate::str::contains("No dependency chain"));
}
#[test]
fn wp03_chain_with_an_unresolvable_selector_exits_two() {
let (_tmp, dir) = repo_with_issues(1);
tix()
.current_dir(&dir)
.args(["chain", "1000"])
.assert()
.code(2);
}
#[test]
fn wp03_list_show_relationships_adds_blocked_and_relations_columns() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "blocks"])
.assert()
.success();
let plain = tix()
.current_dir(&dir)
.args(["list", "--all"])
.assert()
.success()
.get_output()
.stdout
.clone();
let plain = String::from_utf8(plain).unwrap();
assert!(
!plain.contains("BLOCKED"),
"the default listing gains no column:\n{plain}"
);
let shown = tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "relationships"])
.assert()
.success()
.get_output()
.stdout
.clone();
let shown = String::from_utf8(shown).unwrap();
let header = shown.lines().next().unwrap();
assert!(header.contains("BLOCKED"), "header: {header}");
assert!(header.contains("RELATIONS"), "header: {header}");
assert!(
shown.contains("Blocked by #2"),
"the blocked reason comes from the service:\n{shown}"
);
assert!(
shown.contains("blocks -> #1"),
"the relationship is shown on the blocked side too:\n{shown}"
);
}
#[test]
fn wp03_a_related_only_issue_is_not_shown_as_blocked() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "related"])
.assert()
.success();
let shown = tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "relationships"])
.assert()
.success()
.get_output()
.stdout
.clone();
let shown = String::from_utf8(shown).unwrap();
assert!(
!shown.contains("Blocked by"),
"`related` must not imply blocked — the distinction is the point:\n{shown}"
);
let rows: Vec<&str> = shown
.lines()
.skip(1)
.filter(|line| line.contains("related -> #"))
.collect();
assert_eq!(
rows.len(),
2,
"both endpoints show the canonical orientation:\n{shown}"
);
let target = rows[0].split("related -> ").nth(1).unwrap();
assert_eq!(
rows[1].split("related -> ").nth(1).unwrap(),
target,
"both rows must render the one stored orientation:\n{shown}"
);
}
#[test]
fn wp03_view_show_relationships_appends_the_derived_block() {
let (_tmp, dir) = repo_with_issues(2);
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "blocks"])
.assert()
.success();
let plain = tix()
.current_dir(&dir)
.args(["view", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let plain = String::from_utf8(plain).unwrap();
assert!(
!plain.contains("## Relationships"),
"the default detail output is unchanged:\n{plain}"
);
let shown = tix()
.current_dir(&dir)
.args(["view", "1", "--show", "relationships"])
.assert()
.success()
.get_output()
.stdout
.clone();
let shown = String::from_utf8(shown).unwrap();
for section in ["## Relationships", "## Dependency chain", "## Blocked"] {
assert!(shown.contains(section), "missing {section} in:\n{shown}");
}
assert!(shown.contains("Blocked by #2"), "reason:\n{shown}");
}
#[test]
fn wp03_an_unknown_show_value_is_rejected() {
let (_tmp, dir) = repo_with_issues(1);
tix()
.current_dir(&dir)
.args(["list", "--show", "relatinships"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown --show value"));
tix()
.current_dir(&dir)
.args(["view", "1", "--show", "relatinships"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown --show value"));
}
#[test]
fn wp03_a_two_issue_mutation_is_all_or_nothing() {
let (_tmp, dir) = repo_with_issues(3);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let before = issue_snapshot(&dir);
assert!(before.len() >= 3, "precondition: three files on disk");
tix()
.current_dir(&dir)
.args(["link", "2", "1", "--type", "blocks"])
.assert()
.failure();
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["link", "3", "1", "--type", "blocks"])
.assert()
.success();
let mid = issue_snapshot(&dir);
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "#1 --blocks--> #2");
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--to", "1", "--type", "related"])
.assert()
.failure();
assert_eq!(
issue_snapshot(&dir),
mid,
"a rejected two-half mutation must leave both files byte-identical"
);
}
#[test]
fn wp03_relationship_commands_are_tty_free() {
let (_tmp, dir) = repo_with_issues(2);
for args in [
vec!["link", "1", "2", "--type", "blocks"],
vec!["link", "list"],
vec!["chain", "1"],
vec!["list", "--show", "relationships"],
vec!["view", "2", "--show", "relationships"],
] {
tix().current_dir(&dir).args(&args).assert().success();
}
}
#[test]
fn wp03_a_rekind_that_moves_the_canonical_owner_keeps_the_relationship() {
for (from, to, kind, rekind) in [
("3", "1", "duplicates", "blocked_by"),
("3", "1", "duplicates", "related"),
("6", "9", "related", "retarget"),
] {
let (_tmp, dir) = repo_with_named_issues(9);
tix()
.current_dir(&dir)
.args(["link", from, to, "--type", kind])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), &format!("--{kind}-->"));
let edit = if rekind == "retarget" {
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--to", "3"])
.assert()
.success()
} else {
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--type", rekind])
.assert()
.success()
};
let text = String::from_utf8(edit.get_output().stdout.clone()).unwrap();
assert!(
text.starts_with("Relinked"),
"{from} {kind} {to} -> {rekind}: {text}"
);
let after = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let after = String::from_utf8(after).unwrap();
assert!(
after.contains(&id),
"{from} {kind} {to} -> {rekind}: the relationship vanished.\nlist said: {after}"
);
let events: Vec<String> = issue_files_under(&dir.join("docs").join("issues"))
.into_iter()
.filter(|p| {
std::fs::read_to_string(p)
.unwrap()
.contains("relationship_retargeted")
})
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(
events.len(),
1,
"{from} {kind} {to} -> {rekind}: exactly one retarget event, on the new owner, got {events:?}"
);
}
}
#[test]
fn wp03_a_rekind_names_the_canonical_target_in_its_display_and_history() {
let (_tmp, dir) = repo_with_named_issues(3);
tix()
.current_dir(&dir)
.args(["link", "3", "1", "--type", "duplicates"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "--duplicates--> #1");
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--type", "blocked_by"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let listed = String::from_utf8(listed).unwrap();
let row = listed
.lines()
.find(|l| l.contains("#1 --blocks--> #3"))
.unwrap_or_else(|| panic!("no blocks edge after the re-kind:\n{listed}"));
assert!(
row.ends_with(" Charlie [") || row.contains(" Charlie "),
"the row must name #2's title, got {row:?}"
);
assert!(
!row.contains(" Alpha "),
"and must not name #0's title, got {row:?}"
);
let view = tix()
.current_dir(&dir)
.args(["view", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
let view = String::from_utf8(view).unwrap();
let event = view
.lines()
.find(|l| l.contains("relationship_retargeted"))
.unwrap_or_else(|| panic!("no retarget event:\n{view}"));
assert!(
event.contains("blocks #3 Issue Charlie"),
"the event must name the canonical target, got {event:?}"
);
assert!(
!event.contains("blocks #2 Issue Alpha"),
"and must not name the authored target, got {event:?}"
);
}
#[test]
fn wp03_only_blocks_edges_are_subject_to_cycle_rejection() {
let (_tmp, dir) = repo_with_named_issues(3);
tix()
.current_dir(&dir)
.args(["link", "3", "1", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["link", "1", "3", "--type", "related"])
.assert()
.success()
.stdout(predicate::str::contains("linked").or(predicate::str::contains("Linked")));
tix()
.current_dir(&dir)
.args(["link", "1", "3", "--type", "blocks"])
.assert()
.failure()
.stderr(predicate::str::contains("cycle"));
}
#[test]
fn wp03_a_blocks_edge_can_be_rekinds_as_related() {
let (_tmp, dir) = repo_with_named_issues(3);
tix()
.current_dir(&dir)
.args(["link", "3", "1", "--type", "blocks"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let id = id_from_list(&String::from_utf8(listed).unwrap(), "--blocks--> #1");
tix()
.current_dir(&dir)
.args(["link", "edit", &id, "--type", "related"])
.assert()
.success()
.stdout(predicate::str::contains("Relinked"));
let after = tix()
.current_dir(&dir)
.args(["link", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let after = String::from_utf8(after).unwrap();
assert!(
after.contains("--related-->"),
"the re-kinded edge must be stored: {after}"
);
assert!(
!after.contains("--blocks-->"),
"and must no longer be a `blocks` edge: {after}"
);
}
#[test]
fn wp04_edit_status_cannot_move_a_blocked_issue_into_completion() {
let (_tmp, dir) = repo_with_named_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
let before = issue_snapshot(&dir);
let move_err = tix()
.current_dir(&dir)
.args(["move", "2", "done"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let edit_err = tix()
.current_dir(&dir)
.args(["edit", "2", "--status", "done"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let move_err = String::from_utf8_lossy(&move_err).to_string();
let edit_err = String::from_utf8_lossy(&edit_err).to_string();
for (label, text) in [("move", &move_err), ("edit", &edit_err)] {
assert!(text.contains("blocked:"), "{label}: {text}");
assert!(text.contains("#1"), "{label} must name the blocker: {text}");
}
assert!(
edit_err.contains("tix move"),
"the refusal must say how to proceed: {edit_err}"
);
assert_eq!(
issue_snapshot(&dir),
before,
"a refused edit writes nothing"
);
let view = tix()
.current_dir(&dir)
.args(["view", "2"])
.assert()
.success()
.get_output()
.stdout
.clone();
let view = String::from_utf8(view).unwrap();
assert!(
!view.contains("blocked_move_acknowledged"),
"a refused move must not record an acknowledgement:\n{view}"
);
assert!(
view.contains("status: triage"),
"the status must be unchanged:\n{view}"
);
}
#[test]
fn wp04_edit_status_still_allows_ordinary_columns_and_unblocked_issues() {
let (_tmp, dir) = repo_with_named_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["edit", "2", "--status", "in progress"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "done"])
.assert()
.success();
let view = tix()
.current_dir(&dir)
.args(["view", "1"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(String::from_utf8(view).unwrap().contains("status: done"));
}
#[test]
fn wp04_a_rejected_show_value_writes_nothing_to_stdout() {
let (_tmp, dir) = repo_with_named_issues(1);
let out = tix()
.current_dir(&dir)
.args(["view", "1", "--show", "bogus"])
.assert()
.failure()
.get_output()
.stdout
.clone();
assert!(
out.is_empty(),
"a rejected invocation must print nothing, got {} bytes: {:?}",
out.len(),
String::from_utf8_lossy(&out)
);
}
#[test]
fn wp04_the_acknowledgement_event_names_the_blockers_once() {
let (_tmp, dir) = repo_with_named_issues(2);
tix()
.current_dir(&dir)
.args(["link", "1", "2", "--type", "blocks"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "2", "done", "--yes", "--commit", "abc123"])
.assert()
.success();
let view = tix()
.current_dir(&dir)
.args(["view", "2"])
.assert()
.success()
.get_output()
.stdout
.clone();
let view = String::from_utf8(view).unwrap();
let event = view
.lines()
.find(|l| l.contains("blocked_move_acknowledged"))
.unwrap_or_else(|| panic!("no acknowledgement event:\n{view}"));
assert!(
event.contains("Blocked by #1"),
"the event must name the blocker: {event:?}"
);
assert_eq!(
event.matches("blocked:").count(),
0,
"the record carries the reason, not the surface wrapper: {event:?}"
);
assert!(
!event.contains("blocked: Blocked by"),
"no doubled prefix: {event:?}"
);
}
fn hierarchy_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let root = dir.join("docs").join("issues");
let mut stack = vec![root];
let mut out = Vec::new();
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.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
fn hierarchy_repo(count: usize) -> (TempDir, PathBuf, u32) {
let names = ["Epic", "Alpha", "Bravo", "Charlie"];
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
tix().current_dir(&dir).arg("init").assert().success();
let cfg = dir.join("tix.toml");
let mut text = std::fs::read_to_string(&cfg).unwrap();
text.push_str("\n[layout]\nmove_on_change = false\n");
std::fs::write(&cfg, text).unwrap();
for name in names.iter().take(count) {
tix()
.current_dir(&dir)
.args(["create", "--title", name])
.assert()
.success();
}
let out = tix()
.current_dir(&dir)
.args(["list", "--all"])
.assert()
.success()
.get_output()
.stdout
.clone();
let first = String::from_utf8(out).unwrap();
let number: u32 = first
.lines()
.nth(1)
.and_then(|l| l.split_whitespace().next())
.and_then(|n| n.parse().ok())
.expect("`tix list` must report a first row with a number");
(tmp, dir, number)
}
fn parent_of_title(dir: &Path, title: &str) -> Option<String> {
let path = issue_file_named(dir, title);
let text = fs::read_to_string(path).unwrap();
text.lines()
.find_map(|l| l.strip_prefix("parent: "))
.map(|value| value.trim().to_string())
}
fn id_of_title(dir: &Path, title: &str) -> String {
let path = issue_file_named(dir, title);
let text = fs::read_to_string(path).unwrap();
text.lines()
.find_map(|line| line.strip_prefix("id: "))
.map(str::trim)
.filter(|id| tix::id::is_issue_id(id))
.map(str::to_string)
.unwrap_or_else(|| panic!("issue {title:?} has no canonical id"))
}
fn history_of_title(dir: &Path, title: &str) -> Vec<String> {
let text = fs::read_to_string(issue_file_named(dir, title)).unwrap();
text.lines()
.skip_while(|l| *l != "## History")
.filter(|l| l.starts_with("- "))
.map(|l| {
let parts: Vec<&str> = l.trim_start_matches("- ").split(" \u{2014} ").collect();
format!(
"{} | {}",
parts.get(2).copied().unwrap_or("?"),
parts.get(3).copied().unwrap_or("?")
)
})
.collect()
}
fn issue_file_named(dir: &Path, title: &str) -> PathBuf {
let slug = title.to_lowercase().replace(' ', "-");
let issues = dir.join("docs").join("issues");
for child in issue_files_under(&issues) {
if child
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(&slug))
{
return child;
}
}
panic!("no issue file matching {title:?} under {issues:?}");
}
#[test]
fn t014_create_with_a_parent_writes_the_field_and_one_history_entry() {
let (_tmp, dir, epic) = hierarchy_repo(1);
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--parent", &epic.to_string()])
.assert()
.success();
assert_eq!(
parent_of_title(&dir, "Alpha"),
Some(id_of_title(&dir, "Epic")),
"the parent is stored as the parent's canonical id"
);
let events = history_of_title(&dir, "Alpha");
assert_eq!(events.len(), 1, "exactly one entry: {events:?}");
assert!(
events[0].starts_with("parent_set"),
"the kind must name the transition: {events:?}"
);
assert!(
events[0].contains(&format!("#{epic}")),
"the entry must name the parent: {events:?}"
);
}
#[test]
fn t014_edit_changes_then_clears_the_parent() {
let (_tmp, dir, epic) = hierarchy_repo(3);
let alpha = epic + 1;
let bravo = epic + 2;
tix()
.current_dir(&dir)
.args(["edit", &alpha.to_string(), "--parent", &epic.to_string()])
.assert()
.success();
let epic_id = id_of_title(&dir, "Epic");
let bravo_id = id_of_title(&dir, "Bravo");
assert_eq!(parent_of_title(&dir, "Alpha"), Some(epic_id));
tix()
.current_dir(&dir)
.args(["edit", &alpha.to_string(), "--parent", &bravo.to_string()])
.assert()
.success();
assert_eq!(parent_of_title(&dir, "Alpha"), Some(bravo_id));
let events = history_of_title(&dir, "Alpha");
assert_eq!(events.len(), 2, "one entry per accepted change: {events:?}");
assert!(events[0].starts_with("parent_set"), "{events:?}");
assert!(
events[1].starts_with("parent_changed"),
"a repoint is a change, not a second set: {events:?}"
);
tix()
.current_dir(&dir)
.args(["edit", &alpha.to_string(), "--parent", ""])
.assert()
.success();
assert_eq!(
parent_of_title(&dir, "Alpha"),
None,
"`--parent \"\"` clears"
);
let events = history_of_title(&dir, "Alpha");
assert_eq!(events.len(), 3, "{events:?}");
assert!(events[2].starts_with("parent_cleared"), "{events:?}");
}
#[test]
fn t014_parent_reports_the_hierarchy() {
let (_tmp, dir, epic) = hierarchy_repo(1);
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--parent", &epic.to_string()])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["create", "--title", "Bravo", "--parent", &epic.to_string()])
.assert()
.success();
let alpha = epic + 1;
let show = tix()
.current_dir(&dir)
.args(["parent", "show", &epic.to_string()])
.assert()
.success()
.get_output()
.stdout
.clone();
let show = String::from_utf8(show).unwrap();
assert!(show.contains("parent: none"), "{show}");
assert!(show.contains("children: 2"), "{show}");
assert!(show.contains("completed: 0/2"), "{show}");
let alpha_at = show.find(&format!("#{alpha} Alpha")).unwrap();
let bravo = alpha + 1;
let bravo_at = show.find(&format!("#{bravo} Bravo")).unwrap();
assert!(alpha_at < bravo_at, "children must be ordered:\n{show}");
let children = tix()
.current_dir(&dir)
.args(["parent", "children", &epic.to_string()])
.assert()
.success()
.get_output()
.stdout
.clone();
let children = String::from_utf8(children).unwrap();
assert!(children.contains("2 children"), "{children}");
let list = tix()
.current_dir(&dir)
.arg("parent")
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
let list = String::from_utf8(list).unwrap();
assert!(list.contains("Epic"), "{list}");
assert!(
!list.contains("Alpha") && !list.contains("Bravo"),
"leaves are not parents: {list}"
);
}
#[test]
fn t014_a_rejected_change_leaves_every_file_byte_identical() {
let (_tmp, dir, epic) = hierarchy_repo(3);
let alpha = epic + 1;
let bravo = epic + 2;
tix()
.current_dir(&dir)
.args(["edit", &alpha.to_string(), "--parent", &epic.to_string()])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["edit", &bravo.to_string(), "--parent", &alpha.to_string()])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
let alpha_s = alpha.to_string();
let bravo_s = bravo.to_string();
let epic_s = epic.to_string();
let cases: Vec<(Vec<&str>, &str)> = vec![
(
vec!["edit", alpha_s.as_str(), "--parent", alpha_s.as_str()],
"self_parent",
),
(vec!["edit", alpha_s.as_str(), "--parent", "99999"], "99999"),
(
vec!["edit", epic_s.as_str(), "--parent", bravo_s.as_str()],
"cycle",
),
];
for (args, expected) in cases {
tix()
.current_dir(&dir)
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains(expected));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a rejected change must leave every file byte-identical; failed for {args:?}"
);
}
}
#[test]
fn t014_create_with_a_bad_parent_creates_no_file() {
let (_tmp, dir, _) = hierarchy_repo(1);
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["create", "--title", "Orphan", "--parent", "99999"])
.assert()
.failure()
.stderr(predicate::str::contains("99999"));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a refused create must leave the directory byte-identical"
);
}
#[test]
fn t014_clearing_an_issue_with_no_parent_is_a_no_op() {
let (_tmp, dir, _) = hierarchy_repo(2);
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["edit", "1", "--parent", ""])
.assert()
.success();
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a no-op must not write, append, or move a date"
);
}
#[test]
fn t014_a_legacy_file_with_no_parent_key_is_accepted_and_left_byte_identical() {
let (_tmp, dir, _) = hierarchy_repo(1);
let legacy = dir
.join("docs")
.join("issues")
.join("unassigned")
.join("2026-01-01-legacy.md");
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
let contents = "---\nnumber: 90\ntitle: Legacy issue\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody text.\n";
fs::write(&legacy, contents).unwrap();
tix()
.current_dir(&dir)
.args(["parent", "show", "90"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["parent", "children", "90"])
.assert()
.success();
assert_eq!(
fs::read_to_string(&legacy).unwrap(),
contents,
"a legacy file must survive the hierarchy surfaces byte-identically"
);
tix()
.current_dir(&dir)
.args(["edit", "90", "--parent", ""])
.assert()
.success();
assert_eq!(fs::read_to_string(&legacy).unwrap(), contents);
}
fn workflow_repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"triage\", \"wip\", \"review\", \"shipped\", \"abandoned\"]\n\
done_column = \"shipped\"\n\n\
[kanban.default.category_map]\n\
triage = \"backlog\"\n\
wip = \"active\"\n\
review = \"active\"\n\
shipped = \"completed\"\n\
abandoned = \"canceled\"\n",
)
.unwrap();
for title in ["Alpha", "Bravo"] {
tix()
.current_dir(&dir)
.args(["create", "--title", title])
.assert()
.success();
}
(tmp, dir)
}
fn legacy_board_repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"todo\", \"in progress\", \"in review\", \"done\"]\n\
done_column = \"done\"\n",
)
.unwrap();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha"])
.assert()
.success();
(tmp, dir)
}
#[test]
fn t020_workflow_categories_prints_the_vocabulary_in_order() {
let (_tmp, dir) = legacy_board_repo();
let out = tix()
.current_dir(&dir)
.args(["workflow", "categories"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
for key in ["backlog", "unstarted", "active", "completed", "canceled"] {
assert!(text.contains(key), "`{key}` must be listed: {text}");
}
let order_line = text
.lines()
.find(|l| l.starts_with("Order:"))
.expect("the declared order must be printed");
let listed: Vec<&str> = order_line
.trim_start_matches("Order: ")
.split(" -> ")
.collect();
assert_eq!(
listed.first().copied(),
Some("backlog"),
"the order line is the adjacency order: {order_line}"
);
assert!(
listed.iter().position(|c| *c == "completed")
< listed.iter().position(|c| *c == "canceled"),
"and it matches the vocabulary: {order_line}"
);
let completed_row = text
.lines()
.find(|l| l.starts_with("completed"))
.expect("a row per category");
assert!(
completed_row.trim_end().ends_with("yes"),
"completed is terminal: {completed_row}"
);
let active_row = text
.lines()
.find(|l| l.starts_with("active "))
.expect("a row per category");
assert!(
!active_row.contains("yes"),
"active is not terminal: {active_row}"
);
}
#[test]
fn t020_workflow_show_reports_the_source_of_each_resolution() {
let (_tmp, dir) = workflow_repo();
let out = tix()
.current_dir(&dir)
.args(["workflow", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("Category policy: declared"),
"a board with a map has opted into policy: {text}"
);
for column in ["triage", "wip", "review", "shipped", "abandoned"] {
assert!(text.contains(column), "{column} must be listed: {text}");
}
assert!(
text.contains("category_map"),
"and its source must be named: {text}"
);
assert!(
!text.contains("safe default"),
"nothing on a fully-mapped board relied on the default: {text}"
);
let (_tmp2, dir2) = legacy_board_repo();
let out2 = tix()
.current_dir(&dir2)
.args(["workflow", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text2 = String::from_utf8(out2).unwrap();
assert!(
text2.contains("Category policy: not declared"),
"a board with no map has no transition policy: {text2}"
);
assert!(
text2.contains("safe default"),
"and the default is reported rather than silent: {text2}"
);
let done_row = text2
.lines()
.find(|l| l.starts_with("done"))
.expect("the completion column must be listed");
assert!(
done_row.contains("completed"),
"the done_column resolves to completed via the legacy rule: {done_row}"
);
assert!(
done_row.contains("safe default"),
"and via the default, which is why it is not listed as a finding: {done_row}"
);
}
#[test]
fn t020_workflow_show_on_an_unconfigured_board_is_not_an_error() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(dir.join("tix.toml"), "issues_dir = \"docs/issues\"\n").unwrap();
tix()
.current_dir(&dir)
.args(["workflow", "show"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["workflow", "categories"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["workflow", "transitions"])
.assert()
.success();
}
#[test]
fn t020_workflow_show_names_an_unknown_board_and_lists_the_real_ones() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["workflow", "show", "nope"])
.assert()
.failure()
.stderr(predicates::str::contains("nope"))
.stderr(predicates::str::contains("default"));
}
#[test]
fn t020_workflow_transitions_reports_the_effective_policy() {
let (_tmp, dir) = workflow_repo();
let text = String::from_utf8(
tix()
.current_dir(&dir)
.args(["workflow", "transitions"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(text.contains("Terminal lock: on"), "{text}");
assert!(text.contains("Applies: yes"), "{text}");
assert!(text.contains("Category adjacency: on"), "{text}");
assert!(
text.contains("--reason"),
"the escape hatch is spelled, not just described: {text}"
);
let (_tmp2, dir2) = legacy_board_repo();
let text2 = String::from_utf8(
tix()
.current_dir(&dir2)
.args(["workflow", "transitions"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(text2.contains("Terminal lock: on"), "{text2}");
assert!(
text2.contains("Applies: no"),
"on but inapplicable must not read as enforced: {text2}"
);
}
#[test]
fn t020_a_compatible_move_is_accepted_and_recorded() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success()
.stdout(predicates::str::contains("In progress"));
let events = history_of_title(&dir, "Alpha");
assert_eq!(
events,
vec!["transition_accepted | triage -> wip (backlog -> active)".to_string()],
"exactly one decision, naming both columns and both categories: {events:?}"
);
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(text.contains("status: wip"), "{text}");
}
#[test]
fn t020_an_unknown_column_is_rejected_and_writes_nothing() {
let (_tmp, dir) = workflow_repo();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "nonsense"])
.assert()
.failure()
.stderr(predicates::str::contains("unknown_column"))
.stderr(predicates::str::contains(
"valid columns: triage, wip, review, shipped, abandoned",
));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a rejected move must not touch a single byte"
);
}
#[test]
fn t020_a_backwards_move_is_rejected_and_writes_nothing() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "triage"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"))
.stderr(predicates::str::contains("category policy"));
assert_eq!(hierarchy_snapshot(&dir), before);
}
#[test]
fn t020_a_terminal_exit_is_refused_without_a_reason_and_writes_nothing() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"))
.stderr(predicates::str::contains("--reason"));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"and the recorded commit is still there, untouched"
);
}
#[test]
fn t020_a_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
tix()
.current_dir(&dir)
.args([
"move",
"1",
"wip",
"--reason",
"reopening: shipped by mistake",
])
.assert()
.success();
let events = history_of_title(&dir, "Alpha");
assert_eq!(events.len(), 2, "{events:?}");
assert!(
events[1].contains("override: reopening: shipped by mistake"),
"the reason is in the audit trail, or the override is unexplained: {events:?}"
);
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(
!text.contains("commit:"),
"leaving completion clears it: {text}"
);
}
#[test]
fn t020_a_blank_reason_is_not_a_reason() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "wip", "--reason", " "])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"));
assert_eq!(hierarchy_snapshot(&dir), before);
}
#[test]
fn t020_a_same_column_move_is_inert() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "triage"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "triage"])
.assert()
.success();
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a same-column move writes nothing: not the file, not a decision, not the date"
);
assert_eq!(
history_of_title(&dir, "Alpha"),
Vec::<String>::new(),
"and appends no decision"
);
}
#[test]
fn t020_edit_status_is_not_a_bypass_of_the_same_rules() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "wip"])
.assert()
.success();
assert_eq!(
history_of_title(&dir, "Alpha"),
vec!["transition_accepted | triage -> wip (backlog -> active)".to_string()],
"the same decision `tix mv` would have recorded"
);
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "triage"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"));
assert_eq!(hierarchy_snapshot(&dir), before);
}
#[test]
fn t020_edit_status_cannot_skip_the_override_requirement() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "shipped"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "wip"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"));
assert_eq!(hierarchy_snapshot(&dir), before);
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "wip", "--reason", "reopening"])
.assert()
.success();
let events = history_of_title(&dir, "Alpha");
assert!(
events.last().unwrap().contains("override: reopening"),
"{events:?}"
);
}
#[test]
fn t020_an_unrelated_edit_does_not_clear_the_status() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["edit", "1", "--title", "Renamed"])
.assert()
.success();
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(
text.contains("title: Renamed"),
"the title did change: {text}"
);
assert!(
text.contains("status: wip"),
"an unrelated edit must leave the column alone: {text}"
);
}
#[test]
fn t020_status_clearing_is_a_field_write_not_a_transition() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", ""])
.assert()
.success();
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(
text.contains("status: ''") || text.contains("status: \"\""),
"the field is cleared: {text}"
);
assert_eq!(
history_of_title(&dir, "Alpha"),
Vec::<String>::new(),
"and a clearing is not a transition, so it records no decision"
);
}
#[test]
fn t020_a_custom_completion_column_reports_the_same_completion_as_a_board_named_done() {
let (_tmp, mapped) = workflow_repo();
tix()
.current_dir(&mapped)
.args(["move", "1", "shipped", "--commit", "abc"])
.assert()
.success();
let (_tmp2, legacy) = legacy_board_repo();
tix()
.current_dir(&legacy)
.args(["move", "1", "done", "--commit", "abc"])
.assert()
.success();
let mapped_overdue = tix()
.current_dir(&mapped)
.args(["status", "--filter", "overdue"])
.assert()
.success()
.get_output()
.stdout
.clone();
let legacy_overdue = tix()
.current_dir(&legacy)
.args(["status", "--filter", "overdue"])
.assert()
.success()
.get_output()
.stdout
.clone();
let figure = |text: &str, label: &str| -> String {
text.lines()
.find(|l| l.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("no `{label}` row in:\n{text}"))
.split_whitespace()
.last()
.unwrap_or("")
.to_string()
};
let mapped_text = String::from_utf8(mapped_overdue).unwrap();
let legacy_text = String::from_utf8(legacy_overdue).unwrap();
assert_eq!(
figure(&mapped_text, "Total"),
figure(&legacy_text, "Total"),
"a custom completion column reports the same total"
);
assert_eq!(
figure(&mapped_text, "Overdue"),
figure(&legacy_text, "Overdue"),
"and the same overdue figure, which is the completion decision in use:\n{mapped_text}\n---\n{legacy_text}"
);
assert!(
!mapped_text.contains(" done"),
"reporting must not name a completion column: {mapped_text}"
);
}
#[test]
fn t020_a_canceled_issue_is_not_complete() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "2", "abandoned"])
.assert()
.success();
let text = String::from_utf8(
tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "category"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(text.contains("Canceled"), "the category is shown: {text}");
let overdue = |dir: &Path| -> String {
let out = tix()
.current_dir(dir)
.args(["status", "--filter", "overdue"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(out).unwrap();
text.lines()
.find(|l| l.trim_start().starts_with("Overdue"))
.unwrap_or_else(|| panic!("no Overdue row in:\n{text}"))
.split_whitespace()
.last()
.unwrap_or("")
.to_string()
};
tix()
.current_dir(&dir)
.args(["edit", "2", "--due-date", "2000-01-01"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["edit", "1", "--due-date", "2000-01-01"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc"])
.assert()
.success();
assert_eq!(
overdue(&dir),
"1",
"the canceled issue is still overdue and the completed one is not, so exactly \
one is counted: a terminal category is not a completion"
);
}
#[test]
fn t020_list_show_category_labels_match_the_service() {
let (_tmp, dir) = workflow_repo();
let text = String::from_utf8(
tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "category"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(text.contains("CATEGORY"), "the column is named: {text}");
assert!(text.contains("Backlog"), "`triage` is `backlog`: {text}");
let path = issue_file_named(&dir, "Alpha");
let text_on_disk = fs::read_to_string(&path).unwrap();
fs::write(
&path,
text_on_disk.replace("status: triage", "status: not-a-column"),
)
.unwrap();
let text2 = String::from_utf8(
tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "category"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
text2.contains("(unmapped)"),
"an unconfigured column is reported as such, not defaulted: {text2}"
);
}
#[test]
fn t020_a_legacy_board_gains_no_transition_policy() {
let (_tmp, dir) = legacy_board_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "done", "--commit", "abc"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "in progress"])
.assert()
.success();
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(
text.contains("status: in progress"),
"a board with no category_map has no policy to override: {text}"
);
}
#[test]
fn t020_workflow_show_for_a_board_with_no_completion_column_is_reported() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"a\", \"b\"]\n\
done_column = \"b\"\n\n\
[kanban.default.category_map]\n\
a = \"backlog\"\n\
b = \"active\"\n",
)
.unwrap();
tix()
.current_dir(&dir)
.args(["workflow", "show"])
.assert()
.failure()
.stderr(predicates::str::contains("missing_completion_category"))
.stderr(predicates::str::contains("maps no column to `completed`"))
.stderr(predicates::str::contains("config error"));
tix()
.current_dir(&dir)
.args(["list"])
.assert()
.failure()
.stderr(predicates::str::contains("missing_completion_category"));
}
#[test]
fn t020_the_commit_prompt_follows_the_service_not_the_done_column_name() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"todo\", \"shipped\", \"done\"]\n\
done_column = \"done\"\n\n\
[kanban.default.category_map]\n\
todo = \"unstarted\"\n\
shipped = \"completed\"\n\
done = \"active\"\n",
)
.unwrap();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
let text = fs::read_to_string(issue_file_named(&dir, "Alpha")).unwrap();
assert!(
text.contains("commit: abc1234"),
"the service's completion decision is what records the commit: {text}"
);
assert!(
text.contains("shipped -> shipped") || text.contains("todo -> shipped"),
"and the decision names the columns: {text}"
);
}
#[test]
fn t020_a_rejected_status_leaves_the_other_edit_fields_unwritten() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["edit", "1", "--title", "Renamed", "--status", "triage"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid_transition"));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a refused transition must not commit the fields edited alongside it"
);
}
#[test]
fn t020_list_overdue_uses_completion_rather_than_a_column_name() {
let (_tmp, dir) = workflow_repo();
for (selector, title) in [("1", "Alpha"), ("2", "Bravo")] {
tix()
.current_dir(&dir)
.args(["edit", selector, "--due-date", "2000-01-01"])
.assert()
.success();
let _ = title;
}
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc"])
.assert()
.success();
let out = String::from_utf8(
tix()
.current_dir(&dir)
.args(["list", "--columns", "num,title,overdue"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
let overdue_rows: Vec<&str> = out.lines().skip(1).filter(|l| l.contains("yes")).collect();
assert_eq!(
overdue_rows.len(),
1,
"exactly the incomplete issue is overdue, so completion is a category \\
decision and not a column comparison:\n{out}"
);
assert!(
overdue_rows[0].contains("Bravo"),
"and it is the one still in a non-completion column:\n{out}"
);
}
#[test]
fn t020_workflow_show_reports_the_non_fatal_diagnostics() {
let (_tmp, dir) = legacy_board_repo();
let text = String::from_utf8(
tix()
.current_dir(&dir)
.args(["workflow", "show"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
text.contains("unmapped_column_default"),
"a board relying on the safe default is reported by name:\n{text}"
);
assert!(
text.contains("Diagnostics:"),
"under a heading, so it is not mistaken for part of the table:\n{text}"
);
assert_eq!(
text.matches("unmapped_column_default").count(),
3,
"the completion column is not a finding:\n{text}"
);
}
fn split_completion_repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"todo\", \"shipped\", \"done\"]\n\
done_column = \"done\"\n\n\
[kanban.default.category_map]\n\
todo = \"unstarted\"\n\
shipped = \"completed\"\n\
done = \"active\"\n",
)
.unwrap();
for title in ["Alpha", "Bravo"] {
tix()
.current_dir(&dir)
.args(["create", "--title", title])
.assert()
.success();
}
(tmp, dir)
}
#[test]
fn t020_reaching_completion_without_a_commit_is_refused() {
let (_tmp, dir) = split_completion_repo();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["move", "1", "shipped"])
.assert()
.failure()
.stderr(predicates::str::contains("requires a commit hash"));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"`shipped` is the service's completion column, so the prompt fires and the \\
move is refused for want of a hash"
);
tix()
.current_dir(&dir)
.args(["move", "1", "done"])
.assert()
.success();
}
#[test]
fn t020_overdue_uses_completion_on_a_board_whose_done_column_is_not_complete() {
let (_tmp, dir) = split_completion_repo();
for selector in ["1", "2"] {
tix()
.current_dir(&dir)
.args(["edit", selector, "--due-date", "2000-01-01"])
.assert()
.success();
}
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc"])
.assert()
.success();
let out = String::from_utf8(
tix()
.current_dir(&dir)
.args(["status", "--filter", "overdue"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
let overdue = out
.lines()
.find(|l| l.trim_start().starts_with("Overdue"))
.unwrap_or_else(|| panic!("no Overdue row in:\n{out}"))
.split_whitespace()
.last()
.unwrap_or("")
.to_string();
assert_eq!(
overdue, "1",
"only the issue outside the completion category is overdue:\n{out}"
);
}
#[test]
fn t025_create_rejects_a_column_the_board_does_not_configure() {
let (_tmp, dir) = workflow_repo();
let before = hierarchy_snapshot(&dir);
tix()
.current_dir(&dir)
.args(["create", "--title", "Nowhere", "--status", "nonsense"])
.assert()
.failure()
.stderr(predicates::str::contains("unknown_column"))
.stderr(predicates::str::contains(
"valid columns: triage, wip, review, shipped, abandoned",
));
assert_eq!(
hierarchy_snapshot(&dir),
before,
"a refused create writes nothing"
);
tix()
.current_dir(&dir)
.args(["create", "--title", "Dropped", "--status", "abandoned"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["create", "--title", "Finished", "--status", "shipped"])
.assert()
.success();
}
#[test]
fn t025_a_created_issue_reports_its_category() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Dropped", "--status", "abandoned"])
.assert()
.success();
let out = String::from_utf8(
tix()
.current_dir(&dir)
.args(["list", "--all", "--show", "category"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
out.contains("Canceled"),
"and `canceled` is not complete: {out}"
);
assert!(
out.contains("Backlog"),
"and the untouched issues are still `backlog`: {out}"
);
}
fn list_row(dir: &std::path::Path, number: u32) -> String {
let out = tix()
.current_dir(dir)
.args(["list", "--all", "--show", "category"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(text.contains("NUM"), "unexpected table header in:\n{text}");
text.lines()
.find(|l| {
l.split_whitespace()
.next()
.is_some_and(|first| first == number.to_string())
})
.unwrap_or_else(|| panic!("no row for #{number} in:\n{text}"))
.trim_end()
.to_string()
}
fn last_cell(row: &str) -> &str {
row.rsplit_once(" ")
.map(|(_, cell)| cell.trim())
.expect("a row with at least two cells")
}
#[track_caller]
fn assert_row_cell_is(row: &str, cell: &str, context: &str) {
assert_eq!(
last_cell(row),
cell,
"{context}: the row's trailing cell\n row: {row:?}"
);
}
fn show_category_line(dir: &std::path::Path, selector: &str) -> String {
let out = tix()
.current_dir(dir)
.args(["show", selector])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
text.contains("## Workflow"),
"no workflow block in:\n{text}"
);
text.lines()
.find(|l| l.starts_with("- category: "))
.unwrap_or_else(|| panic!("no category line in:\n{text}"))
.trim_start_matches("- category: ")
.to_string()
}
#[test]
fn t037_the_category_label_is_identical_on_every_path() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "2", "wip"])
.assert()
.success();
let mut config = tix::config::Config::load(&dir).unwrap();
config.issues_dir = dir.join("docs").join("issues");
let store = tix::storage::Store::open(&config).unwrap();
assert_eq!(
store.list().unwrap().len(),
2,
"the fixture's two issues, not this repository's"
);
for (number, key, label) in [(1u32, "completed", "Done"), (2u32, "active", "In progress")] {
let shown = show_category_line(&dir, &number.to_string());
assert_eq!(
shown,
format!("{key} ({label})"),
"`tix show` for #{number}"
);
assert_eq!(
last_cell(&list_row(&dir, number)),
label,
"`tix list --show category` for #{number}"
);
let (_, resolution) = tix::workflow::active_resolution(&config);
let issue = store
.get(tix::storage::IssueSelector::Number(number))
.unwrap();
let from_service = tix::workflow::category_of(&issue, &resolution).unwrap_or_else(|| {
panic!(
"no category for #{number}: status {:?}, board {:?}, columns {:?}",
issue.status.display(),
resolution.board,
resolution.columns().collect::<Vec<_>>()
)
});
assert_eq!(
from_service.label(),
label,
"the service's `label()` for #{number}"
);
assert_eq!(from_service.key(), key, "and its key for #{number}");
}
}
#[test]
fn t037_a_canceled_issue_is_never_reported_complete_on_any_path() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "abandoned", "--yes"])
.assert()
.success();
assert_eq!(
show_category_line(&dir, "1"),
"canceled (Canceled)",
"`tix show` must not read a canceled issue as done"
);
assert_row_cell_is(&list_row(&dir, 1), "Canceled", "a canceled row");
let out = tix().current_dir(&dir).args(["status"]).output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
!text.contains("1/1 done") && !text.contains("1/1 complete"),
"the board summary must not count a canceled issue as complete:\n{text}"
);
}
#[test]
fn t037_an_unmapped_column_reports_the_safe_default_label_on_all_three_paths() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["edit", "1", "--status", "review"])
.assert()
.success();
let toml = dir.join("tix.toml");
let stripped = fs::read_to_string(&toml)
.unwrap()
.replace("review = \"active\"\n", "");
fs::write(&toml, stripped).unwrap();
assert_eq!(
show_category_line(&dir, "1"),
"unstarted (Not started)",
"a column with no mapping resolves to the safe default"
);
assert_row_cell_is(
&list_row(&dir, 1),
"Not started",
"a column with no mapping",
);
}
#[test]
fn t037_two_boards_with_different_columns_report_the_same_category_and_completion() {
let (tmp_a, dir_a) = workflow_repo();
let _tmp_b = tmp_a;
let tmp_b = TempDir::new().unwrap();
let dir_b = tmp_b.path().to_path_buf();
fs::create_dir_all(dir_b.join("docs").join("issues")).unwrap();
fs::write(
dir_b.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"icebox\", \"doing\", \"verifying\", \"released\", \"dropped\"]\n\
done_column = \"released\"\n\n\
[kanban.default.category_map]\n\
icebox = \"backlog\"\n\
doing = \"active\"\n\
verifying = \"active\"\n\
released = \"completed\"\n\
dropped = \"canceled\"\n",
)
.unwrap();
tix()
.current_dir(&dir_b)
.args(["create", "--title", "Alpha"])
.assert()
.success();
for (dir, column, other) in [(&dir_a, "shipped", "wip"), (&dir_b, "released", "doing")] {
tix()
.current_dir(dir)
.args(["move", "1", column, "--commit", "abc1234"])
.assert()
.success();
tix()
.current_dir(dir)
.args([
"move",
"1",
other,
"--reason",
"reopening for the comparison",
])
.assert()
.success();
}
let a = show_category_line(&dir_a, "1");
let b = show_category_line(&dir_b, "1");
assert_eq!(a, b, "two boards, equal mappings, different column names");
assert_eq!(a, "active (In progress)");
assert_row_cell_is(
&list_row(&dir_b, 1),
last_cell(&list_row(&dir_a, 1)),
"the two boards' category cells",
);
}
#[test]
fn t037_an_unknown_category_is_an_error_from_every_surface_that_filters() {
let (_tmp, dir) = workflow_repo();
for args in [
vec!["list", "--filter", "category:finished"],
vec!["search", "category:finished"],
vec!["status", "--filter", "category:finished"],
] {
let out = tix().current_dir(&dir).args(&args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"{args:?} must fail on an unknown category"
);
assert!(
stderr.contains("unknown category 'finished'"),
"{args:?} stderr: {stderr}"
);
}
}
#[test]
fn t037_a_valid_category_filter_works_from_every_surface_that_filters() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
let listed = tix()
.current_dir(&dir)
.args(["list", "--all", "--filter", "category:completed"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&listed.stdout).to_string();
assert!(text.contains("Alpha"), "{text}");
assert!(!text.contains("Bravo"), "{text}");
let found = tix()
.current_dir(&dir)
.args(["search", "category:completed"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&found.stdout).to_string();
assert!(text.contains("Alpha"), "{text}");
assert!(
!text.contains("Bravo"),
"and nothing outside the category:\n{text}"
);
assert!(
text.contains("status: shipped"),
"the matched-lines context must show the derivation: {text}"
);
let status = tix()
.current_dir(&dir)
.args(["status", "--filter", "category:completed"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&status.stdout).to_string();
assert!(
text.contains("shipped") && text.contains("Total 1"),
"the summary counts the filtered set:\n{text}"
);
assert!(
!text.contains("Total 2"),
"and only the filtered set:\n{text}"
);
let empty = tix()
.current_dir(&dir)
.args(["list", "--filter", "category:canceled"])
.output()
.unwrap();
assert!(
empty.status.success(),
"a valid category with no members is an empty board, not an error"
);
}
#[test]
fn t037_every_documented_filter_token_actually_filters() {
let (_tmp, dir) = workflow_repo();
tix()
.current_dir(&dir)
.args(["move", "1", "shipped", "--commit", "abc1234"])
.assert()
.success();
for (token, hits, misses) in [
("status:shipped", "Alpha", "Bravo"),
("priority:medium", "Alpha", "nobody"),
("assignee:Herbert", "Alpha", "nobody"),
("label:urgent", "nobody", "nobody"),
("cycle:One", "nobody", "nobody"),
] {
let out = tix()
.current_dir(&dir)
.args(["list", "--all", "--filter", token])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "{token}: must not be an error");
if hits != "nobody" {
assert!(text.contains(hits), "{token}: should match {hits}\n{text}");
}
if misses != "nobody" {
assert!(
!text.contains(misses),
"{token}: should not match {misses}\n{text}"
);
}
}
let none = tix()
.current_dir(&dir)
.args(["list", "--filter", "status:no-such-column"])
.output()
.unwrap();
assert!(none.status.success());
assert!(
!String::from_utf8_lossy(&none.stdout).contains("Alpha"),
"a filter with no matches is an empty board"
);
}
fn created_issue(dir: &Path) -> (PathBuf, PathBuf) {
let file = issue_file(dir);
let name = file.file_name().unwrap().to_string_lossy().to_string();
let archived = file.parent().unwrap().join(format!(".{name}"));
(file, archived)
}
#[test]
fn archive_round_trip_hides_resolves_and_restores() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Only issue"])
.assert()
.success();
let (file, archived) = created_issue(dir);
tix()
.current_dir(dir)
.args(["archive", "1"])
.assert()
.success();
assert!(archived.is_file(), "renamed to {}", archived.display());
assert!(!file.exists(), "the active name is gone");
tix()
.current_dir(dir)
.args(["list", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue").not());
tix()
.current_dir(dir)
.args(["list", "--all", "--with-archived"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"))
.stdout(predicate::str::contains("ARCHIVED"));
tix()
.current_dir(dir)
.args(["show", "1"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"));
tix()
.current_dir(dir)
.args(["show", "Only issue"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"));
tix()
.current_dir(dir)
.args([
"list",
"--all",
"--with-archived",
"--filter",
"archived:true",
])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"));
tix()
.current_dir(dir)
.args([
"list",
"--all",
"--with-archived",
"--filter",
"archived:false",
])
.assert()
.success()
.stdout(predicate::str::contains("Only issue").not());
tix()
.current_dir(dir)
.args(["search", "Only"])
.assert()
.success()
.stdout(predicate::str::contains("No issues found."));
tix()
.current_dir(dir)
.args(["search", "--with-archived", "Only"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"));
tix()
.current_dir(dir)
.args(["restore", "1"])
.assert()
.success();
assert!(file.is_file());
assert!(!archived.exists());
tix()
.current_dir(dir)
.args(["list", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("Only issue"));
}
#[test]
fn redundant_archive_and_restore_are_refused_with_named_diagnostics() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Only issue"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["restore", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("not_archived"));
tix()
.current_dir(dir)
.args(["archive", "1"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["archive", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("already_archived"));
}
#[test]
fn a_failing_selector_does_not_roll_back_a_successful_one() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Real"])
.assert()
.success();
let (file, archived) = created_issue(dir);
tix()
.current_dir(dir)
.args(["archive", "9999", "1"])
.assert()
.failure();
assert!(
archived.is_file(),
"the valid item is archived despite the missing one"
);
assert!(!file.exists());
}
#[test]
fn new_subcommand_opens_the_create_form_in_the_tui() {
let out = tix().args(["new", "--help"]).output().unwrap();
assert!(out.status.success(), "tix new --help must succeed");
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("create-issue form"),
"the help names the form it opens: {text}"
);
let empty = TempDir::new().unwrap();
tix()
.current_dir(empty.path())
.arg("new")
.assert()
.failure()
.stderr(predicate::str::contains("tix.toml"));
}
#[test]
fn init_writes_a_working_triage_inbox() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let config = fs::read_to_string(dir.join("tix.toml")).unwrap();
for needle in [
"[triage]",
"entry_status = \"triage\"",
"accepted_status = \"todo\"",
"declined_status = \"wontfix\"",
] {
assert!(
config.contains(needle),
"starter config lacks {needle:?}:\n{config}"
);
}
let board = fs::read_to_string(dir.join("docs/boards/default.md")).unwrap();
for needle in ["- triage", "- wontfix", "wontfix: canceled"] {
assert!(
board.contains(needle),
"starter board lacks {needle:?}:\n{board}"
);
}
tix()
.current_dir(dir)
.args(["create", "--title", "Something broke"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["triage", "list"])
.assert()
.success()
.stdout(predicate::str::contains("Something broke"));
tix()
.current_dir(dir)
.args(["triage", "accept", "1"])
.assert()
.success();
let accepted = fs::read_to_string(issue_file(dir)).unwrap();
assert!(accepted.contains("status: todo"), "{accepted}");
assert!(accepted.contains("triage_accepted"), "{accepted}");
tix()
.current_dir(dir)
.args(["create", "--title", "Not a bug"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["triage", "decline", "2", "--reason", "works as intended"])
.assert()
.success();
let declined = find_issue_text(dir, "Not a bug").expect("the declined issue");
assert!(declined.contains("status: wontfix"), "{declined}");
assert!(declined.contains("works as intended"), "{declined}");
}
fn find_issue_text(dir: &std::path::Path, title: &str) -> Option<String> {
let mut stack = vec![dir.join("docs").join("issues")];
while let Some(next) = stack.pop() {
for entry in fs::read_dir(&next).ok()?.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(text) = fs::read_to_string(&path)
&& text.contains(&format!("title: {title}"))
{
return Some(text);
}
}
}
None
}
#[test]
fn related_issues_round_trip_edit_and_lint() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Alpha",
"--related-issues",
"42,./missing.md,gitlab.com/foo/bar#7",
])
.assert()
.success();
let file = issue_file(dir);
let text = fs::read_to_string(&file).unwrap();
assert!(text.contains("related_issues:"), "{text}");
for value in ["42", "./missing.md", "gitlab.com/foo/bar#7"] {
assert!(text.contains(value), "stored verbatim: {value}\n{text}");
}
tix()
.current_dir(dir)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("related_issue_unresolved"))
.stderr(predicate::str::contains("related path does not exist"))
.stderr(predicate::str::contains("not linted (remote)"));
tix()
.current_dir(dir)
.args(["edit", "1", "--related-issues", "99"])
.assert()
.success();
let text = fs::read_to_string(issue_file(dir)).unwrap();
assert!(text.contains("related_issues:"), "{text}");
assert!(
text.contains("99") && !text.contains("gitlab.com/foo/bar"),
"{text}"
);
assert!(
!text.contains("related_issue_unresolved") || text.contains("99"),
"the field was replaced, not appended"
);
tix()
.current_dir(dir)
.args(["edit", "1", "--related-issues", ""])
.assert()
.success();
let text = fs::read_to_string(issue_file(dir)).unwrap();
assert!(!text.contains("related_issues"), "cleared:\n{text}");
}
#[test]
fn lint_foreign_repositories_merges_and_no_foreign_suppresses() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let a = root.join("a");
let b = root.join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
for repo in [&a, &b] {
tix().current_dir(repo).arg("init").assert().success();
}
tix()
.current_dir(&b)
.args(["create", "--title", "Foreign"])
.assert()
.success();
let b_issues = b.join("docs").join("issues");
tix()
.current_dir(&a)
.args([
"create",
"--title",
"Alpha",
"--related-issues",
b_issues.to_str().unwrap(),
])
.assert()
.success();
tix()
.current_dir(&a)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("[b]"));
tix()
.current_dir(&a)
.args(["lint", "--no-foreign"])
.assert()
.success()
.stderr(predicate::str::contains("[b]").not());
}
#[test]
fn type_is_written_checked_and_cleared() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let cfg = fs::read_to_string(dir.join("tix.toml")).unwrap();
assert!(cfg.contains("types = ["), "{cfg}");
assert!(cfg.contains("\"bug\""), "{cfg}");
tix()
.current_dir(dir)
.args(["create", "--title", "Alpha", "--type", "bug"])
.assert()
.success();
tix()
.current_dir(dir)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("unknown_type").not());
let text = fs::read_to_string(issue_file(dir)).unwrap();
assert!(text.contains("type: bug"), "{text}");
tix()
.current_dir(dir)
.args(["edit", "1", "--type", "story"])
.assert()
.success();
let moved = issue_file(dir);
assert!(
moved.to_string_lossy().contains("/story/"),
"relocated under the type directory: {moved:?}"
);
tix()
.current_dir(dir)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("unknown issue type `story`"))
.stderr(predicate::str::contains("`types` in tix.toml"));
tix()
.current_dir(dir)
.args(["edit", "1", "--type", ""])
.assert()
.success();
let cleared = fs::read_to_string(issue_file(dir)).unwrap();
assert!(!cleared.contains("type:"), "{cleared}");
assert!(
issue_file(dir).to_string_lossy().contains("/default/"),
"falls back to the default directory"
);
}
#[test]
fn cli_status_changes_relocate_the_file_to_the_status_directory() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
tix()
.current_dir(dir)
.args(["create", "--title", "Alpha"])
.assert()
.success();
let before = issue_file_named(dir, "Alpha");
assert!(before.to_string_lossy().contains("/triage/"), "{before:?}");
tix()
.current_dir(dir)
.args(["move", "1", "in progress"])
.assert()
.success();
let after = issue_file_named(dir, "Alpha");
assert!(
after.to_string_lossy().contains("/in-progress/"),
"{after:?}"
);
assert!(!before.exists(), "the old path is gone");
tix()
.current_dir(dir)
.args(["create", "--title", "Beta"])
.assert()
.success();
let triaged = issue_file_named(dir, "Beta");
assert!(
triaged.to_string_lossy().contains("/triage/"),
"{triaged:?}"
);
tix()
.current_dir(dir)
.args(["triage", "accept", "2"])
.assert()
.success();
let accepted = issue_file_named(dir, "Beta");
assert!(
accepted.to_string_lossy().contains("/todo/"),
"{accepted:?}"
);
assert!(!triaged.exists(), "the old triage path is gone");
}
fn registry_bytes(dir: &Path) -> String {
fs::read_to_string(dir.join("tix.toml")).expect("tix.toml")
}
fn repo_snapshot(dir: &Path) -> (String, Vec<(String, Vec<u8>)>) {
(registry_bytes(dir), issue_snapshot(dir))
}
fn project_create(dir: &Path, name: &str) -> String {
let stdout = tix()
.current_dir(dir)
.args(["project", "create", name])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
let open = stdout.rfind('(').expect("a parenthesised id");
let close = stdout.rfind(')').expect("a closing parenthesis");
stdout[open + 1..close].to_string()
}
fn project_rows(dir: &Path) -> Vec<(String, String, usize)> {
let stdout = tix()
.current_dir(dir)
.args(["project", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(stdout)
.unwrap()
.lines()
.map(|line| {
let mut fields = line.split('\t');
let id = fields.next().expect("id column").to_string();
let name = fields.next().expect("name column").to_string();
let count: usize = fields
.next()
.expect("count column")
.parse()
.expect("a count");
assert!(fields.next().is_none(), "exactly three columns: {line:?}");
(id, name, count)
})
.collect()
}
fn repo_with_numbered_issues(count: usize) -> (TempDir, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
tix().current_dir(&dir).arg("init").assert().success();
for i in 1..=count {
tix()
.current_dir(&dir)
.args(["create", "--title", &format!("Issue {i}")])
.assert()
.success();
}
(tmp, dir)
}
fn recorded_projects(dir: &Path, title: &str) -> Vec<String> {
let contents = fs::read_to_string(issue_file_named(dir, title)).unwrap();
let mut out = Vec::new();
let mut in_list = false;
for line in contents.lines() {
if line == "projects:" {
in_list = true;
continue;
}
if in_list {
match line.strip_prefix("- ") {
Some(id) => out.push(id.trim().to_string()),
None => break,
}
}
}
out
}
fn association_events(dir: &Path, title: &str) -> usize {
let contents = fs::read_to_string(issue_file_named(dir, title)).unwrap();
contents
.lines()
.filter(|line| line.contains("project_associated") || line.contains("project_dissociated"))
.count()
}
#[test]
fn cli_project_lifecycle_keeps_the_id_across_a_rename_and_an_edit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let platform = project_create(dir, "Platform");
let infra = project_create(dir, "Infra");
assert_ne!(platform, infra, "each create allocates its own id");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--projects", &platform])
.assert()
.success();
tix()
.current_dir(dir)
.args(["project", "edit", &platform, "--name", "Platform hardening"])
.assert()
.success();
let rows = project_rows(dir);
assert!(
rows.iter().any(|(id, name, count)| id == &platform
&& name == "Platform hardening"
&& *count == 1),
"the id survives the rename with its member count: {rows:?}"
);
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![platform.clone()],
"the issue still references the id after the rename"
);
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["project", "edit", &infra, "--name", "Platform hardening"])
.assert()
.failure()
.stderr(predicate::str::contains("duplicate_name"));
assert_eq!(
registry_bytes(dir),
before,
"a refused rename writes nothing"
);
}
#[test]
fn cli_project_listing_shows_the_id_and_the_name_and_a_member_count() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let platform = project_create(dir, "Platform");
let rows = project_rows(dir);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, platform, "the id is a column of its own (C-002)");
assert_eq!(rows[0].1, "Platform");
assert_eq!(rows[0].2, 0, "a project with no issues has no members");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--projects", &platform])
.assert()
.success();
assert_eq!(project_rows(dir)[0].2, 1, "the member count is derived");
}
#[test]
fn cli_project_show_prints_the_record_and_the_members_with_ids() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let platform = project_create(dir, "Platform");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--projects", &platform])
.assert()
.success();
let stdout = tix()
.current_dir(dir)
.args(["project", "show", &platform])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
assert!(stdout.contains(&format!("id: {platform}")), "{stdout}");
assert!(stdout.contains("name: Platform"), "{stdout}");
assert!(stdout.contains("members: 1"), "{stdout}");
assert!(
stdout.contains("#1") && stdout.contains("Fix login"),
"a member is named by number and title: {stdout}"
);
}
#[test]
fn cli_project_association_create_add_remove_clear_tracks_frontmatter_and_history() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let platform = project_create(dir, "Platform");
let infra = project_create(dir, "Infra");
let data = project_create(dir, "Data");
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Fix login",
"--projects",
&format!("{platform},{infra}"),
])
.assert()
.success();
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![platform.clone(), infra.clone()]
);
assert_eq!(association_events(dir, "Fix login"), 2);
tix()
.current_dir(dir)
.args(["edit", "1", "--add-project", &data])
.assert()
.success();
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![platform.clone(), infra.clone(), data.clone()]
);
assert_eq!(association_events(dir, "Fix login"), 3);
let before = fs::read(issue_file_named(dir, "Fix login")).unwrap();
let stdout = tix()
.current_dir(dir)
.args(["edit", "1", "--add-project", &data])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
assert!(
stdout.contains("(no association change)"),
"a no-op edit does not claim an update: {stdout}"
);
assert_eq!(
fs::read(issue_file_named(dir, "Fix login")).unwrap(),
before,
"a no-op add writes nothing"
);
assert_eq!(association_events(dir, "Fix login"), 3);
tix()
.current_dir(dir)
.args(["edit", "1", "--remove-project", &infra])
.assert()
.success();
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![platform.clone(), data.clone()]
);
assert!(
issue_file_named(dir, "Fix login").is_file(),
"the issue is kept"
);
tix()
.current_dir(dir)
.args(["edit", "1", "--projects", ""])
.assert()
.success();
assert!(recorded_projects(dir, "Fix login").is_empty());
assert_eq!(association_events(dir, "Fix login"), 6);
assert!(issue_file_named(dir, "Fix login").is_file());
}
#[test]
fn cli_project_bulk_add_reports_partial_success_and_writes_the_good_ones() {
let (tmp, dir) = repo_with_numbered_issues(3);
let _tmp = tmp;
let platform = project_create(&dir, "Platform");
tix()
.current_dir(&dir)
.args(["project", "bulk-add", &platform, "1", "99", "2"])
.assert()
.failure()
.stdout(predicate::str::contains("associated 2 of 3"))
.stderr(predicate::str::contains("issue #99"));
assert_eq!(recorded_projects(&dir, "Issue 1"), vec![platform.clone()]);
assert_eq!(recorded_projects(&dir, "Issue 2"), vec![platform.clone()]);
assert!(recorded_projects(&dir, "Issue 3").is_empty());
tix()
.current_dir(&dir)
.args(["project", "bulk-add", &platform, "3"])
.assert()
.success()
.stdout(predicate::str::contains("associated 1 of 1"));
assert_eq!(recorded_projects(&dir, "Issue 3"), vec![platform.clone()]);
tix()
.current_dir(&dir)
.args(["project", "add", &platform, "1", "2"])
.assert()
.success()
.stdout(predicate::str::contains("(2 unchanged)"));
}
#[test]
fn cli_project_unknown_id_is_refused_with_a_named_diagnostic_and_no_write() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let _platform = project_create(dir, "Platform");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login"])
.assert()
.success();
let before = repo_snapshot(dir);
for args in [
vec!["project", "show", "bogus"],
vec!["project", "edit", "bogus", "--name", "X"],
vec!["project", "delete", "bogus", "--yes"],
vec!["project", "add", "bogus", "1"],
vec!["edit", "1", "--add-project", "bogus"],
vec!["edit", "1", "--remove-project", "bogus"],
] {
tix()
.current_dir(dir)
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("unknown_project"));
}
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Half associated",
"--projects",
"bogus",
])
.assert()
.failure()
.stderr(predicate::str::contains("unknown_project"));
assert_eq!(
repo_snapshot(dir),
before,
"a rejected project operation changes nothing on disk"
);
}
#[test]
fn cli_project_duplicate_name_and_invalid_dates_are_refused_with_no_write() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let _platform = project_create(dir, "Platform");
let before = repo_snapshot(dir);
tix()
.current_dir(dir)
.args(["project", "create", "platform"])
.assert()
.failure()
.stderr(predicate::str::contains("duplicate_name"));
tix()
.current_dir(dir)
.args([
"project",
"create",
"Bad dates",
"--start-date",
"2026-05-01",
"--due-date",
"2026-01-01",
])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_dates"));
tix()
.current_dir(dir)
.args(["project", "create", "Bad shape", "--due-date", "not-a-date"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_dates"));
assert_eq!(
repo_snapshot(dir),
before,
"a rejected project write changes nothing on disk"
);
}
#[test]
fn cli_project_delete_retains_the_issues_and_only_removes_the_registry_entry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let platform = project_create(dir, "Platform");
let infra = project_create(dir, "Infra");
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Fix login",
"--projects",
&format!("{platform},{infra}"),
])
.assert()
.success();
let issue_before = fs::read(issue_file_named(dir, "Fix login")).unwrap();
tix()
.current_dir(dir)
.args(["project", "delete", &platform, "--yes"])
.assert()
.success()
.stderr(predicate::str::contains("Fix login").and(predicate::str::contains("not deleted")));
let registry = registry_bytes(dir);
assert!(!registry.contains(&platform), "the entry is removed");
assert!(registry.contains(&infra), "the sibling project survives");
assert_eq!(
fs::read(issue_file_named(dir, "Fix login")).unwrap(),
issue_before,
"deleting a project must not touch an issue"
);
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![platform.clone(), infra.clone()],
"the dangling reference is retained, not dropped (C-005)"
);
tix()
.current_dir(dir)
.args(["edit", "1", "--remove-project", &platform])
.assert()
.success();
assert_eq!(
recorded_projects(dir, "Fix login"),
vec![infra.clone()],
"the stale reference is removable"
);
}
#[test]
fn cli_edit_projects_clear_is_the_only_spelling_that_clears() {
let (tmp, dir) = repo_with_numbered_issues(1);
let _tmp = tmp;
let platform = project_create(&dir, "Platform");
tix()
.current_dir(&dir)
.args(["edit", "1", "--add-project", &platform])
.assert()
.success();
assert_eq!(recorded_projects(&dir, "Issue 1"), vec![platform.clone()]);
let before = fs::read(issue_file_named(&dir, "Issue 1")).unwrap();
tix()
.current_dir(&dir)
.args(["edit", "1", "--projects", &platform])
.assert()
.failure()
.stderr(predicate::str::contains("--projects"));
assert_eq!(fs::read(issue_file_named(&dir, "Issue 1")).unwrap(), before);
let stdout = tix()
.current_dir(&dir)
.args(["edit", "1", "--projects", ""])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8(stdout).unwrap().starts_with("Updated "),
"a clearing that changed something is an update"
);
assert!(recorded_projects(&dir, "Issue 1").is_empty());
let before = fs::read(issue_file_named(&dir, "Issue 1")).unwrap();
let stdout = tix()
.current_dir(&dir)
.args(["edit", "1", "--projects", ""])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8(stdout)
.unwrap()
.contains("(no association change)"),
"a clearing that changed nothing does not claim an update"
);
assert_eq!(fs::read(issue_file_named(&dir, "Issue 1")).unwrap(), before);
}
#[test]
fn cli_project_surface_has_no_template_or_general_bulk_machinery() {
let stdout = tix()
.args(["project", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
let mut commands: Vec<String> = Vec::new();
let mut in_commands = false;
for line in stdout.lines() {
if line == "Commands:" {
in_commands = true;
continue;
}
if in_commands {
if line.trim().is_empty() {
break;
}
if let Some(verb) = line.split_whitespace().next() {
commands.push(verb.to_string());
}
}
}
assert_eq!(
commands,
vec![
"create", "edit", "list", "show", "delete", "add", "remove", "bulk-add", "help"
],
"the surface is exactly the contract verbs: {stdout}"
);
assert!(
!stdout.contains("template"),
"no template machinery is built here: {stdout}"
);
let delete_help = tix()
.args(["project", "delete", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let delete_help = String::from_utf8(delete_help).unwrap();
assert!(
!delete_help.contains("--cascade"),
"no cascade flag: {delete_help}"
);
let edit_help = tix()
.args(["edit", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let edit_help = String::from_utf8(edit_help).unwrap();
assert!(
edit_help.contains("--add-project")
&& edit_help.contains("--remove-project")
&& edit_help.contains("clear"),
"the association flags and the clearing convention are documented: {edit_help}"
);
let remove_help = tix()
.args(["project", "remove", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let remove_help = String::from_utf8(remove_help).unwrap();
assert!(
remove_help.contains("verbatim")
&& remove_help.contains("0 changed")
&& remove_help.contains("unknown_project"),
"the remove divergence is documented: {remove_help}"
);
let bulk_help = tix()
.args(["project", "bulk-add", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let bulk_help = String::from_utf8(bulk_help).unwrap();
assert!(
bulk_help.contains("unknown_project") && bulk_help.contains("0 changed"),
"the bulk-add divergence is documented: {bulk_help}"
);
}
fn epic_create(dir: &Path, name: &str) -> String {
let stdout = tix()
.current_dir(dir)
.args(["epic", "create", name])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
let open = stdout.rfind('(').expect("a parenthesised id");
let close = stdout.rfind(')').expect("a closing parenthesis");
stdout[open + 1..close].to_string()
}
fn epic_rows(dir: &Path) -> Vec<(String, String, usize, usize)> {
let stdout = tix()
.current_dir(dir)
.args(["epic", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(stdout)
.unwrap()
.lines()
.map(|line| {
let mut f = line.split('\t');
let id = f.next().expect("id").to_string();
let name = f.next().expect("name").to_string();
let members: usize = f.next().expect("members").parse().expect("a count");
let children: usize = f.next().expect("children").parse().expect("a count");
assert!(f.next().is_none(), "exactly four columns: {line:?}");
(id, name, members, children)
})
.collect()
}
fn recorded_epics(dir: &Path, title: &str) -> Vec<String> {
let contents = fs::read_to_string(issue_file_named(dir, title)).unwrap();
let mut out = Vec::new();
let mut in_list = false;
for line in contents.lines() {
if line == "epics:" {
in_list = true;
continue;
}
if in_list {
match line.strip_prefix("- ") {
Some(id) => out.push(id.trim().to_string()),
None => break,
}
}
}
out
}
fn epic_events(dir: &Path, title: &str) -> usize {
let contents = fs::read_to_string(issue_file_named(dir, title)).unwrap();
contents
.lines()
.filter(|line| line.contains("epic_referenced") || line.contains("epic_dereferenced"))
.count()
}
#[test]
fn cli_epic_lifecycle_keeps_the_id_across_a_rename_and_an_edit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let root = epic_create(dir, "Root epic");
let mid = epic_create(dir, "Mid epic");
assert_ne!(root, mid, "each create allocates its own id");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--epics", &mid])
.assert()
.success();
tix()
.current_dir(dir)
.args(["epic", "edit", &mid, "--name", "Mid epic hardened"])
.assert()
.success();
let rows = epic_rows(dir);
assert!(
rows.iter().any(|(id, name, members, _)| id == &mid
&& name == "Mid epic hardened"
&& *members == 1),
"the id survives the rename with its member count: {rows:?}"
);
assert_eq!(
recorded_epics(dir, "Fix login"),
vec![mid.clone()],
"the issue still references the id after the rename"
);
tix()
.current_dir(dir)
.args(["epic", "edit", &mid, "--parent", &root])
.assert()
.success();
assert_eq!(
epic_rows(dir)
.iter()
.find(|(id, ..)| id == &root)
.unwrap()
.3,
1,
"Root now has one child epic"
);
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["epic", "edit", &mid, "--name", "Root epic"])
.assert()
.failure()
.stderr(predicate::str::contains("duplicate_name"));
assert_eq!(
registry_bytes(dir),
before,
"a refused rename writes nothing"
);
}
#[test]
fn cli_epic_listing_shows_the_id_and_the_name_and_counts() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let root = epic_create(dir, "Root");
let child = epic_create(dir, "Child");
tix()
.current_dir(dir)
.args(["epic", "edit", &child, "--parent", &root])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--epics", &root])
.assert()
.success();
let rows = epic_rows(dir);
assert_eq!(rows.len(), 2, "declaration order: {rows:?}");
assert_eq!(rows[0].0, root, "the id is a column of its own (C-003)");
assert_eq!(rows[0].1, "Root");
assert_eq!(rows[0].2, 1, "the member count is derived");
assert_eq!(rows[0].3, 1, "the child count is derived");
assert_eq!(rows[1].0, child);
assert_eq!(rows[1].1, "Child");
}
#[test]
fn cli_epic_show_prints_record_hierarchy_figures_and_members_with_ids() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let root = epic_create(dir, "Root");
let child = epic_create(dir, "Child");
tix()
.current_dir(dir)
.args(["epic", "edit", &child, "--parent", &root])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--epics", &root])
.assert()
.success();
let stdout = tix()
.current_dir(dir)
.args(["epic", "show", &root])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
assert!(stdout.contains(&format!("id: {root}")), "{stdout}");
assert!(stdout.contains("name: Root"), "{stdout}");
assert!(stdout.contains("children: 1"), "{stdout}");
assert!(stdout.contains("members: 1"), "{stdout}");
assert!(
stdout.contains("progress: 0/1"),
"the service progress figure: {stdout}"
);
assert!(
stdout.contains(&format!("child: Child ({child})")),
"a child epic shows its id: {stdout}"
);
assert!(
stdout.contains("#1") && stdout.contains("Fix login"),
"a member is named by number and title: {stdout}"
);
}
#[test]
fn cli_epic_navigate_prints_parent_children_ancestors_and_depth_in_order() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let root = epic_create(dir, "Root");
let mid = epic_create(dir, "Mid");
let leaf = epic_create(dir, "Leaf");
tix()
.current_dir(dir)
.args(["epic", "edit", &mid, "--parent", &root])
.assert()
.success();
tix()
.current_dir(dir)
.args(["epic", "edit", &leaf, "--parent", &mid])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--epics", &leaf])
.assert()
.success();
let stdout = tix()
.current_dir(dir)
.args(["epic", "navigate", &leaf])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
let pos = |needle: &str| {
stdout
.find(needle)
.unwrap_or_else(|| panic!("{needle} in {stdout}"))
};
assert!(pos("Root (") < pos("Mid ("), "root first: {stdout}");
assert!(pos("Mid (") < pos("Leaf ("), "parent before self: {stdout}");
assert!(stdout.contains(&format!("parent: Mid ({mid})")), "{stdout}");
assert!(
stdout.contains("depth: 2"),
"the depth is printed: {stdout}"
);
assert!(stdout.contains(&format!("epic: Leaf ({leaf})")), "{stdout}");
assert!(
stdout.contains("issues: 1") && stdout.contains("Fix login"),
"direct issue navigation is advertised with the member list: {stdout}"
);
assert!(
stdout.contains("navigate one directly"),
"FR-004's direct navigation is visible: {stdout}"
);
}
#[test]
fn cli_epic_delete_retains_members_and_child_epics_and_removes_only_the_entry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let parent = epic_create(dir, "Parent");
let child = epic_create(dir, "Child");
tix()
.current_dir(dir)
.args(["epic", "edit", &child, "--parent", &parent])
.assert()
.success();
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login", "--epics", &parent])
.assert()
.success();
let issue_before = fs::read(issue_file_named(dir, "Fix login")).unwrap();
tix()
.current_dir(dir)
.args(["epic", "delete", &parent, "--yes"])
.assert()
.success()
.stderr(
predicate::str::contains("Fix login")
.and(predicate::str::contains("Child"))
.and(predicate::str::contains("not deleted")),
);
let registry = registry_bytes(dir);
assert!(
!registry.contains(&format!("[epics.{parent}]")),
"the entry is removed: {registry}"
);
assert!(registry.contains(&child), "the child epic survives");
assert!(
epic_rows(dir).iter().all(|(id, ..)| id != &parent),
"the deleted epic is gone from the listing"
);
assert_eq!(
fs::read(issue_file_named(dir, "Fix login")).unwrap(),
issue_before,
"deleting an epic must not touch an issue"
);
assert_eq!(recorded_epics(dir, "Fix login"), vec![parent.clone()]);
let stdout = tix()
.current_dir(dir)
.args(["epic", "navigate", &child])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
assert!(
stdout.contains("unresolved-parent:"),
"the recorded parent is retained as unresolved: {stdout}"
);
}
#[test]
fn cli_epic_association_create_add_remove_clear_tracks_frontmatter_and_history() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let one = epic_create(dir, "One");
let two = epic_create(dir, "Two");
let three = epic_create(dir, "Three");
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Fix login",
"--epics",
&format!("{one},{two}"),
])
.assert()
.success();
assert_eq!(
recorded_epics(dir, "Fix login"),
vec![one.clone(), two.clone()]
);
assert_eq!(epic_events(dir, "Fix login"), 2);
let before = fs::read(issue_file_named(dir, "Fix login")).unwrap();
let stdout = tix()
.current_dir(dir)
.args(["edit", "1", "--add-epic", &one])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8(stdout)
.unwrap()
.contains("(no association change)"),
"a no-op add does not claim an update"
);
assert_eq!(
fs::read(issue_file_named(dir, "Fix login")).unwrap(),
before
);
assert_eq!(epic_events(dir, "Fix login"), 2);
tix()
.current_dir(dir)
.args(["edit", "1", "--add-epic", &three])
.assert()
.success();
assert_eq!(
recorded_epics(dir, "Fix login"),
vec![one.clone(), two.clone(), three.clone()]
);
assert_eq!(epic_events(dir, "Fix login"), 3);
tix()
.current_dir(dir)
.args(["edit", "1", "--remove-epic", &two])
.assert()
.success();
assert_eq!(
recorded_epics(dir, "Fix login"),
vec![one.clone(), three.clone()]
);
assert!(
issue_file_named(dir, "Fix login").is_file(),
"the issue is kept"
);
tix()
.current_dir(dir)
.args(["edit", "1", "--epics", ""])
.assert()
.success();
assert!(recorded_epics(dir, "Fix login").is_empty());
assert_eq!(epic_events(dir, "Fix login"), 6);
}
#[test]
fn cli_task_labelled_issue_is_an_ordinary_issue_with_a_reference() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let one = epic_create(dir, "One");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["edit", "1", "--labels", "task", "--add-epic", &one])
.assert()
.success();
assert_eq!(recorded_epics(dir, "Fix login"), vec![one.clone()]);
let contents = fs::read_to_string(issue_file_named(dir, "Fix login")).unwrap();
let frontmatter = contents.split("---").nth(1).expect("a frontmatter block");
for forbidden in ["task:", "task_id:", "work_item:", "epic_task:"] {
assert!(
!frontmatter.contains(forbidden),
"no task key may exist: {forbidden} in {frontmatter}"
);
}
assert!(
frontmatter.contains("labels:") && frontmatter.contains("task"),
"the task label is just a label: {frontmatter}"
);
}
#[test]
fn cli_epic_bulk_add_reports_partial_success_and_writes_the_good_ones() {
let (tmp, dir) = repo_with_numbered_issues(3);
let _tmp = tmp;
let epic = epic_create(&dir, "Harden");
tix()
.current_dir(&dir)
.args(["epic", "bulk-add", &epic, "1", "99", "2"])
.assert()
.failure()
.stdout(predicate::str::contains("referenced 2 of 3"))
.stderr(predicate::str::contains("issue #99"));
assert_eq!(recorded_epics(&dir, "Issue 1"), vec![epic.clone()]);
assert_eq!(recorded_epics(&dir, "Issue 2"), vec![epic.clone()]);
assert!(recorded_epics(&dir, "Issue 3").is_empty());
assert_eq!(epic_events(&dir, "Issue 1"), 1, "exactly one entry");
tix()
.current_dir(&dir)
.args(["epic", "bulk-add", &epic, "3"])
.assert()
.success()
.stdout(predicate::str::contains("referenced 1 of 1"));
tix()
.current_dir(&dir)
.args(["epic", "add", &epic, "1", "2"])
.assert()
.success()
.stdout(predicate::str::contains("(2 unchanged)"));
}
#[test]
fn cli_epic_unknown_reference_is_refused_with_a_named_diagnostic_and_no_write() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let _epic = epic_create(dir, "Harden");
tix()
.current_dir(dir)
.args(["create", "--title", "Fix login"])
.assert()
.success();
let before = repo_snapshot(dir);
for args in [
vec!["epic", "show", "bogus"],
vec!["epic", "navigate", "bogus"],
vec!["epic", "edit", "bogus", "--name", "X"],
vec!["epic", "delete", "bogus", "--yes"],
vec!["epic", "add", "bogus", "1"],
vec!["edit", "1", "--add-epic", "bogus"],
vec!["edit", "1", "--remove-epic", "bogus"],
] {
tix()
.current_dir(dir)
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("unknown_epic"));
}
tix()
.current_dir(dir)
.args(["epic", "remove", "bogus", "1"])
.assert()
.success()
.stdout(predicate::str::contains("0 of 1"));
tix()
.current_dir(dir)
.args(["create", "--title", "Half referenced", "--epics", "bogus"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown_epic"));
assert_eq!(
repo_snapshot(dir),
before,
"a rejected epic operation changes nothing on disk"
);
}
#[test]
fn cli_epic_lifecycle_refusals_are_named_and_write_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let one = epic_create(dir, "One");
let two = epic_create(dir, "Two");
let three = epic_create(dir, "Three");
tix()
.current_dir(dir)
.args(["epic", "edit", &two, "--parent", &one])
.assert()
.success();
tix()
.current_dir(dir)
.args(["epic", "edit", &three, "--parent", &two])
.assert()
.success();
let before = repo_snapshot(dir);
tix()
.current_dir(dir)
.args(["epic", "create", "one"])
.assert()
.failure()
.stderr(predicate::str::contains("duplicate_name"));
tix()
.current_dir(dir)
.args([
"epic",
"create",
"Bad dates",
"--start-date",
"2026-05-01",
"--due-date",
"2026-01-01",
])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_dates"));
tix()
.current_dir(dir)
.args(["epic", "create", "Bad shape", "--due-date", "not-a-date"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_dates"));
tix()
.current_dir(dir)
.args(["epic", "create", "No weight", "--weight", "0"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_weight"));
tix()
.current_dir(dir)
.args(["epic", "create", "Bad weight", "--weight", "heavy"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_weight"));
for args in [
vec!["epic", "create", "Neg A", "--weight", "-5"],
vec!["epic", "create", "Neg B", "--weight=-5"],
] {
tix()
.current_dir(dir)
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("invalid_weight"));
}
tix()
.current_dir(dir)
.args(["epic", "edit", &one, "--weight", "-5"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_weight"));
tix()
.current_dir(dir)
.args(["epic", "edit", &one, "--parent", &one])
.assert()
.failure()
.stderr(predicate::str::contains("self_parent"));
tix()
.current_dir(dir)
.args(["epic", "edit", &one, "--parent", &two])
.assert()
.failure()
.stderr(predicate::str::contains("cycle"));
assert_eq!(
repo_snapshot(dir),
before,
"a refused epic write changes nothing on disk"
);
}
#[test]
fn cli_epic_surface_has_the_contract_verbs_and_documents_clearing() {
let stdout = tix()
.args(["epic", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(stdout).unwrap();
let mut commands: Vec<String> = Vec::new();
let mut in_commands = false;
for line in stdout.lines() {
if line == "Commands:" {
in_commands = true;
continue;
}
if in_commands {
if line.trim().is_empty() {
break;
}
if let Some(verb) = line.split_whitespace().next() {
commands.push(verb.to_string());
}
}
}
assert_eq!(
commands,
vec![
"create", "edit", "list", "show", "navigate", "delete", "add", "remove", "bulk-add",
"help"
],
"the surface is exactly the contract verbs: {stdout}"
);
let delete_help = tix()
.args(["epic", "delete", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let delete_help = String::from_utf8(delete_help).unwrap();
assert!(
!delete_help.contains("--cascade"),
"no cascade flag: {delete_help}"
);
let edit_help = tix()
.args(["edit", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let edit_help = String::from_utf8(edit_help).unwrap();
assert!(
edit_help.contains("--add-epic")
&& edit_help.contains("--remove-epic")
&& edit_help.contains("clear"),
"the reference flags and the clearing convention are documented: {edit_help}"
);
let create_help = tix()
.args(["create", "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let create_help = String::from_utf8(create_help).unwrap();
assert!(create_help.contains("--epics"), "{create_help}");
}
#[test]
fn cli_epic_projects_milestone_and_cycle_are_stored_and_cleared() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = {
let out = tix()
.current_dir(dir)
.args([
"epic",
"create",
"Harden",
"--projects",
"p-a,p-b",
"--milestone",
"Q4",
"--cycle",
"Oct",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
out[out.rfind('(').unwrap() + 1..out.rfind(')').unwrap()].to_string()
};
let shown = || -> String {
let out = tix()
.current_dir(dir)
.args(["epic", "show", "Harden"])
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
};
let first = shown();
assert!(first.contains("projects: p-a, p-b"), "{first}");
assert!(first.contains("milestone: Q4"), "{first}");
assert!(first.contains("cycle: Oct"), "{first}");
tix()
.current_dir(dir)
.args(["epic", "edit", &id, "--projects", ""])
.assert()
.success();
tix()
.current_dir(dir)
.args(["epic", "edit", &id, "--milestone", ""])
.assert()
.success();
tix()
.current_dir(dir)
.args(["epic", "edit", &id, "--cycle", ""])
.assert()
.success();
let cleared = shown();
assert!(cleared.contains("projects: -"), "{cleared}");
assert!(cleared.contains("milestone: -"), "{cleared}");
assert!(cleared.contains("cycle: -"), "{cleared}");
}
#[test]
fn cli_epic_reference_fields_are_shape_checked_and_dangling_ids_are_stored() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["epic", "create", "Bad", "--projects", "not valid!"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid_reference"));
assert_eq!(
registry_bytes(dir),
before,
"a shape refusal writes nothing"
);
tix()
.current_dir(dir)
.args(["epic", "create", "Harden", "--projects", "p-nowhere"])
.assert()
.success();
assert!(
registry_bytes(dir).contains("p-nowhere"),
"the dangling id is stored"
);
let out = tix()
.current_dir(dir)
.args(["epic", "show", "Harden"])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("missing-references: 1"), "{out}");
}