use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
fn rigger(data: &Path) -> Command {
let mut cmd = Command::cargo_bin("rigger").unwrap();
cmd.env("RIGGER_DATA_DIR", data);
cmd
}
fn output(data: &Path, args: &[&str]) -> String {
let out = rigger(data).args(args).assert().success();
String::from_utf8(out.get_output().stdout.clone()).unwrap()
}
fn two_projects(data: &Path) {
rigger(data).arg("init").assert().success();
for (name, stages) in [("alpha", ["v0.1.0", "v0.2.0"]), ("beta", ["v0.1.0", "v0.2.0"])] {
let root = data.join(name);
std::fs::create_dir_all(&root).unwrap();
rigger(data).args(["project", "add"]).arg(&root).assert().success();
let hub = data.join(format!("{name}-hub"));
std::fs::create_dir_all(&hub).unwrap();
let plan = format!("# План\n\n## {} · Первый\n\n- [ ] one\n\n## {} · Второй\n\n- [ ] two\n", stages[0], stages[1]);
std::fs::write(hub.join("План.md"), plan).unwrap();
rigger(data).args(["import", name, "--hub"]).arg(&hub).assert().success();
}
}
fn ship(data: &Path, project: &str, version: &str, at: &str) {
let db_path = data.join("profiles").join("line").join("rigger.db");
let db = rusqlite::Connection::open(&db_path).unwrap();
let changed = db
.execute(
"UPDATE versions SET status = 'shipped', shipped_at = ?1, shipped_ts = ?2, shipped_source = 'tag' \
WHERE name = ?3 AND project_id = (SELECT id FROM projects WHERE name = ?4)",
rusqlite::params![at, format!("{at}T09:00:00Z"), version, project],
)
.unwrap();
assert_eq!(changed, 1, "no version {version} of {project} to ship");
}
#[test]
fn a_tie_is_recorded_once_and_reads_the_same_from_both_ends() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
let added = output(data.path(), &["link", "add", "alpha@v0.2.0", "beta@v0.2.0", "--note", "one capability"]);
assert!(added.contains("alpha v0.2.0 <-> beta v0.2.0 (pair)"), "{added}");
let again = output(data.path(), &["link", "add", "alpha@v0.2.0", "beta@v0.2.0"]);
assert!(again.contains("already recorded"), "{again}");
assert_eq!(output(data.path(), &["link", "list"]).lines().count(), 1);
let from_beta = output(data.path(), &["link", "list", "beta"]);
assert!(from_beta.contains("beta v0.2.0 <-> alpha v0.2.0"), "{from_beta}");
}
#[test]
fn a_directed_tie_turns_about_when_read_from_the_other_end() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["link", "add", "alpha", "beta", "--kind", "consumer"])
.assert()
.success();
assert!(output(data.path(), &["link", "list", "alpha"]).contains("consumer alpha -> beta"));
assert!(output(data.path(), &["link", "list", "beta"]).contains("donor beta -> alpha"));
}
#[test]
fn a_version_the_project_does_not_have_is_refused() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["link", "add", "alpha@v9.9.9", "beta"])
.assert()
.failure()
.stderr(predicate::str::contains("has no version v9.9.9"));
assert!(output(data.path(), &["link", "list"]).contains("no links"));
}
#[test]
fn a_version_is_anchored_by_value_however_the_hub_spells_it() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
let added = output(data.path(), &["link", "add", "alpha@v0.2", "beta@0.2.0"]);
assert!(added.contains("alpha v0.2.0 <-> beta v0.2.0"), "{added}");
assert!(output(data.path(), &["link", "add", "alpha@v0.2.0", "beta@v0.2.0"]).contains("already recorded"));
assert_eq!(output(data.path(), &["link", "list"]).lines().count(), 1);
}
#[test]
fn a_project_cannot_be_tied_to_itself() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["link", "add", "alpha", "alpha"])
.assert()
.failure()
.stderr(predicate::str::contains("a link joins two projects"));
}
#[test]
fn a_half_that_shipped_without_its_other_half_is_reported() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["link", "add", "alpha@v0.2.0", "beta@v0.2.0", "--note", "the shared door"])
.assert()
.success();
assert!(output(data.path(), &["link", "drift"]).contains("both halves in step"));
ship(data.path(), "alpha", "v0.2.0", "2026-09-11");
let drift = output(data.path(), &["link", "drift"]);
assert!(drift.contains("alpha v0.2.0 shipped without beta v0.2.0"), "{drift}");
assert!(drift.contains("the shared door"), "the note is carried: {drift}");
assert!(output(data.path(), &["next"]).contains("Pairs out of step"));
assert!(output(data.path(), &["week"]).contains("alpha v0.2.0 shipped without beta"));
assert!(output(data.path(), &["retro", "--weeks", "4"]).contains("Pairs out of step"));
ship(data.path(), "beta", "v0.2.0", "2026-09-11");
assert!(output(data.path(), &["link", "drift"]).contains("both halves in step"));
assert!(!output(data.path(), &["next"]).contains("Pairs out of step"));
}
#[test]
fn a_consumer_running_ahead_raises_nothing() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["link", "add", "alpha@v0.2.0", "beta@v0.2.0", "--kind", "consumer"])
.assert()
.success();
ship(data.path(), "alpha", "v0.2.0", "2026-09-11");
assert!(output(data.path(), &["link", "drift"]).contains("both halves in step"));
}
#[test]
fn the_screen_prints_the_neighbours_under_three_headings() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
let gamma = data.path().join("gamma");
std::fs::create_dir_all(&gamma).unwrap();
rigger(data.path()).args(["project", "add"]).arg(&gamma).assert().success();
rigger(data.path()).args(["link", "add", "alpha@v0.2.0", "beta@v0.2.0"]).assert().success();
rigger(data.path())
.args(["link", "add", "alpha", "gamma", "--kind", "consumer"])
.assert()
.success();
let screen = output(data.path(), &["show", "alpha"]);
assert!(screen.contains("Paired with:"), "{screen}");
assert!(screen.contains("beta v0.2.0 (here: v0.2.0)"), "{screen}");
assert!(screen.contains("Draws on:"), "{screen}");
assert!(screen.contains("gamma"), "{screen}");
assert!(output(data.path(), &["show", "gamma"]).contains("Drawn on by:"));
assert!(!output(data.path(), &["show", "beta"]).contains("Draws on:"));
}
#[test]
fn a_tie_can_be_forgotten_by_the_id_the_list_prints() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path()).args(["link", "add", "alpha", "beta"]).assert().success();
let listed = output(data.path(), &["link", "list"]);
let id = listed.split(']').next().unwrap().trim_start_matches('[');
rigger(data.path()).args(["link", "remove", id]).assert().success();
assert!(output(data.path(), &["link", "list"]).contains("no links"));
rigger(data.path())
.args(["link", "remove", id])
.assert()
.failure()
.stderr(predicate::str::contains("no link"));
}
#[test]
fn a_principle_gathers_its_decisions_from_every_project() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args([
"note",
"alpha",
"broke it outright",
"--kind",
"decision",
"--principle",
"no users, no compatibility",
])
.assert()
.success();
rigger(data.path())
.args([
"note",
"beta",
"dropped the old column",
"--kind",
"decision",
"--principle",
"no users, no compatibility",
])
.assert()
.success();
rigger(data.path())
.args(["note", "beta", "measured the screen", "--kind", "decision", "--principle", "measure the screen"])
.assert()
.success();
let thread = output(data.path(), &["why", "--principle", "no users, no compatibility"]);
assert!(thread.contains("2 decisions across 2 projects"), "{thread}");
assert!(thread.contains("broke it outright") && thread.contains("dropped the old column"), "{thread}");
assert!(!thread.contains("measured the screen"), "a second principle is a second thread: {thread}");
let names = output(data.path(), &["why", "--principles"]);
assert!(names.contains("no users, no compatibility — 2 decisions"), "{names}");
assert!(names.contains("measure the screen — 1 decision"), "{names}");
let one = output(data.path(), &["why", "alpha", "--principle", "no users, no compatibility"]);
assert!(one.contains("1 decision across 1 project"), "{one}");
}
#[test]
fn an_unknown_principle_shows_the_ones_the_record_knows() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["note", "alpha", "a decision", "--kind", "decision", "--principle", "measure the screen"])
.assert()
.success();
let answer = output(data.path(), &["why", "--principle", "mesure the screen"]);
assert!(answer.contains("Nothing stands on"), "{answer}");
assert!(answer.contains("measure the screen (1)"), "{answer}");
}
#[test]
fn only_a_decision_can_stand_on_a_principle() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["note", "alpha", "a finding", "--kind", "finding", "--principle", "measure the screen"])
.assert()
.failure()
.stderr(predicate::str::contains("a principle belongs to a decision"));
}
#[test]
fn a_wish_from_a_neighbour_names_who_is_waiting() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["wish", "beta", "a gate for the docs address", "--from", "alpha"])
.assert()
.success()
.stdout(predicate::str::contains("from alpha"));
let packet = output(data.path(), &["context", "beta"]);
assert!(packet.contains("(alpha asks) a gate for the docs address"), "{packet}");
let inbox = output(data.path(), &["inbox"]);
assert!(inbox.contains("Neighbours are asking for:"), "{inbox}");
assert!(inbox.contains("alpha"), "{inbox}");
assert!(output(data.path(), &["show", "beta"]).contains("Neighbours are asking for:"));
let id = packet
.lines()
.find(|l| l.contains("alpha asks"))
.and_then(|l| l.split(']').next())
.map(|l| l.trim_start_matches("- ["))
.unwrap()
.to_string();
rigger(data.path()).args(["resolve", "beta", &id]).assert().success();
assert!(!output(data.path(), &["inbox"]).contains("Neighbours are asking"));
assert!(!output(data.path(), &["show", "beta"]).contains("Neighbours are asking"));
}
#[test]
fn a_neighbour_that_does_not_exist_is_refused() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path())
.args(["wish", "beta", "something", "--from", "nowhere"])
.assert()
.failure()
.stderr(predicate::str::contains("nowhere"));
assert!(!output(data.path(), &["context", "beta"]).contains("something"));
}
#[test]
fn the_neighbour_on_a_wish_line_costs_the_packet_almost_nothing() {
let data = tempfile::tempdir().unwrap();
two_projects(data.path());
rigger(data.path()).args(["wish", "beta", "a plain wish"]).assert().success();
let plain = output(data.path(), &["context", "beta"]).len();
rigger(data.path())
.args(["wish", "beta", "an asked wish", "--from", "alpha"])
.assert()
.success();
let with_asker = output(data.path(), &["context", "beta"]);
assert!(with_asker.contains("(alpha asks)"), "{with_asker}");
assert!(
with_asker.len() < plain + "- [99] (alpha asks) an asked wish\n".len() + 8,
"{}",
with_asker.len()
);
}