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, releases: &[(&str, &str)]) {
let root = data.join(name);
let hub = root.join("hub");
std::fs::create_dir_all(&hub).unwrap();
std::fs::write(hub.join("План.md"), "# План\n\n## v0.1.0 · First\n\n- [ ] a task\n").unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"], None);
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"));
for (n, (version, date)) in releases.iter().enumerate() {
let at = format!("{date}T12:00:00Z");
std::fs::write(root.join(format!("f{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));
}
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()
}
fn note(data: &Path, project: &str, kind: &str, text: &str) {
rigger(data).args(["note", project, text, "--kind", kind]).assert().success();
}
#[test]
fn events_written_while_a_session_is_open_belong_to_it() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
note(data.path(), "alpha", "finding", "written beforehand");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "decision", "written inside");
note(data.path(), "alpha", "change", "also inside");
let json = output(data.path(), &["session", "end", "alpha", "--json"]);
let json: serde_json::Value = serde_json::from_str(&json).unwrap();
let session = &json["session"];
assert_eq!(session["decisions"].as_array().unwrap().len(), 1, "{json}");
assert_eq!(session["changes"].as_array().unwrap().len(), 1, "{json}");
let findings = session["findings"].as_array().unwrap();
assert!(findings.is_empty(), "an event from before the session was counted in it: {json}");
}
#[test]
fn an_event_written_between_sessions_belongs_to_neither() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "decision", "inside the first");
rigger(data.path()).args(["session", "end", "alpha"]).assert().success();
note(data.path(), "alpha", "finding", "in between");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["session", "end", "alpha", "--json"])).unwrap();
let session = &json["session"];
assert!(session["findings"].as_array().unwrap().is_empty(), "{json}");
assert!(session["decisions"].as_array().unwrap().is_empty(), "{json}");
}
#[test]
fn starting_a_second_time_joins_the_sitting() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "decision", "before the second start");
let again = output(data.path(), &["session", "start", "alpha"]);
assert!(again.contains("already open"), "{again}");
note(data.path(), "alpha", "finding", "after it");
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["session", "end", "alpha", "--json"])).unwrap();
assert_eq!(json["session"]["decisions"].as_array().unwrap().len(), 1, "{json}");
assert_eq!(json["session"]["findings"].as_array().unwrap().len(), 1, "{json}");
let after = output(data.path(), &["session", "end", "alpha"]);
assert!(after.contains("No session is open"), "{after}");
}
#[test]
fn the_chronicle_from_commits_is_not_counted_as_the_sessions_work() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
let root = data.path().join("alpha");
std::fs::write(root.join("work.txt"), "x").unwrap();
git(&root, &["add", "."], Some("2026-09-04T12:00:00Z"));
git(&root, &["commit", "--quiet", "-m", "feat: a committed change"], Some("2026-09-04T12:00:00Z"));
rigger(data.path()).args(["sync", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "a change the session wrote");
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["session", "end", "alpha", "--json"])).unwrap();
let changes = json["session"]["changes"].as_array().unwrap();
assert_eq!(changes.len(), 1, "{json}");
assert_eq!(changes[0], "a change the session wrote");
}
#[test]
fn the_end_says_what_the_ritual_asked_for_and_did_not_get() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "did a thing");
let out = output(data.path(), &["session", "end", "alpha"]);
assert!(out.contains("The ritual asks for"), "{out}");
assert!(out.contains("no next step"), "{out}");
assert!(out.contains("nothing says why"), "{out}");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "did another thing");
note(data.path(), "alpha", "decision", "and here is why");
note(data.path(), "alpha", "next", "carry on tomorrow");
let out = output(data.path(), &["session", "end", "alpha"]);
assert!(!out.contains("The ritual asks for"), "{out}");
}
#[test]
fn a_session_that_only_reasoned_is_not_asked_why() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "decision", "we will use ISO weeks");
note(data.path(), "alpha", "next", "start on it");
let out = output(data.path(), &["session", "end", "alpha"]);
assert!(!out.contains("nothing says why"), "{out}");
}
#[test]
fn a_reminder_speaks_only_when_there_is_something_to_say() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let out = output(data.path(), &["session", "end", "alpha", "--remind"]);
assert!(out.trim().is_empty(), "a hook spoke about nothing: {out}");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "a change");
note(data.path(), "alpha", "decision", "and why");
note(data.path(), "alpha", "next", "onwards");
let out = output(data.path(), &["session", "end", "alpha", "--remind"]);
assert!(out.trim().is_empty(), "a hook spoke about a complete session: {out}");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "a lone change");
let out = output(data.path(), &["session", "end", "alpha", "--remind"]);
assert!(out.contains("nothing says why"), "{out}");
}
#[test]
fn the_diary_entry_is_written_from_what_was_recorded() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let diary = data.path().join("diary.md");
std::fs::write(
&diary,
"# Дневник работы\n\nОдна запись на сессию, новые сверху.\n\n---\n\n## 2026-09-01 · раньше\n\nЧто-то было.\n",
)
.unwrap();
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "added the calendar");
note(data.path(), "alpha", "pitfall", "the grid wraps at 46 releases");
note(data.path(), "alpha", "next", "ship the retro");
rigger(data.path())
.args(["session", "end", "alpha", "--heading", "v0.1.0 «First»", "--diary"])
.arg(&diary)
.assert()
.success();
let text = std::fs::read_to_string(&diary).unwrap();
assert!(text.starts_with("# Дневник работы"), "{text}");
let new_at = text.find("v0.1.0 «First»").unwrap_or_else(|| panic!("no new entry: {text}"));
let old_at = text.find("## 2026-09-01 · раньше").unwrap_or_else(|| panic!("the old entry was lost: {text}"));
assert!(new_at < old_at, "the new entry went below the old one: {text}");
assert!(text.contains("Что-то было."), "{text}");
assert!(text.contains("added the calendar"), "{text}");
assert!(text.contains("the grid wraps at 46 releases"), "{text}");
assert!(text.contains("**Следующий шаг.** ship the retro"), "{text}");
assert!(!text.contains("**Решения.**"), "{text}");
}
#[test]
fn a_diary_is_created_if_it_is_not_there() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let diary = data.path().join("new-diary.md");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "change", "the first thing");
rigger(data.path()).args(["session", "end", "alpha", "--diary"]).arg(&diary).assert().success();
let text = std::fs::read_to_string(&diary).unwrap();
assert!(text.contains("the first thing"), "{text}");
assert!(text.starts_with("## "), "{text}");
}
#[test]
fn the_packet_says_what_happened_since_the_last_sitting() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let packet = output(data.path(), &["context", "alpha"]);
assert!(!packet.contains("Last session ended"), "{packet}");
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
note(data.path(), "alpha", "decision", "inside the sitting");
rigger(data.path()).args(["session", "end", "alpha"]).assert().success();
let packet = output(data.path(), &["context", "alpha"]);
assert!(packet.contains("nothing since"), "{packet}");
note(data.path(), "alpha", "finding", "after hours");
let packet = output(data.path(), &["context", "alpha"]);
assert!(packet.contains("1 event recorded"), "{packet}");
assert!(!packet.contains("nothing since"), "{packet}");
}
#[test]
fn an_event_in_the_second_the_session_closed_counts_as_since() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["session", "end", "alpha", "--json"])).unwrap();
let ended_at = json["session"]["ended_at"].as_str().unwrap().to_string();
let mut hit = false;
for n in 0..40 {
note(data.path(), "alpha", "finding", &format!("attempt {n}"));
let packet: serde_json::Value = serde_json::from_str(&output(data.path(), &["context", "alpha", "--json"])).unwrap();
let events = packet["state"]["since_last_session"]["events"].as_u64().unwrap_or(0);
assert_eq!(events, n + 1, "an event after the close went uncounted (ended {ended_at}): {packet}");
hit = true;
}
assert!(hit);
}
#[test]
fn commits_since_the_last_sitting_are_counted_apart_from_events() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
rigger(data.path()).args(["session", "end", "alpha"]).assert().success();
let root = data.path().join("alpha");
std::fs::write(root.join("later.txt"), "x").unwrap();
let after = "2099-01-01T12:00:00Z";
git(&root, &["add", "."], Some(after));
git(&root, &["commit", "--quiet", "-m", "feat: done outside a session"], Some(after));
rigger(data.path()).args(["sync", "alpha"]).assert().success();
let json: serde_json::Value = serde_json::from_str(&output(data.path(), &["context", "alpha", "--json"])).unwrap();
let since = &json["state"]["since_last_session"];
assert_eq!(since["commits"], 1, "{json}");
assert_eq!(since["events"], 0, "{json}");
let packet = output(data.path(), &["context", "alpha"]);
assert!(packet.contains("1 change committed"), "{packet}");
}
#[test]
fn the_packet_still_fits_its_budget_with_a_session_line() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
rigger(data.path()).args(["session", "start", "alpha"]).assert().success();
for n in 0..60 {
note(
data.path(),
"alpha",
"finding",
&format!("a finding numbered {n}, with enough words in it to cost something worth counting"),
);
}
rigger(data.path()).args(["session", "end", "alpha"]).assert().success();
let explained = output(data.path(), &["context", "alpha", "--explain"]);
let total: usize = explained
.lines()
.find(|l| l.trim_start().starts_with("total"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|n| n.parse().ok())
.unwrap_or_else(|| panic!("no total in --explain: {explained}"));
assert!(total <= 3000, "the packet went over its budget: {total}");
assert!(explained.contains("Last session ended"), "{explained}");
}
#[test]
fn the_working_directory_names_the_project_when_nothing_else_does() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let root = data.path().join("alpha");
rigger(data.path()).current_dir(&root).args(["session", "start"]).assert().success();
note(data.path(), "alpha", "change", "recorded from the root");
let deep = root.join("src").join("deep");
std::fs::create_dir_all(&deep).unwrap();
let out = rigger(data.path()).current_dir(&deep).args(["session", "end"]).assert().success();
let out = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(out.contains("alpha"), "{out}");
assert!(out.contains("1 change"), "{out}");
}
#[test]
fn a_reminder_outside_any_project_is_silent_and_successful() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha", &[]);
let elsewhere = data.path().join("not-a-project");
std::fs::create_dir_all(&elsewhere).unwrap();
let out = rigger(data.path())
.current_dir(&elsewhere)
.args(["session", "end", "--remind"])
.assert()
.success();
assert!(String::from_utf8(out.get_output().stdout.clone()).unwrap().trim().is_empty());
rigger(data.path())
.current_dir(&elsewhere)
.args(["session", "end"])
.assert()
.failure()
.stderr(predicate::str::contains("no project recorded"));
}
#[test]
fn a_project_nobody_recorded_is_refused_by_both_halves() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
for args in [vec!["session", "start", "ghost"], vec!["session", "end", "ghost"]] {
rigger(data.path()).args(&args).assert().failure().stderr(predicate::str::contains("ghost"));
}
}