use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn tix() -> String {
env!("CARGO_BIN_EXE_tix").to_string()
}
fn repo(wip: &str) -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
fs::write(
tmp.path().join("tix.toml"),
format!(
"issues_dir = {issues:?}\n\
[kanban.default]\ncolumns = [\n \"todo\",\n \"in progress\",\n \"done\",\n]\n\
[layout]\nmove_on_change = false\n{wip}",
issues = dir.to_string_lossy(),
),
)
.unwrap();
(tmp, dir)
}
fn write_issue(dir: &Path, number: u32, status: &str, estimate: Option<&str>) {
let estimate = estimate
.map(|value| format!("estimate: \"{value}\"\n"))
.unwrap_or_default();
fs::write(
dir.join(format!("2026-01-01-issue-{number}.md")),
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
{estimate}---\n\nBody.\n"
),
)
.unwrap();
}
fn run(root: &Path, args: &[&str]) -> Output {
Command::new(tix())
.current_dir(root)
.args(args)
.output()
.expect("the binary runs")
}
fn ok(root: &Path, args: &[&str]) -> String {
let out = run(root, args);
assert!(
out.status.success(),
"tix {args:?} failed: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("stdout is UTF-8")
}
fn refused(root: &Path, args: &[&str]) -> (String, String) {
let out = run(root, args);
assert!(!out.status.success(), "tix {args:?} unexpectedly succeeded");
(
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(root, &path, out);
} else {
out.insert(
path.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string(),
fs::read(&path).unwrap_or_default(),
);
}
}
}
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
fn issue_text(dir: &Path, number: u32) -> String {
fs::read_to_string(dir.join(format!("2026-01-01-issue-{number}.md"))).unwrap_or_default()
}
fn status_of(text: &str) -> String {
text.lines()
.find_map(|line| line.trim_start().strip_prefix("status:"))
.map(|value| value.trim().trim_matches('"').to_string())
.unwrap_or_default()
}
#[test]
fn wip_config_round_trips_through_the_cli() {
let (tmp, _dir) = repo("");
let root = tmp.path();
let before = fs::read_to_string(root.join("tix.toml")).unwrap();
assert!(
ok(root, &["wip", "show"]).contains("units: -"),
"a board with no limits says so"
);
ok(root, &["wip", "unit", "d", "1"]);
ok(root, &["wip", "unit", "w", "5"]);
let set = ok(
root,
&[
"wip",
"set",
"in progress",
"--max-items",
"2",
"--max-estimate",
"10",
"--unit",
"d",
"--policy",
"block",
],
);
assert!(set.contains("Set the limit"), "{set}");
let configured = fs::read_to_string(root.join("tix.toml")).unwrap();
assert!(
configured.contains("[kanban.default.wip.units]"),
"{configured}"
);
assert!(configured.contains("max_items = 2"), "{configured}");
assert!(configured.contains("policy = \"block\""), "{configured}");
let shown = ok(root, &["wip", "show"]);
assert!(shown.contains("units: d=1, w=5"), "{shown}");
assert!(shown.contains("in progress"), "{shown}");
assert!(
shown.contains("max_items=2,max_estimate=10d,policy=block"),
"{shown}"
);
assert!(shown.contains("todo -"), "{shown}");
ok(root, &["wip", "set", "in progress", "--max-items", "3"]);
let updated = fs::read_to_string(root.join("tix.toml")).unwrap();
assert!(updated.contains("max_items = 3") && updated.contains("max_estimate = 10"));
assert!(
updated.contains("policy = \"block\""),
"the policy survived"
);
let (_, stderr) = refused(root, &["wip", "set", "shipped", "--max-items", "1"]);
assert!(stderr.contains("not a column"), "{stderr}");
let (_, stderr) = refused(
root,
&["wip", "set", "todo", "--max-items", "1", "--policy", "nope"],
);
assert!(stderr.contains("unknown policy"), "{stderr}");
let (_, stderr) = refused(
root,
&["wip", "set", "todo", "--max-items", "1", "--unit", "q"],
);
assert!(
stderr.contains("q") && stderr.contains("wip.units"),
"an undeclared unit is refused by name: {stderr}"
);
assert_eq!(fs::read_to_string(root.join("tix.toml")).unwrap(), updated);
ok(root, &["wip", "unset", "in progress"]);
let without_limit = fs::read_to_string(root.join("tix.toml")).unwrap();
assert!(!without_limit.contains("max_items"), "{without_limit}");
let (_, stderr) = refused(root, &["wip", "unset", "in progress"]);
assert!(stderr.contains("no limit"), "{stderr}");
ok(root, &["wip", "unit", "d", "--remove"]);
ok(root, &["wip", "unit", "w", "--remove"]);
let canonical = fs::read_to_string(root.join("tix.toml")).unwrap();
assert!(!canonical.contains("wip"), "{canonical}");
ok(root, &["wip", "unit", "q", "2"]);
ok(root, &["wip", "unit", "q", "--remove"]);
assert_eq!(
fs::read_to_string(root.join("tix.toml")).unwrap(),
canonical,
"saving twice from the canonical state changes nothing"
);
assert!(!before.is_empty(), "the fixture was written");
}
#[test]
fn move_blocks_allows_and_reports_a_warning() {
let (tmp, dir) = repo(
"\n[kanban.default.wip.units]\nd = 1\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 1\nmax_estimate = 10\n\
unit = \"d\"\npolicy = \"block\"\n",
);
let root = tmp.path();
write_issue(&dir, 1, "todo", Some("2"));
write_issue(&dir, 2, "todo", Some("2"));
let out = run(root, &["move", "1", "in progress"]);
assert!(out.status.success());
assert!(
out.stderr.is_empty(),
"{:?}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!issue_text(&dir, 1).contains("wip override"),
"a move within the limit writes no override event"
);
let before = snapshot(&dir);
let (stdout, stderr) = refused(root, &["move", "2", "in progress"]);
assert!(stdout.is_empty(), "the refusal is not a result: {stdout:?}");
assert!(stderr.contains("block"), "{stderr}");
assert!(stderr.contains("override_required"), "{stderr}");
assert!(
stderr.contains("authorized override is required"),
"the policy says what to do without naming a surface's flag: {stderr}"
);
let help = ok(root, &["move", "--help"]);
assert!(
help.contains("--override") && help.contains("--reason"),
"and the flag that supplies one is documented where a caller looks: {help}"
);
assert_eq!(snapshot(&dir), before, "a refused move writes nothing");
assert_eq!(
status_of(&issue_text(&dir, 2)),
"todo",
"the refused issue did not move"
);
let (_, stderr) = refused(root, &["move", "2", "in progress", "--override", "--yes"]);
assert!(stderr.contains("missing_override_field"), "{stderr}");
assert_eq!(snapshot(&dir), before);
let (_, stderr) = refused(
root,
&[
"move",
"2",
"in progress",
"--override",
"--reason",
"release week",
],
);
assert!(stderr.contains("unconfirmed_override"), "{stderr}");
assert_eq!(snapshot(&dir), before);
let out = ok(
root,
&[
"move",
"2",
"in progress",
"--override",
"--reason",
"release week",
"--yes",
],
);
assert!(out.contains("Moved"), "{out}");
let moved = issue_text(&dir, 2);
assert!(moved.contains("in progress"), "{moved}");
assert!(moved.contains("wip override"), "{moved}");
assert!(moved.contains("release week"), "{moved}");
assert!(moved.contains("max_items 1"), "{moved}");
write_issue(&dir, 3, "todo", Some("2"));
ok(root, &["wip", "set", "in progress", "--policy", "warn"]);
let out = run(root, &["move", "3", "in progress"]);
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("warn") && stderr.contains("over_limit"),
"{stderr}"
);
assert!(
!stderr.contains("wip override"),
"a warning appends no override: {stderr}"
);
assert!(String::from_utf8_lossy(&out.stdout).contains("Moved"));
assert!(!issue_text(&dir, 3).contains("wip override"));
}
#[test]
fn a_move_with_an_uncountable_estimate_is_refused_not_rounded() {
let (tmp, dir) = repo(
"\n[kanban.default.wip.units]\nd = 1\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_estimate = 10\nunit = \"d\"\n\
policy = \"warn\"\n",
);
let root = tmp.path();
write_issue(&dir, 1, "todo", Some("nope"));
write_issue(&dir, 2, "todo", Some("3q"));
let before = snapshot(&dir);
for (issue, kind) in [(1, "malformed_estimate"), (2, "missing_conversion")] {
let (_, stderr) = refused(root, &["move", &issue.to_string(), "in progress"]);
assert!(stderr.contains(kind), "{issue}: {stderr}");
assert!(
stderr.contains("refused rather than counted as zero")
|| stderr.contains("cannot count"),
"{issue}: {stderr}"
);
}
assert_eq!(snapshot(&dir), before, "neither refusal wrote anything");
write_issue(&dir, 3, "todo", Some("2"));
ok(root, &["move", "3", "in progress"]);
assert!(
issue_text(&dir, 3).contains("in progress"),
"the move landed"
);
}
#[test]
fn a_board_without_limits_is_unaffected() {
let (tmp, dir) = repo("");
let root = tmp.path();
write_issue(&dir, 1, "todo", None);
write_issue(&dir, 2, "in progress", None);
write_issue(&dir, 3, "in progress", None);
ok(root, &["move", "1", "in progress"]);
let status = ok(root, &["status"]);
assert!(!status.contains('/'), "no usage figures: {status}");
let listed = ok(root, &["list", "--show", "wip"]);
assert!(listed.lines().all(|line| !line.contains("4/3")), "{listed}");
assert_eq!(
ok(root, &["wip", "show"]).lines().count(),
3 + 3,
"one header line, the units line, the column header and three rows"
);
}
#[test]
fn the_existing_move_behaviour_is_preserved() {
let (tmp, dir) = repo("");
let root = tmp.path();
write_issue(&dir, 1, "in progress", None);
let out = ok(root, &["move", "1", "done", "--commit", "abc123"]);
assert!(out.contains("(commit abc123)"), "{out}");
assert!(out.contains("Moved"), "{out}");
let text = issue_text(&dir, 1);
assert!(text.contains("status: done"), "{text}");
assert!(text.contains("commit: abc123"), "{text}");
ok(root, &["move", "1", "in progress"]);
let text = issue_text(&dir, 1);
assert!(!text.contains("commit:"), "the commit is cleared: {text}");
let (_, stderr) = refused(root, &["move", "1", "shipped"]);
assert!(stderr.contains("unknown_column"), "{stderr}");
}