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]) {
let out = 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));
}
const PLAN: &str = "\
# План разработки
Только незакрытые этапы.
## Ждёт решения владельца
1. Первый вопрос?
## Блок «Первый»
### v0.2.0 · Второй
- [ ] сделать одно
- [ ] сделать другое
**Результат:** что-то работает.
## Блок «Второй»
### v0.3.0 · Третий
Зачем это нужно.
- [ ] ещё одно
";
const CHANGES: &str = "\
# Изменения
Закрытые этапы, новые сверху.
---
## v0.1.0 · Первый — выпущена 2026-09-01
Абзац прозы про то, что было сделано и почему.
- Пункт с деталями.
";
const DIARY: &str = "\
# Дневник работы
Одна запись на сессию, новые сверху.
---
## 2026-09-02 (вечер) · v0.1.0 «Первый»
**Сделано.** Вторая запись того же дня.
---
## 2026-09-02 · Основание
**Сделано.** Первая запись.
";
fn project(data: &Path, name: &str) -> std::path::PathBuf {
let root = data.join(name);
let hub = root.join("hub");
std::fs::create_dir_all(&hub).unwrap();
std::fs::write(hub.join("План.md"), PLAN).unwrap();
std::fs::write(hub.join("Изменения.md"), CHANGES).unwrap();
std::fs::write(hub.join("Дневник.md"), DIARY).unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"]);
std::fs::write(root.join("README.md"), name).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "--quiet", "-m", "chore: start"]);
rigger(data).args(["project", "add"]).arg(&root).assert().success();
rigger(data).args(["import", name, "--hub"]).arg(&hub).assert().success();
hub
}
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 body(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap();
let mark = rigger_mark();
match text.find(&mark) {
Some(_) => text.split_once('\n').map(|(_, rest)| rest.trim_start_matches('\n').to_string()).unwrap_or(text),
None => text,
}
}
fn rigger_mark() -> String {
"<!-- generated by rigger; edits here are overwritten -->".to_string()
}
#[test]
fn a_hub_survives_being_written_from_the_record() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let hub = project(data.path(), "alpha");
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "alpha", "--hub"]).arg(&out).assert().success();
for (name, source) in [("План.md", PLAN), ("Изменения.md", CHANGES), ("Дневник.md", DIARY)] {
assert_eq!(body(&out.join(name)), source, "{name} came back different");
}
assert_eq!(std::fs::read_to_string(hub.join("План.md")).unwrap(), PLAN);
}
#[test]
fn the_same_record_writes_the_same_bytes() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha");
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "alpha", "--hub"]).arg(&out).assert().success();
let first: Vec<String> = ["План.md", "Изменения.md", "Дневник.md"].iter().map(|n| body(&out.join(n))).collect();
let again = output(data.path(), &["export", "alpha", "--hub", out.to_str().unwrap()]);
assert!(again.contains("already what the record says"), "{again}");
let second: Vec<String> = ["План.md", "Изменения.md", "Дневник.md"].iter().map(|n| body(&out.join(n))).collect();
assert_eq!(first, second);
}
#[test]
fn a_changelog_keeps_the_order_its_hub_had() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let root = data.path().join("beta");
let hub = root.join("hub");
std::fs::create_dir_all(&hub).unwrap();
std::fs::write(hub.join("План.md"), "# План\n").unwrap();
std::fs::write(
hub.join("Изменения.md"),
"# Изменения\n\n## v0.2.0 · Second — выпущена 2026-09-02\n\nПро второй.\n\n## v0.2.1 · Patch — выпущена 2026-09-02\n\nПро патч.\n\n## v0.1.0 · First — выпущена 2026-09-01\n\nПро первый.\n",
)
.unwrap();
std::fs::write(hub.join("Дневник.md"), "# Дневник\n").unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"]);
std::fs::write(root.join("README.md"), "beta").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "--quiet", "-m", "chore: start"]);
rigger(data.path()).args(["project", "add"]).arg(&root).assert().success();
rigger(data.path()).args(["import", "beta", "--hub"]).arg(&hub).assert().success();
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "beta", "--hub"]).arg(&out).assert().success();
let text = std::fs::read_to_string(out.join("Изменения.md")).unwrap();
let at = |v: &str| text.find(v).unwrap_or_else(|| panic!("{v} is missing from\n{text}"));
assert!(at("v0.2.0") < at("v0.2.1"), "the patch stays below the release it patched\n{text}");
assert!(at("v0.2.1") < at("v0.1.0"), "and both stay above the older one\n{text}");
}
#[test]
fn doctor_says_when_it_does_not_know_where_a_hub_is() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let root = data.path().join("gamma");
std::fs::create_dir_all(&root).unwrap();
git(&root, &["init", "--quiet", "--initial-branch", "main"]);
std::fs::write(root.join("README.md"), "gamma").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "--quiet", "-m", "chore: start"]);
rigger(data.path()).args(["project", "add"]).arg(&root).assert().success();
let said = output(data.path(), &["doctor", "--hubs"]);
assert!(
said.contains("no hub recorded"),
"a hub it has never seen is not a clean bill of health\n{said}"
);
assert!(!said.contains("every generated file matches"), "{said}");
}
#[test]
fn a_column_an_edited_migration_left_out_is_put_back() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let db = data.path().join("profiles").join("line").join("rigger.db");
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute_batch("ALTER TABLE versions DROP COLUMN after_prose").unwrap();
let missing: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('versions')")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
assert!(!missing.iter().any(|c| c == "after_prose"), "the column is gone to start with");
drop(conn);
rigger(data.path()).arg("doctor").assert().success();
let conn = rusqlite::Connection::open(&db).unwrap();
let back: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('versions')")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
assert!(back.iter().any(|c| c == "after_prose"), "{back:?}");
}
#[test]
fn importing_what_was_exported_changes_nothing() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha");
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "alpha", "--hub"]).arg(&out).assert().success();
let report = output(data.path(), &["import", "alpha", "--hub", out.to_str().unwrap(), "--json"]);
let report: serde_json::Value = serde_json::from_str(&report).unwrap();
for key in [
"versions_added",
"versions_updated",
"tasks_added",
"tasks_updated",
"decisions_added",
"questions_added",
"diary_added",
"diary_updated",
"prose_files",
] {
assert_eq!(report[key], 0, "{key} moved on the way back: {report}");
}
}
#[test]
fn a_handwritten_file_is_not_taken_without_being_offered() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let hub = project(data.path(), "alpha");
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(&hub)
.assert()
.failure()
.stderr(predicate::str::contains("--adopt"));
assert_eq!(std::fs::read_to_string(hub.join("План.md")).unwrap(), PLAN);
let check = output(data.path(), &["export", "alpha", "--hub", hub.to_str().unwrap(), "--check"]);
assert!(check.contains("would change") || check.contains("unchanged"), "{check}");
assert_eq!(std::fs::read_to_string(hub.join("План.md")).unwrap(), PLAN);
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(&hub)
.arg("--adopt")
.assert()
.success();
assert!(std::fs::read_to_string(hub.join("План.md")).unwrap().contains(&rigger_mark()));
rigger(data.path()).args(["export", "alpha", "--hub"]).arg(&hub).assert().success();
}
#[test]
fn the_line_endings_a_file_already_used_are_kept() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let hub = project(data.path(), "alpha");
for name in ["План.md", "Изменения.md", "Дневник.md"] {
let text = std::fs::read_to_string(hub.join(name)).unwrap().replace('\n', "\r\n");
std::fs::write(hub.join(name), text).unwrap();
}
rigger(data.path()).args(["import", "alpha", "--hub"]).arg(&hub).assert().success();
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(&hub)
.arg("--adopt")
.assert()
.success();
let text = std::fs::read(hub.join("План.md")).unwrap();
let crlf = text.windows(2).filter(|w| w == b"\r\n").count();
let lf = text.iter().filter(|b| **b == b'\n').count();
assert!(crlf > 0, "the CRLF file came back as LF");
assert_eq!(crlf, lf, "the file was left with a mixture of endings");
let again = output(data.path(), &["export", "alpha", "--hub", hub.to_str().unwrap()]);
assert!(again.contains("already what the record says"), "{again}");
}
#[test]
fn doctor_names_a_generated_file_that_was_edited() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let hub = project(data.path(), "alpha");
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(&hub)
.arg("--adopt")
.assert()
.success();
let clean = output(data.path(), &["doctor", "--hubs"]);
assert!(clean.contains("every generated file matches"), "{clean}");
let text = std::fs::read_to_string(hub.join("План.md")).unwrap();
std::fs::write(hub.join("План.md"), format!("{text}\n\nдописано руками\n")).unwrap();
let drifted = output(data.path(), &["doctor", "--hubs"]);
assert!(drifted.contains("cannot vouch for"), "{drifted}");
assert!(drifted.contains("edited since it was generated"), "{drifted}");
assert!(drifted.contains("План.md"), "{drifted}");
assert!(drifted.contains("rigger import"), "{drifted}");
assert!(drifted.contains("rigger export"), "{drifted}");
let plain = output(data.path(), &["doctor"]);
assert!(!plain.contains("edited since"), "{plain}");
}
#[test]
fn only_the_three_generated_files_are_written() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
let hub = project(data.path(), "alpha");
std::fs::write(hub.join("Видение.md"), "# Видение\n\nПроза владельца.\n").unwrap();
std::fs::write(hub.join("Решения.md"), "# Решения\n\n## 2026-09-01 · Первое\n\nПочему.\n").unwrap();
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(&hub)
.arg("--adopt")
.assert()
.success();
assert_eq!(std::fs::read_to_string(hub.join("Видение.md")).unwrap(), "# Видение\n\nПроза владельца.\n");
assert_eq!(
std::fs::read_to_string(hub.join("Решения.md")).unwrap(),
"# Решения\n\n## 2026-09-01 · Первое\n\nПочему.\n"
);
}
#[test]
fn a_hub_that_is_not_a_directory_is_refused() {
let data = tempfile::tempdir().unwrap();
rigger(data.path()).arg("init").assert().success();
project(data.path(), "alpha");
rigger(data.path())
.args(["export", "alpha", "--hub"])
.arg(data.path().join("nowhere"))
.assert()
.failure()
.stderr(predicate::str::contains("not a directory"));
}