use std::path::Path;
use std::process::Command;
use assert_cmd::Command as TestCommand;
use predicates::prelude::*;
fn rigger(data: &Path) -> TestCommand {
let mut cmd = TestCommand::cargo_bin("rigger").unwrap();
cmd.env("RIGGER_DATA_DIR", data);
cmd
}
fn git(dir: &Path, args: &[&str], at: Option<&str>) {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.com")
.env("GIT_COMMITTER_NAME", "Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.com")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null");
if let Some(at) = at {
cmd.env("GIT_AUTHOR_DATE", at).env("GIT_COMMITTER_DATE", at);
}
let out = cmd.output().unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
}
fn project(data: &Path, name: &str, stages: &[&str], releases: &[(&str, &str)]) {
let root = data.join(name);
let hub = root.join("hub");
std::fs::create_dir_all(&hub).unwrap();
let mut plan = String::from("# ะะปะฐะฝ\n\n");
for stage in stages {
plan.push_str(&format!("## {stage}\n\n- [ ] a task\n\n"));
}
std::fs::write(hub.join("ะะปะฐะฝ.md"), plan).unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"], None);
for (n, (version, date)) in releases.iter().enumerate() {
let at = format!("{date}T12:00:00Z");
std::fs::write(root.join(format!("file{n}.txt")), version).unwrap();
git(&root, &["add", "."], Some(&at));
git(&root, &["commit", "--quiet", "-m", &format!("feat: {version}")], Some(&at));
git(&root, &["tag", version], Some(&at));
}
if releases.is_empty() {
std::fs::write(root.join("README.md"), name).unwrap();
git(&root, &["add", "."], Some("2026-08-10T12:00:00Z"));
git(&root, &["commit", "--quiet", "-m", "chore: start"], Some("2026-08-10T12:00:00Z"));
}
rigger(data).args(["project", "add"]).arg(&root).assert().success();
rigger(data).args(["import", name, "--hub"]).arg(&hub).assert().success();
rigger(data).args(["sync", name]).assert().success();
}
fn output(data: &Path, args: &[&str]) -> String {
let out = rigger(data).args(args).assert().success();
String::from_utf8(out.get_output().stdout.clone()).unwrap()
}
#[test]
fn a_retro_sorts_every_release_into_on_time_slipped_or_unplanned() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(
data.path(),
"alpha",
&["v0.1.0 ยท First", "v0.2.0 ยท Second", "v0.3.0 ยท Third"],
&[("v0.1.0", "2026-09-11"), ("v0.2.0", "2026-09-25"), ("v0.3.0", "2026-09-25")],
);
rigger(data.path())
.args(["version", "plan", "alpha", "v0.1.0", "--week", "2026-W37"])
.assert()
.success();
rigger(data.path())
.args(["version", "plan", "alpha", "v0.2.0", "--week", "2026-W37"])
.assert()
.success();
let out = output(data.path(), &["retro", "--to", "2026-W40", "--weeks", "6"]);
assert!(out.contains("3 shipped"), "{out}");
assert!(out.contains("1 on time"), "{out}");
assert!(out.contains("1 slipped"), "{out}");
assert!(out.contains("1 unplanned"), "{out}");
assert!(out.contains("2 weeks late"), "{out}");
}
#[test]
fn the_share_of_planned_releases_is_reported() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(
data.path(),
"alpha",
&["v0.1.0 ยท First", "v0.2.0 ยท Second"],
&[("v0.1.0", "2026-09-11"), ("v0.2.0", "2026-09-11")],
);
rigger(data.path())
.args(["version", "plan", "alpha", "v0.1.0", "--week", "2026-W37"])
.assert()
.success();
let out = output(data.path(), &["retro", "--to", "2026-W37", "--weeks", "4"]);
assert!(out.contains("50% of what shipped had been planned"), "{out}");
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["retro", "--to", "2026-W37", "--weeks", "4", "--json"])).unwrap();
assert_eq!(json["planned_share"], 50);
}
#[test]
fn only_what_happened_inside_the_window_is_counted() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(
data.path(),
"alpha",
&["v0.1.0 ยท First", "v0.2.0 ยท Second"],
&[("v0.1.0", "2026-06-12"), ("v0.2.0", "2026-09-11")],
);
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["retro", "--to", "2026-W37", "--weeks", "2", "--json"])).unwrap();
let shipped = json["shipped"].as_array().unwrap();
assert_eq!(shipped.len(), 1, "{json}");
assert_eq!(shipped[0]["version"], "v0.2.0");
}
#[test]
fn a_missed_release_is_measured_against_the_window_not_the_clock() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[]);
rigger(data.path())
.args(["version", "plan", "alpha", "v0.1.0", "--week", "2026-W37"])
.assert()
.success();
let first = output(data.path(), &["retro", "--to", "2026-W40", "--weeks", "4", "--json"]);
let later = output(data.path(), &["retro", "--to", "2026-W40", "--weeks", "4", "--json"]);
assert_eq!(first, later, "the same window gave two answers");
let json: serde_json::Value = serde_json::from_str(&first).unwrap();
let missed = json["missed"].as_array().unwrap();
assert_eq!(missed.len(), 1, "{json}");
assert_eq!(missed[0]["weeks"], 3);
}
#[test]
fn the_two_directions_of_a_misfit_are_shown_apart() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(
data.path(),
"busy",
&["v0.1.0 ยท A", "v0.2.0 ยท B", "v0.3.0 ยท C", "v0.4.0 ยท D"],
&[
("v0.1.0", "2026-09-11"),
("v0.2.0", "2026-09-11"),
("v0.3.0", "2026-09-18"),
("v0.4.0", "2026-09-18"),
],
);
rigger(data.path()).args(["project", "tier", "busy", "C"]).assert().success();
project(data.path(), "stalled", &["v0.1.0 ยท A"], &[]);
rigger(data.path()).args(["project", "tier", "stalled", "C"]).assert().success();
let out = output(data.path(), &["retro", "--to", "2026-W40", "--cycle"]);
let stalled_at = out.find("Nothing shipped, and their tier asked for something:");
let outgrown_at = out.find("Shipping past their tier");
assert!(stalled_at.is_some(), "{out}");
assert!(outgrown_at.is_some(), "{out}");
assert!(stalled_at < outgrown_at, "{out}");
let stalled_block = &out[stalled_at.unwrap()..outgrown_at.unwrap()];
assert!(stalled_block.contains("stalled"), "{out}");
assert!(!stalled_block.contains("busy"), "a racing project was called stalled: {out}");
let outgrown_block = &out[outgrown_at.unwrap()..];
assert!(outgrown_block.contains("busy"), "{out}");
assert!(outgrown_block.contains("4x"), "{out}");
}
#[test]
fn a_tier_that_is_working_is_not_named() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(
data.path(),
"steady",
&["v0.1.0 ยท A", "v0.2.0 ยท B", "v0.3.0 ยท C"],
&[("v0.1.0", "2026-09-11"), ("v0.2.0", "2026-09-18"), ("v0.3.0", "2026-09-25")],
);
rigger(data.path()).args(["project", "tier", "steady", "A"]).assert().success();
let out = output(data.path(), &["retro", "--to", "2026-W40", "--cycle"]);
assert!(out.contains("3 shipped"), "{out}");
assert!(!out.contains("Shipping past their tier"), "a working tier was called a misfit: {out}");
assert!(!out.contains("Nothing shipped, and their tier"), "{out}");
}
#[test]
fn a_project_out_of_the_rotation_is_never_a_misfit() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "aside", &["v0.1.0 ยท A"], &[]);
rigger(data.path()).args(["project", "tier", "aside", "out"]).assert().success();
let out = output(data.path(), &["retro", "--to", "2026-W40", "--cycle"]);
assert!(!out.contains("aside"), "{out}");
}
#[test]
fn recording_without_a_place_to_keep_it_says_what_to_do() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[("v0.1.0", "2026-09-11")]);
rigger(data.path())
.args(["retro", "--to", "2026-W37", "--record"])
.assert()
.failure()
.stderr(predicate::str::contains("rigger project service"));
}
#[test]
fn the_same_retro_is_kept_once() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[("v0.1.0", "2026-09-11")]);
rigger(data.path()).args(["project", "service", "line"]).assert().success();
let first = output(data.path(), &["retro", "--to", "2026-W37", "--record"]);
assert!(first.contains("Kept in the record"), "{first}");
let again = output(data.path(), &["retro", "--to", "2026-W37", "--record"]);
assert!(again.contains("already in the record"), "{again}");
let packet = output(data.path(), &["context", "line"]);
assert!(packet.contains("2026-09-11"), "the retro is not dated by its window: {packet}");
assert_eq!(kept(data.path()), 1, "{}", output(data.path(), &["context", "line"]));
output(data.path(), &["retro", "--to", "2026-W38", "--record"]);
assert_eq!(kept(data.path()), 2, "{}", output(data.path(), &["context", "line"]));
}
fn kept(data: &Path) -> usize {
let json = output(data, &["digest", "line", "--since", "3650d", "--json"]);
let json: serde_json::Value = serde_json::from_str(&json).unwrap();
json["projects"][0]["facts"]["changes"].as_u64().unwrap_or(0) as usize
}
#[test]
fn a_quiet_window_is_not_kept() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[]);
rigger(data.path()).args(["project", "service", "line"]).assert().success();
let out = output(data.path(), &["retro", "--to", "2026-W44", "--record"]);
assert!(out.contains("Nothing shipped"), "{out}");
assert!(out.contains("Nothing to keep"), "{out}");
let doctor: serde_json::Value = serde_json::from_str(&output(data.path(), &["doctor", "--json"])).unwrap();
assert_eq!(doctor["counts"]["events"], 0, "{doctor}");
}
#[test]
fn a_place_the_record_keeps_for_itself_is_not_asked_about_git() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[("v0.1.0", "2026-09-11")]);
rigger(data.path()).args(["project", "service", "line"]).assert().success();
let out = output(data.path(), &["sync"]);
assert!(!out.contains("not a git repository"), "{out}");
let named = output(data.path(), &["sync", "line"]);
assert!(named.contains("no repository"), "{named}");
let doctor = output(data.path(), &["doctor"]);
assert!(!doctor.lines().any(|l| l.starts_with(" line")), "{doctor}");
let doctor: serde_json::Value = serde_json::from_str(&output(data.path(), &["doctor", "--json"])).unwrap();
assert_eq!(doctor["counts"]["projects"], 2, "{doctor}");
}
#[test]
fn a_service_project_never_shows_a_path_it_does_not_have() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
rigger(data.path()).args(["project", "service", "line"]).assert().success();
for args in [vec!["project", "list"], vec!["project", "show", "line"], vec!["context", "line"]] {
let out = output(data.path(), &args);
assert!(!out.contains("service:line"), "{args:?} showed the marker: {out}");
assert!(out.contains("line"), "{args:?}: {out}");
}
}
#[test]
fn a_window_of_no_weeks_is_refused() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
rigger(data.path())
.args(["retro", "--weeks", "0"])
.assert()
.failure()
.stderr(predicate::str::contains("at least one"));
rigger(data.path())
.args(["retro", "--to", "never"])
.assert()
.failure()
.stderr(predicate::str::contains("is not a week"));
}
#[test]
fn a_cycle_is_the_seven_weeks_the_calendar_named() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 ยท First"], &[("v0.1.0", "2026-09-11")]);
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["retro", "--cycle", "--to", "2026-W40", "--json"])).unwrap();
assert_eq!(json["weeks"], 7);
assert_eq!(json["from"], "2026-W34");
assert_eq!(json["to"], "2026-W40");
}