rigger 0.16.0

One seat for all your projects and tasks: a local record of what is done, what is next and when it ships - read by you and your coding assistant
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Writing a hub back out of the record, and the circle that has to close.
//!
//! Two properties hold this release up, and both are tested here rather
//! than described:
//!
//! - **Byte-stable.** The same record produces the same bytes. A generated
//!   file that churned would show up as a diff in the vault's git history
//!   on every run, and nobody would read those diffs again.
//! - **`import(export(db)) == db`.** Reading back what was written gives
//!   the record it came from. Anything that cannot survive that trip has no
//!   business being generated: it would be quietly lost the first time
//!   somebody ran the export.
//!
//! The fixtures are hubs the export itself produced, so the circle is
//! closed on material under the test's control. The real hubs of the line
//! are checked by a live run before the tag, not from here - a test that
//! read the owner's vault would fail for edits to a hub rather than for
//! edits to the code.

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));
}

/// A hub shaped the way the hubs of this line are actually written.
///
/// That last part is the point. An earlier version of this fixture gave a
/// stage prose both before and after its tasks, because that seemed a
/// plausible shape - and no hub of the twenty is written that way. The
/// model grew a field to hold the case, and the round trip fell from
/// twenty hubs out of twenty to five. A fixture is a small copy of
/// reality, not a guess at what reality might contain.
///
/// It does hold everything an export has to put back: a preamble, the
/// owner's questions, blocks that group stages, prose before the tasks in
/// one stage and after them in another, a changelog entry with a body, and
/// a diary of two entries on one day.
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 · Основание

**Сделано.** Первая запись.
";

/// A project whose hub holds the fixtures above.
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()
}

/// The generated file, without the mark that says it is generated.
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()
}

/// The whole claim of the release: what the record writes is what the hub
/// said, to the byte.
#[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");
    }
    // And the source hub is untouched: an export writes where it is told.
    assert_eq!(std::fs::read_to_string(hub.join("План.md")).unwrap(), PLAN);
}

/// The same record twice gives the same bytes. The mark carries no
/// timestamp for exactly this reason - a stamp would make every run a diff.
#[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);
}

/// A hub that does not run by version number keeps its own order. One
/// changelog of this line writes v0.17.0 above v0.17.1 - the patch was
/// written up after the release it patched - and sorting by number
/// transposed the two on every run.
#[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}");
}

/// A check with nothing to check says so. `doctor --hubs` used to look for
/// a hub beside the repository, and every hub of this line lives in a notes
/// vault instead - so it read no files at all and reported that every
/// generated file matched the record, which is the answer it gives when
/// everything is well.
#[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();

    // A project whose hub has never been imported or exported.
    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}");
}

/// A column an edited migration left out is put back on open.
///
/// An applied migration is frozen: migration 7 was edited while this stage
/// was being built, and a database that had already passed it never got
/// `after_prose`. It sat at the same schema version as a healthy one, so
/// nothing noticed until a live run failed on every query naming that
/// column.
#[test]
fn a_column_an_edited_migration_left_out_is_put_back() {
    let data = tempfile::tempdir().unwrap();
    rigger(data.path()).arg("init").assert().success();

    // A database in exactly that state: the version is current, the column
    // is not there.
    let db = data.path().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);

    // Opening the record puts it back, and the command that needs it works.
    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:?}");
}

/// The circle: reading back what was written gives the record it came from.
/// Every counter zero is the claim - anything that changed would be a fact
/// the export invented or lost.
#[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}");
    }
}

/// A file somebody has been writing in is not overwritten because a command
/// was run. The mark is what says the record owns a file, and only an
/// explicit handover puts it there.
#[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"));
    // Untouched.
    assert_eq!(std::fs::read_to_string(hub.join("План.md")).unwrap(), PLAN);

    // `--check` may look at it, because it writes nothing.
    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);

    // And `--adopt` hands it over, once, on purpose.
    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()));

    // After which no flag is needed again: the file says who owns it.
    rigger(data.path()).args(["export", "alpha", "--hub"]).arg(&hub).assert().success();
}

/// Every hub of this line is CRLF - they are written on Windows, in
/// Obsidian - and a generated file in LF would differ from its source on
/// every line. That is not a diff anybody reads; it is the file rewritten.
#[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");

    // Rewrite the hub as CRLF, as the real ones are, and import it again.
    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");

    // And a second export is quiet, so the ending does not churn.
    let again = output(data.path(), &["export", "alpha", "--hub", hub.to_str().unwrap()]);
    assert!(again.contains("already what the record says"), "{again}");
}

/// A generated file edited by hand has stopped being a view of the record,
/// and the next export would overwrite the edit without saying so.
#[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}");
    // Named with what to do about it, in both directions.
    assert!(drifted.contains("rigger import"), "{drifted}");
    assert!(drifted.contains("rigger export"), "{drifted}");

    // Off by default: it reads every hub from disk.
    let plain = output(data.path(), &["doctor"]);
    assert!(!plain.contains("edited since"), "{plain}");
}

/// The three files the record can rebuild, and no others. Vision, the
/// decision log's prose and the research notes are argument rather than
/// record, and nothing here should touch them.
#[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"));
}