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 the_brief_names_the_focus_the_friday_and_what_waits() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 · First", "v0.2.0 · Second"], &[]);
rigger(data.path()).args(["project", "tier", "alpha", "B"]).assert().success();
rigger(data.path())
.args(["version", "plan", "alpha", "v0.1.0", "--week", "2026-W37"])
.assert()
.success();
rigger(data.path())
.args(["note", "alpha", "Which day do we announce on?", "--kind", "finding"])
.assert()
.success();
let out = output(data.path(), &["week", "--week", "2026-W37"]);
assert!(out.contains("2026-08-31") || out.contains("2026-09-07"), "{out}");
assert!(out.contains("2026-09-11"), "no Friday named: {out}");
assert!(out.contains("Focus"), "{out}");
assert!(out.contains("v0.1.0"), "{out}");
assert!(out.contains("First"), "{out}");
assert!(out.contains("Waiting on you"), "{out}");
}
#[test]
fn only_a_question_counts_as_waiting_on_the_owner() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 · First"], &[]);
let out = output(data.path(), &["week", "--week", "2026-W37"]);
assert!(out.contains("Waiting on you"), "{out}");
let waiting = out.split("Waiting on you").nth(1).unwrap_or("");
assert!(waiting.contains("nothing"), "{out}");
}
#[test]
fn a_tag_on_the_friday_reads_as_the_rule_kept() {
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 out = output(data.path(), &["release-day", "--week", "2026-W37"]);
assert!(out.contains("Friday"), "{out}");
assert!(out.contains("v0.1.0"), "{out}");
assert!(!out.contains("before Friday"), "{out}");
assert!(!out.contains("past the one release"), "{out}");
}
#[test]
fn releases_before_friday_and_past_the_slot_are_both_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.3.0 · Third"],
&[("v0.1.0", "2026-09-07"), ("v0.2.0", "2026-09-08"), ("v0.3.0", "2026-09-11")],
);
let out = output(data.path(), &["release-day", "--week", "2026-W37"]);
assert!(out.contains("2 releases past the one release this week has room for"), "{out}");
assert!(out.contains("2 releases went out before Friday"), "{out}");
let json = output(data.path(), &["release-day", "--week", "2026-W37", "--json"]);
assert!(json.contains("\"over_the_slot\": 2"), "{json}");
assert!(json.contains("\"early\": 2"), "{json}");
}
#[test]
fn a_busy_week_stays_one_screen() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let versions: Vec<String> = (1..=12).map(|n| format!("v0.{n}.0")).collect();
let stages: Vec<String> = versions.iter().map(|v| format!("{v} · Stage")).collect();
let stage_refs: Vec<&str> = stages.iter().map(String::as_str).collect();
let releases: Vec<(&str, &str)> = versions.iter().map(|v| (v.as_str(), "2026-09-08")).collect();
project(data.path(), "alpha", &stage_refs, &releases);
let out = output(data.path(), &["release-day", "--week", "2026-W37"]);
let listed = out.lines().filter(|l| l.contains("2026-09-08")).count();
assert_eq!(listed, 1, "a line each rather than a folded day: {out}");
assert!(out.contains("v0.1.0..v0.12.0 (12)"), "{out}");
assert!(out.contains("11 releases past"), "{out}");
assert!(out.lines().count() < 15, "{} lines: {out}", out.lines().count());
assert!(out.lines().all(|l| l.chars().count() < 120), "a line ran past the screen: {out}");
}
#[test]
fn a_carrying_product_is_allowed_one_missed_cycle_and_no_more() {
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", "tier", "alpha", "A"]).assert().success();
let out = output(data.path(), &["next", "--week", "2026-W41"]);
assert!(out.contains("Behind their rhythm"), "{out}");
assert!(!out.contains("more than one cycle"), "still inside the allowance: {out}");
let out = output(data.path(), &["next", "--week", "2026-W42"]);
assert!(out.contains("more than one cycle"), "{out}");
assert!(out.contains("alpha"), "{out}");
}
#[test]
fn a_growing_product_is_measured_by_its_last_turn_not_its_last_tag() {
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")]);
rigger(data.path()).args(["project", "tier", "alpha", "B"]).assert().success();
let out = output(data.path(), &["next", "--week", "2026-W44"]);
assert!(out.contains("no turn in the focus"), "{out}");
let root = data.path().join("alpha");
std::fs::write(root.join("work.txt"), "in progress").unwrap();
git(&root, &["add", "."], Some("2026-10-08T12:00:00Z"));
git(&root, &["commit", "--quiet", "-m", "feat: work on the decoder"], Some("2026-10-08T12:00:00Z"));
rigger(data.path()).args(["sync", "alpha"]).assert().success();
let out = output(data.path(), &["next", "--week", "2026-W44"]);
assert!(!out.contains("no turn in the focus"), "a worked-on product read as neglected: {out}");
assert!(out.contains("Behind their rhythm"), "the tag was not old enough to mean anything: {out}");
}
#[test]
fn a_second_declared_product_started_out_of_turn_is_named_with_the_first() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
started(data.path(), "first", "2026-08-12T12:00:00Z");
started(data.path(), "second", "2026-09-02T12:00:00Z");
let out = output(data.path(), &["next", "--week", "2026-W40"]);
let line = out
.lines()
.find(|l| l.contains("started before"))
.unwrap_or_else(|| panic!("no second-start signal: {out}"));
assert!(line.starts_with("second"), "the wrong product was called out of turn: {out}");
assert!(line.contains("before first"), "the wrong product was named as the one to wait for: {out}");
assert_eq!(out.matches("started before").count(), 1, "{out}");
}
fn started(data: &Path, name: &str, at: &str) {
let root = data.join(name);
let hub = root.join("hub");
std::fs::create_dir_all(&hub).unwrap();
std::fs::write(
hub.join("План.md"),
"# План
## v0.1.0 · First
- [ ] a task
",
)
.unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"], None);
std::fs::write(root.join("README.md"), name).unwrap();
git(&root, &["add", "."], Some(at));
git(&root, &["commit", "--quiet", "-m", "feat: begin"], Some(at));
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();
rigger(data).args(["project", "tier", name, "C"]).assert().success();
}
#[test]
fn a_project_out_of_the_rotation_raises_nothing() {
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", "tier", "alpha", "out"]).assert().success();
let out = output(data.path(), &["next", "--week", "2026-W50"]);
assert!(!out.contains("Their tier asks for more"), "{out}");
assert!(!out.contains("Behind their rhythm"), "{out}");
}
#[test]
fn the_brief_and_the_queue_agree_about_the_week() {
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-08")]);
rigger(data.path())
.args(["version", "plan", "alpha", "v0.2.0", "--week", "2026-W37"])
.assert()
.success();
let brief = output(data.path(), &["week", "--week", "2026-W37", "--json"]);
let queue = output(data.path(), &["release-day", "--week", "2026-W37", "--json"]);
let brief: serde_json::Value = serde_json::from_str(&brief).unwrap();
let queue: serde_json::Value = serde_json::from_str(&queue).unwrap();
assert_eq!(brief["shipping"], queue["queued"], "the two screens disagree about the queue");
assert_eq!(brief["shipped"], queue["shipped"], "the two screens disagree about what shipped");
assert_eq!(queue["queued"][0]["version"], "v0.2.0");
assert_eq!(queue["shipped"][0]["version"], "v0.1.0");
}
#[test]
fn an_empty_week_says_so_on_every_screen() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &["v0.1.0 · First"], &[]);
let brief = output(data.path(), &["week", "--week", "2026-W50"]);
assert!(brief.contains("nothing is aimed at this week"), "{brief}");
assert!(brief.contains("nothing is queued"), "{brief}");
let queue = output(data.path(), &["release-day", "--week", "2026-W50"]);
assert!(queue.contains("Nothing is waiting for Friday"), "{queue}");
}
#[test]
fn a_signalled_project_is_named_in_the_digest_rather_than_folded_into_the_quiet_line() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
started(data.path(), "first", "2026-08-12T12:00:00Z");
started(data.path(), "second", "2026-09-02T12:00:00Z");
let out = output(data.path(), &["digest", "--since", "1d"]);
let quiet = out.lines().find(|l| l.starts_with("Quiet:")).unwrap_or("");
assert!(!quiet.contains("second"), "a signalled project was folded away: {out}");
assert!(out.contains("asks for more"), "{out}");
assert!(out.contains("started before first"), "{out}");
assert!(quiet.contains("first"), "the fold stopped working entirely: {out}");
}
#[test]
fn a_week_that_is_not_a_week_is_refused_by_both_screens() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
for command in ["week", "release-day"] {
rigger(data.path())
.args([command, "--week", "next tuesday"])
.assert()
.failure()
.stderr(predicate::str::contains("is not a week"));
}
}