use std::fs;
use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn registry_bytes(dir: &Path) -> String {
fs::read_to_string(dir.join("tix.toml")).expect("tix.toml")
}
fn parse_id(stdout: &[u8]) -> String {
let stdout = String::from_utf8(stdout.to_vec()).unwrap();
let open = stdout.rfind('(').expect("a parenthesised id");
let close = stdout.rfind(')').expect("a closing parenthesis");
stdout[open + 1..close].to_string()
}
fn initiative_create(dir: &Path, name: &str, extra: &[&str]) -> String {
let out = tix()
.current_dir(dir)
.args(["initiative", "create", name])
.args(extra)
.assert()
.success()
.get_output()
.stdout
.clone();
parse_id(&out)
}
fn stdout_of(dir: &Path, args: &[&str]) -> String {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
}
fn roadmap_rows(dir: &Path, args: &[&str]) -> Vec<(String, String, String, String)> {
let mut full: Vec<&str> = vec!["roadmap", "list"];
full.extend_from_slice(args);
stdout_of(dir, &full)
.lines()
.filter(|line| line.split('\t').count() == 4)
.map(|line| {
let mut fields = line.split('\t');
(
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
)
})
.collect()
}
fn initiative_rows(dir: &Path) -> Vec<(String, String, String, String)> {
stdout_of(dir, &["initiative", "list"])
.lines()
.map(|line| {
let mut fields = line.split('\t');
(
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
fields.next().unwrap().to_string(),
)
})
.collect()
}
fn create_named(dir: &Path, name: &str) -> String {
initiative_create(dir, name, &[])
}
#[test]
fn cli_initiative_crud_round_trips_through_the_registry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = initiative_create(
dir,
"Platform",
&[
"--outcome",
"Ship the platform",
"--owner",
"alice",
"--status",
"active",
"--priority",
"high",
"--labels",
"core,infra",
"--target-date",
"2026-03-01",
],
);
let rows = initiative_rows(dir);
assert_eq!(
rows,
vec![(
id.clone(),
"Platform".to_string(),
"2026-03-01".to_string(),
"active".to_string()
)],
"the id is a column of its own (C-002)"
);
let shown = stdout_of(dir, &["initiative", "show", &id]);
assert!(shown.contains(&format!("id: {id}")), "{shown}");
assert!(shown.contains("name: Platform"), "{shown}");
assert!(shown.contains("outcome: Ship the platform"), "{shown}");
assert!(shown.contains("owner: alice"), "{shown}");
assert!(shown.contains("status: active"), "{shown}");
assert!(shown.contains("priority: high"), "{shown}");
assert!(shown.contains("labels: core, infra"), "{shown}");
assert!(shown.contains("target date: 2026-03-01"), "{shown}");
let bytes = registry_bytes(dir);
assert!(bytes.contains("[initiatives."), "{bytes}");
assert!(bytes.contains(&format!("[initiatives.{id}]")), "{bytes}");
}
#[test]
fn cli_initiative_rename_keeps_the_id_and_every_association() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = create_named(dir, "Platform");
let project = parse_id(
tix()
.current_dir(dir)
.args(["project", "create", "Proj"])
.assert()
.success()
.get_output()
.stdout
.as_slice(),
);
tix()
.current_dir(dir)
.args(["initiative", "associate", &id, "--project", &project])
.assert()
.success();
tix()
.current_dir(dir)
.args(["initiative", "edit", &id, "--name", "Platform hardening"])
.assert()
.success();
let rows = initiative_rows(dir);
assert_eq!(rows[0].0, id, "the id survives the rename (C-002)");
assert_eq!(rows[0].1, "Platform hardening");
let bytes = registry_bytes(dir);
assert!(bytes.contains(&format!("[initiatives.{id}]")), "{bytes}");
assert_eq!(
bytes.matches("[initiatives.").count(),
1,
"the record was not re-keyed: {bytes}"
);
let row = stdout_of(dir, &["roadmap", "show", &id]);
assert!(row.contains(&format!("project:{project}")), "{row}");
}
#[test]
fn cli_initiative_associate_and_dereference_are_idempotent() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = create_named(dir, "Platform");
let first = tix()
.current_dir(dir)
.args([
"initiative",
"associate",
&id,
"--project",
"p-0000000000000000",
])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8_lossy(&first).contains("Associated"),
"{first:?}"
);
let after_first = registry_bytes(dir);
let second = tix()
.current_dir(dir)
.args([
"initiative",
"associate",
&id,
"--project",
"p-0000000000000000",
])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8_lossy(&second).contains("Already associated"),
"{second:?}"
);
assert_eq!(
registry_bytes(dir),
after_first,
"the second associate writes nothing"
);
let dropped = tix()
.current_dir(dir)
.args([
"initiative",
"dereference",
&id,
"--project",
"p-0000000000000000",
])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8_lossy(&dropped).contains("Dereferenced"),
"{dropped:?}"
);
let after_drop = registry_bytes(dir);
let again = tix()
.current_dir(dir)
.args([
"initiative",
"dereference",
&id,
"--project",
"p-0000000000000000",
])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
String::from_utf8_lossy(&again).contains("Not associated"),
"{again:?}"
);
assert_eq!(
registry_bytes(dir),
after_drop,
"the second dereference writes nothing"
);
}
#[test]
fn cli_initiative_association_requires_exactly_one_kind_flag() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = create_named(dir, "Platform");
let before = registry_bytes(dir);
let missing = tix()
.current_dir(dir)
.args(["initiative", "associate", &id])
.assert()
.failure()
.get_output()
.clone();
assert!(
String::from_utf8_lossy(&missing.stderr).contains("exactly one of `--project`"),
"{:?}",
missing.stderr
);
tix()
.current_dir(dir)
.args([
"initiative",
"associate",
&id,
"--project",
"p-1",
"--epic",
"e-1",
])
.assert()
.failure()
.stderr(predicate::str::contains("accepts only one of"));
assert_eq!(
registry_bytes(dir),
before,
"a refused associate writes nothing"
);
}
#[test]
fn cli_initiative_edit_requires_a_field_and_writes_nothing_without_one() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = create_named(dir, "Platform");
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["initiative", "edit", &id])
.assert()
.failure()
.stderr(predicate::str::contains("requires at least one field flag"));
assert_eq!(
registry_bytes(dir),
before,
"a no-field edit writes nothing"
);
}
#[test]
fn cli_initiative_delete_removes_only_the_entry_and_reports_references() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = initiative_create(dir, "Platform", &["--target-date", "2026-03-01"]);
tix()
.current_dir(dir)
.args(["initiative", "associate", &id, "--milestone", "2026-Q4"])
.assert()
.success();
let out = tix()
.current_dir(dir)
.args(["initiative", "delete", &id, "--yes"])
.assert()
.success()
.get_output()
.stdout
.clone();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("Deleted initiative Platform"), "{out}");
assert!(
out.contains("milestones: 2026-Q4"),
"the record's own references are reported: {out}"
);
assert!(initiative_rows(dir).is_empty());
assert_eq!(
roadmap_rows(dir, &[]).len(),
0,
"the deleted initiative is gone from the roadmap"
);
let bytes = registry_bytes(dir);
assert!(!bytes.contains("[initiatives."), "{bytes}");
}
#[test]
fn cli_roadmap_orders_by_target_date_then_id_with_undated_last() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let late = initiative_create(dir, "Late", &["--target-date", "2026-05-01"]);
let undated = create_named(dir, "Undated");
let early = initiative_create(dir, "Early", &["--target-date", "2026-01-01"]);
let rows = roadmap_rows(dir, &[]);
assert_eq!(
rows.iter().map(|row| row.0.clone()).collect::<Vec<_>>(),
vec![early, late, undated],
"date ascending, then the explicit undated state"
);
assert_eq!(
rows[2].2, "undated",
"an undated row is never blank (C-003)"
);
let bounded = roadmap_rows(dir, &["--from", "2026-01-01", "--to", "2026-12-31"]);
assert_eq!(bounded.len(), 2, "the undated initiative is excluded");
assert!(bounded.iter().all(|row| row.2 != "undated"), "{bounded:?}");
}
#[test]
fn cli_roadmap_filters_every_dimension_and_a_combination() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let alpha = initiative_create(
dir,
"Alpha",
&[
"--owner",
"alice",
"--status",
"active",
"--labels",
"core,infra",
"--target-date",
"2026-02-01",
"--outcome",
"the platform",
],
);
let beta = initiative_create(
dir,
"Beta",
&[
"--owner",
"bob",
"--status",
"done",
"--labels",
"docs",
"--target-date",
"2026-07-01",
],
);
let ids = |args: &[&str]| {
roadmap_rows(dir, args)
.iter()
.map(|row| row.0.clone())
.collect::<Vec<_>>()
};
assert_eq!(
ids(&["--owner", "ALICE"]),
vec![alpha.clone()],
"owner folds case"
);
assert_eq!(ids(&["--status", "done"]), vec![beta.clone()]);
assert_eq!(ids(&["--label", "core"]), vec![alpha.clone()]);
assert_eq!(ids(&["--from", "2026-06-01"]), vec![beta.clone()]);
assert_eq!(ids(&["--to", "2026-03-01"]), vec![alpha.clone()]);
assert_eq!(ids(&["--query", "platform"]), vec![alpha.clone()]);
assert_eq!(
ids(&[
"--owner",
"alice",
"--status",
"active",
"--label",
"core",
"--from",
"2026-01-01",
"--to",
"2026-06-01",
]),
vec![alpha]
);
assert!(ids(&["--label", "core", "--status", "done"]).is_empty());
}
#[test]
fn cli_roadmap_empty_result_exits_successfully() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let out = stdout_of(dir, &["roadmap", "list"]);
assert!(out.contains("no initiatives match"), "{out}");
initiative_create(dir, "Alpha", &["--status", "active"]);
let out = stdout_of(dir, &["roadmap", "list", "--status", "nonexistent"]);
assert!(out.contains("no initiatives match"), "{out}");
assert!(
roadmap_rows(dir, &["--status", "nonexistent"]).is_empty(),
"the empty projection has no rows"
);
}
#[test]
fn cli_roadmap_malformed_filter_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();
initiative_create(dir, "Alpha", &["--target-date", "2026-03-01"]);
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["roadmap", "list", "--from", "2026-13-01"])
.assert()
.failure()
.stderr(predicate::str::contains("roadmap filter `period_from`"))
.stderr(predicate::str::contains("YYYY-MM-DD"));
tix()
.current_dir(dir)
.args([
"roadmap",
"list",
"--from",
"2026-06-01",
"--to",
"2026-01-01",
])
.assert()
.failure()
.stderr(predicate::str::contains("must be <="));
assert_eq!(
registry_bytes(dir),
before,
"a malformed filter is read-only and writes nothing"
);
}
#[test]
fn cli_roadmap_row_shows_provenance_and_policy_version_never_a_blend() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let project = parse_id(
tix()
.current_dir(dir)
.args(["project", "create", "Proj"])
.assert()
.success()
.get_output()
.stdout
.as_slice(),
);
let epic = parse_id(
tix()
.current_dir(dir)
.args(["epic", "create", "Eppy"])
.assert()
.success()
.get_output()
.stdout
.as_slice(),
);
tix()
.current_dir(dir)
.args([
"create",
"--title",
"Member",
"--projects",
&project,
"--epics",
&epic,
])
.assert()
.success();
let id = initiative_create(dir, "Alpha", &["--target-date", "2026-03-01"]);
for args in [
vec!["initiative", "associate", &id, "--project", &project],
vec!["initiative", "associate", &id, "--epic", &epic],
vec!["initiative", "associate", &id, "--milestone", "2026-Q4"],
] {
tix().current_dir(dir).args(args).assert().success();
}
let out = stdout_of(dir, &["roadmap", "show", &id]);
let header = out.lines().next().unwrap();
assert!(
header.contains(&format!("{id}\tAlpha\t2026-03-01\t")),
"{out}"
);
let contributions: Vec<&str> = out
.lines()
.filter(|line| line.starts_with(" ") && line.contains("policy="))
.collect();
assert_eq!(contributions.len(), 3, "one contribution per source: {out}");
assert!(
contributions
.iter()
.any(|line| line.contains(&format!("project:{project}")) && line.contains("[resolved]")),
"{out}"
);
assert!(
contributions
.iter()
.any(|line| line.contains(&format!("epic:{epic}")) && line.contains("[resolved]")),
"{out}"
);
assert!(
contributions
.iter()
.any(|line| line.contains("milestone:2026-Q4") && line.contains("[missing]")),
"{out}"
);
assert!(
out.contains("missing_reference"),
"the missing reference is diagnosed: {out}"
);
assert!(out.contains("policy=health-v1"), "{out}");
assert!(out.contains("policy=-"), "{out}");
for line in contributions {
assert!(
!(line.contains("project:") && line.contains("epic:")),
"a contribution is one source: {line}"
);
}
assert!(
!out.contains('%'),
"no blended percentage is invented: {out}"
);
}
#[test]
fn cli_initiative_rejected_mutations_leave_toml_byte_identical() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
initiative_create(dir, "Platform", &["--target-date", "2026-03-01"]);
let second = create_named(dir, "Infra");
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["initiative", "create", "Platform"])
.assert()
.failure()
.stderr(predicate::str::contains("must be unique"));
assert_eq!(
registry_bytes(dir),
before,
"a refused create writes nothing"
);
tix()
.current_dir(dir)
.args(["initiative", "edit", &second, "--projects", "not an id!"])
.assert()
.failure();
assert_eq!(registry_bytes(dir), before, "a refused edit writes nothing");
tix()
.current_dir(dir)
.args(["initiative", "edit", &second, "--target-date", "2026-13-01"])
.assert()
.failure();
assert_eq!(registry_bytes(dir), before, "a refused date writes nothing");
}
#[test]
fn cli_initiative_priority_blank_and_padding_agree_across_create_and_edit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let padded = initiative_create(dir, "Padded", &["--priority", " high "]);
let shown = stdout_of(dir, &["initiative", "show", &padded]);
assert!(
shown.contains("priority: high"),
"the known spelling is normalized on create: {shown}"
);
let blank = initiative_create(dir, "Blank", &["--priority", ""]);
let shown = stdout_of(dir, &["initiative", "show", &blank]);
assert!(
shown.contains("priority: -"),
"a blank --priority is absent on create: {shown}"
);
let bytes = registry_bytes(dir);
let blank_section = format!("[initiatives.{blank}]");
let blank_body = bytes
.split(&blank_section)
.nth(1)
.unwrap_or_default()
.split("[initiatives.")
.next()
.unwrap_or_default();
assert!(
!blank_body.contains("priority ="),
"a blank --priority stores no key: {bytes}"
);
let target = create_named(dir, "EditTarget");
tix()
.current_dir(dir)
.args(["initiative", "edit", &target, "--priority", " low "])
.assert()
.success();
let shown = stdout_of(dir, &["initiative", "show", &target]);
assert!(
shown.contains("priority: low"),
"the known spelling is normalized on edit: {shown}"
);
tix()
.current_dir(dir)
.args(["initiative", "edit", &target, "--priority", ""])
.assert()
.success();
let shown = stdout_of(dir, &["initiative", "show", &target]);
assert!(
shown.contains("priority: -"),
"a blank --priority clears on edit: {shown}"
);
}
#[test]
fn cli_initiative_refused_delete_and_dereference_write_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let id = create_named(dir, "Platform");
let before = registry_bytes(dir);
tix()
.current_dir(dir)
.args(["initiative", "delete", "nosuch", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown initiative"));
assert_eq!(
registry_bytes(dir),
before,
"a refused delete writes nothing"
);
tix()
.current_dir(dir)
.args(["initiative", "dereference", &id, "--project", "not an id!"])
.assert()
.failure();
assert_eq!(
registry_bytes(dir),
before,
"a refused dereference writes nothing"
);
}
#[test]
fn cli_roadmap_absent_initiatives_leave_a_legacy_toml_untouched() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
tix().current_dir(dir).arg("init").assert().success();
let before = registry_bytes(dir);
assert!(initiative_rows(dir).is_empty());
let out = stdout_of(dir, &["roadmap", "list"]);
assert!(out.contains("no initiatives match"), "{out}");
assert_eq!(
registry_bytes(dir),
before,
"a read-only surface never writes tix.toml"
);
}