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 project(data: &Path) -> std::path::PathBuf {
let root = data.join("sample");
std::fs::create_dir_all(&root).unwrap();
rigger(data).arg("init").assert().success();
rigger(data).args(["project", "add"]).arg(&root).assert().success();
let hub = data.join("hub");
std::fs::create_dir_all(&hub).unwrap();
hub
}
fn import(data: &Path, hub: &Path) {
rigger(data).args(["import", "sample", "--hub"]).arg(hub).assert().success();
}
fn body(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap();
text.split_once('\n').map(|(_, rest)| rest.trim_start_matches('\n').to_string()).unwrap_or(text)
}
fn export(data: &Path, file: &str) -> String {
let out = data.join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data).args(["export", "sample", "--hub"]).arg(&out).assert().success();
body(&out.join(file))
}
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_task_struck_from_the_plan_is_dropped_and_comes_back_when_listed_again() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let two = "# План\n\n## v0.2.0 · Второй\n\n- [ ] сделать одно\n- [ ] сделать другое\n";
let one = "# План\n\n## v0.2.0 · Второй\n\n- [ ] сделать одно\n";
std::fs::write(hub.join("План.md"), two).unwrap();
import(data.path(), &hub);
assert!(output(data.path(), &["context", "sample"]).contains("2 tasks open"));
std::fs::write(hub.join("План.md"), one).unwrap();
let report = output(data.path(), &["import", "sample", "--hub", hub.to_str().unwrap()]);
assert!(report.contains("0 versions and 1 task struck from the hub"), "{report}");
assert_eq!(export(data.path(), "План.md"), one);
assert!(output(data.path(), &["context", "sample"]).contains("1 task open"));
std::fs::write(hub.join("План.md"), two).unwrap();
import(data.path(), &hub);
assert_eq!(export(data.path(), "План.md"), two);
assert!(output(data.path(), &["context", "sample"]).contains("2 tasks open"));
}
#[test]
fn a_stage_renumbered_by_hand_drops_its_old_number() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("План.md"), "# План\n\n## v1.10 · Триаж\n\n- [ ] snooze\n").unwrap();
import(data.path(), &hub);
let renumbered = "# План\n\n## v1.11 · Триаж\n\n- [ ] snooze\n";
std::fs::write(hub.join("План.md"), renumbered).unwrap();
let report = output(data.path(), &["import", "sample", "--hub", hub.to_str().unwrap()]);
assert!(report.contains("1 version and"), "{report}");
assert_eq!(export(data.path(), "План.md"), renumbered);
let packet = output(data.path(), &["context", "sample"]);
assert!(packet.contains("1 version planned, 1 task open"), "{packet}");
assert!(!packet.contains("v1.10"), "{packet}");
}
#[test]
fn a_diary_entry_read_without_its_date_is_merged_with_itself() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let diary = "# Дневник\n\n## 2026-09-03 (ночь) · v0.2.1: знак\n\n**Сделано.** Знак в шапке.\n";
std::fs::write(hub.join("Дневник.md"), diary).unwrap();
let db = rusqlite::Connection::open(data.path().join("rigger.db")).unwrap();
db.execute(
"INSERT INTO sessions (project_id, started_at, ended_at, day, heading, notes, followed_by_rule, gap_after, rank) \
VALUES (1, '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', '2026-09-03', '(ночь) · v0.2.1: знак', '**Сделано.** Знак в шапке.', 0, 1, 0)",
[],
)
.unwrap();
drop(db);
import(data.path(), &hub);
let written = export(data.path(), "Дневник.md");
assert_eq!(written, diary, "one entry, under the heading the hub writes");
assert_eq!(written.matches("Знак в шапке").count(), 1);
}
#[test]
fn a_session_leaves_its_diary_entry_in_the_record() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("Дневник.md"), "# Дневник\n\n## 2026-09-01 · Основание\n\n**Сделано.** Начало.\n").unwrap();
import(data.path(), &hub);
rigger(data.path()).args(["session", "start", "sample"]).assert().success();
rigger(data.path())
.args(["note", "sample", "--kind", "decision", "Решено так."])
.assert()
.success();
rigger(data.path())
.args(["session", "end", "sample", "--heading", "v0.2.0 «Второй»"])
.assert()
.success();
let written = export(data.path(), "Дневник.md");
let today = written
.lines()
.find(|l| l.starts_with("## 20") && l.contains("v0.2.0 «Второй»"))
.unwrap_or_default();
assert!(!today.is_empty(), "the sitting's entry is in the diary: {written}");
assert!(written.contains("Решено так."), "{written}");
assert!(
written.find("v0.2.0 «Второй»").unwrap() < written.find("Основание").unwrap(),
"newest first: {written}"
);
assert_eq!(export(data.path(), "Дневник.md"), written);
}
#[test]
fn a_state_line_lands_at_the_top_of_the_readme_block() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let readme = "# sample\n\n## Состояние\n\n- **2026-09-01** — **Основан.** Репозиторий заведён.\n";
std::fs::write(hub.join("README.md"), readme).unwrap();
import(data.path(), &hub);
rigger(data.path())
.args(["note", "sample", "--kind", "state", "**v0.2.0 выпущен.** Второй этап закрыт."])
.assert()
.success()
.stdout(predicate::str::contains("state line"));
let written = export(data.path(), "README.md");
let block = written.split("## Состояние").nth(1).unwrap();
let first = block.lines().find(|l| l.starts_with("- ")).unwrap();
assert!(first.contains("v0.2.0 выпущен"), "{written}");
assert!(first.starts_with("- **20"), "dated today: {first}");
assert!(block.contains("Основан."), "the older line stays: {written}");
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.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")
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn a_version_known_only_from_a_tag_is_not_written_into_the_changelog() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let root = data.path().join("sample");
git(&root, &["init", "--quiet", "--initial-branch", "main"]);
std::fs::write(root.join("README.md"), "x").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "--quiet", "-m", "feat: start"]);
git(&root, &["tag", "v0.1.1"]);
let changes = "# Изменения\n\n## v0.1.0 · Первый — выпущена 2026-09-01\n\nБыло.\n";
std::fs::write(hub.join("Изменения.md"), changes).unwrap();
import(data.path(), &hub);
rigger(data.path()).args(["sync", "sample"]).assert().success();
assert_eq!(export(data.path(), "Изменения.md"), changes);
let packet = output(data.path(), &["context", "sample"]);
assert!(packet.contains("Last shipped: v0.1.1"), "{packet}");
}
#[test]
fn a_question_struck_from_the_hub_is_withdrawn_but_an_assistants_stays() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let two = "# План\n\n## Ждёт решения владельца\n\n1. Первый вопрос?\n2. Второй вопрос?\n\n## v0.2.0 · Второй\n\n- [ ] одно\n";
std::fs::write(hub.join("План.md"), two).unwrap();
import(data.path(), &hub);
rigger(data.path()).args(["wish", "sample", "Хочется третьего."]).assert().success();
let asked = "Ассистент спрашивает?";
let db = rusqlite::Connection::open(data.path().join("rigger.db")).unwrap();
db.execute(
"INSERT INTO events (project_id, kind, body, author, created_at) VALUES (1, 'question', ?1, 'assistant', '2026-09-08T10:00:00Z')",
[asked],
)
.unwrap();
drop(db);
let one = "# План\n\n## Ждёт решения владельца\n\n1. Второй вопрос?\n\n## v0.2.0 · Второй\n\n- [ ] одно\n";
std::fs::write(hub.join("План.md"), one).unwrap();
let report = output(data.path(), &["import", "sample", "--hub", hub.to_str().unwrap()]);
assert!(report.contains("withdrawn 1 question struck from the hub"), "{report}");
let packet = output(data.path(), &["context", "sample"]);
assert!(!packet.contains("Первый вопрос"), "{packet}");
assert!(packet.contains("Второй вопрос"), "{packet}");
assert!(packet.contains(asked), "the assistant's question stays: {packet}");
}
#[test]
fn doctor_names_the_hubs_still_kept_by_hand() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("План.md"), "# План\n\n## v0.2.0 · Второй\n\n- [ ] одно\n").unwrap();
import(data.path(), &hub);
let before = output(data.path(), &["doctor", "--hubs"]);
assert!(before.contains("sample") && before.contains("kept by hand"), "{before}");
assert!(before.contains("План.md"), "{before}");
rigger(data.path())
.args(["export", "sample", "--hub"])
.arg(&hub)
.arg("--adopt")
.assert()
.success();
let after = output(data.path(), &["doctor", "--hubs"]);
assert!(after.contains("every generated file matches the record"), "{after}");
}