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
}
const PLAN: &str = "\
# План
## Блок «Первый»
### v0.2.0 · Второй
- [ ] сделать одно
### v0.3.0 · Третий
- [ ] сделать другое
";
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 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) -> 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("План.md"))
}
#[test]
fn a_stage_recorded_without_a_shape_takes_the_plans() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("План.md"), PLAN).unwrap();
let db = rusqlite::Connection::open(data.path().join("profiles").join("line").join("rigger.db")).unwrap();
for name in ["v0.2.0", "v0.3.0"] {
db.execute("INSERT INTO versions (project_id, name, status) VALUES (1, ?1, 'planned')", [name])
.unwrap();
}
drop(db);
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
assert_eq!(export(data.path()), PLAN);
}
#[test]
fn a_plan_reordered_by_hand_reimports_in_the_new_order() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("План.md"), PLAN).unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
assert_eq!(export(data.path()), PLAN);
let moved = "\
# План
## Блок «Первый»
### v0.3.0 · Третий
- [ ] сделать другое
## Блок «Второй»
### v0.2.0 · Второй
- [ ] сделать одно
";
std::fs::write(hub.join("План.md"), moved).unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
assert_eq!(export(data.path()), moved);
}
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_shipped_since_the_last_import_leads_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"]);
std::fs::write(hub.join("План.md"), "# План\n\n## v0.2.0 · Второй\n\n- [x] сделать одно\n").unwrap();
let changes = "\
# Изменения
Закрытые этапы, новые сверху.
## v0.1.0 · Первый — выпущена 2026-09-01
Что было сделано.
## Основание — закрыто 2026-08-30
Проза в конце файла.
";
std::fs::write(hub.join("Изменения.md"), changes).unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
git(&root, &["tag", "v0.2.0"]);
rigger(data.path()).args(["sync", "sample"]).assert().success();
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "sample", "--hub"]).arg(&out).assert().success();
let written = body(&out.join("Изменения.md"));
let second = written.find("## v0.2.0").expect("the shipped version is in the changelog");
let first = written.find("## v0.1.0").unwrap();
let prose = written.find("## Основание").unwrap();
assert!(second < first && first < prose, "{written}");
assert!(written.starts_with("# Изменения\n\nЗакрытые этапы, новые сверху.\n\n## v0.2.0"), "{written}");
}
#[test]
fn a_stale_plan_neither_reopens_a_closed_task_nor_shapes_a_shipped_stage() {
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"]);
let plan = "\
# План
## Блок «Первый»
### v0.2.0 · Второй
- [ ] сделать одно
- [ ] сделать другое
**Результат:** что-то работает.
";
std::fs::write(hub.join("План.md"), plan).unwrap();
std::fs::write(hub.join("Изменения.md"), "# Изменения\n\n## v0.1.0 · Первый — выпущена 2026-09-01\n\nБыло.\n").unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
let db = rusqlite::Connection::open(data.path().join("profiles").join("line").join("rigger.db")).unwrap();
db.execute(
"UPDATE tasks SET status = 'done', closed_at = '2026-09-02T10:00:00Z' WHERE title = 'сделать одно'",
[],
)
.unwrap();
drop(db);
git(&root, &["tag", "v0.2.0"]);
rigger(data.path()).args(["sync", "sample"]).assert().success();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "sample", "--hub"]).arg(&out).assert().success();
let changes = body(&out.join("Изменения.md"));
let plan_out = body(&out.join("План.md"));
assert!(
changes.contains("## v0.2.0 · Второй — выпущен"),
"the stage is a changelog entry, at the changelog's depth: {changes}"
);
assert!(
changes.contains("- [x] сделать одно\n- [ ] сделать другое"),
"the closed task stays closed: {changes}"
);
assert!(changes.contains("**Результат:** что-то работает."), "its prose travels with it: {changes}");
assert!(
changes.find("## v0.2.0").unwrap() < changes.find("## v0.1.0").unwrap(),
"newest first: {changes}"
);
assert!(!plan_out.contains("v0.2.0"), "a shipped stage has left the plan: {plan_out}");
}
#[test]
fn a_stage_in_both_files_keeps_the_changelogs_shape() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let plan = "\
# План
## Карта
### v0.1.0 · Первый
Уже вышел.
### v0.2.0 · Второй
- [ ] сделать одно
";
let changes = "\
# Изменения
## v0.1.0 · Первый — выпущена 2026-09-01
Что было сделано.
";
std::fs::write(hub.join("План.md"), plan).unwrap();
std::fs::write(hub.join("Изменения.md"), changes).unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "sample", "--hub"]).arg(&out).assert().success();
assert_eq!(body(&out.join("Изменения.md")), changes);
}
#[test]
fn a_generated_hub_read_back_strikes_nothing() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
let plan = "\
# План
## v0.1.0 · Первый
- [ ] сделать одно
## v0.2.0 · Второй
- [ ] сделать другое
";
std::fs::write(hub.join("План.md"), plan).unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
let count = |what: &str| -> i64 {
let db = rusqlite::Connection::open(data.path().join("profiles").join("line").join("rigger.db")).unwrap();
let sql = format!("SELECT COUNT(*) FROM {what} WHERE status = 'dropped'");
db.query_row(&sql, [], |r| r.get(0)).unwrap()
};
assert_eq!(count("versions"), 0, "nothing is struck yet");
let out = data.path().join("out");
std::fs::create_dir_all(&out).unwrap();
rigger(data.path()).args(["export", "sample", "--hub"]).arg(&out).assert().success();
assert!(
std::fs::read_to_string(out.join("План.md")).unwrap().contains("generated by rigger"),
"the export marks what it wrote"
);
let partial = "\
<!-- generated by rigger; edits here are overwritten -->
# План
## v0.2.0 · Второй
- [ ] сделать другое
";
std::fs::write(out.join("План.md"), partial).unwrap();
std::fs::write(out.join("Изменения.md"), "# Изменения\n").unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&out).assert().success();
assert_eq!(count("versions"), 0, "a generated plan describes the record; it does not strike from it");
assert_eq!(count("tasks"), 0, "and neither does it strike the tasks of a stage it no longer lists");
}
#[test]
fn a_hub_that_is_not_there_is_refused_rather_than_read_as_empty() {
let data = tempfile::tempdir().unwrap();
let hub = project(data.path());
std::fs::write(hub.join("План.md"), "# План\n\n## v0.1.0 · Первый\n\n- [ ] сделать одно\n").unwrap();
rigger(data.path()).args(["import", "sample", "--hub"]).arg(&hub).assert().success();
let dropped = || -> i64 {
let db = rusqlite::Connection::open(data.path().join("profiles").join("line").join("rigger.db")).unwrap();
db.query_row("SELECT COUNT(*) FROM versions WHERE status = 'dropped'", [], |r| r.get(0))
.unwrap()
};
assert_eq!(dropped(), 0);
rigger(data.path())
.args(["import", "sample", "--hub"])
.arg(data.path().join("no-such-hub"))
.assert()
.failure()
.stderr(predicate::str::contains("is not there"));
assert_eq!(dropped(), 0, "a path that is not there strikes nothing");
let empty = data.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
rigger(data.path())
.args(["import", "sample", "--hub"])
.arg(&empty)
.assert()
.failure()
.stderr(predicate::str::contains("not a hub"));
assert_eq!(dropped(), 0, "an empty directory strikes nothing either");
}