rigger 0.22.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
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
//! Writing a hub back out of the record.
//!
//! The inverse of `import`, and the point at which the hub stops being the
//! place work is written down and becomes a view of what was written down
//! somewhere else.
//!
//! What made this possible was not the writing but the reading. The import
//! used to take a hub's skeleton - version headings, checkboxes, dated
//! decisions - and leave the rest on disk. Measured on this project's own
//! hub before any of this existed: the changelog was 88% prose the record
//! had never held, and the plan 42%. Generating those files from the record
//! would have deleted most of them. So the prose came into the record first
//! (schema v7), and only then could a file be generated from it.
//!
//! Two rules hold the whole thing up:
//!
//! - **Byte-stable.** The same record produces the same bytes, every time.
//!   A generated file that churned would show up as a diff in the vault's
//!   git history on every run, and nobody would look at those diffs again.
//! - **Round-trippable.** 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 someone ran
//!   the export.

use std::fmt::Write as _;

use serde::Serialize;

use crate::hub::{DiaryEntry, Prose, Stage, StateLine};

/// The files a hub is generated from, in the order an export writes them.
///
/// Vision, the rituals and the research notes are not here: they are a
/// person's prose, and they live in the record as documents that
/// `export --docs` writes back out unchanged rather than rebuilds.
///
/// `Решения.md` is not here either, and building it is not a matter of
/// writing it. The owner's journal groups a sitting's decisions under one
/// themed heading (`2026-09-11 · Сверка планов линейки`) with bold
/// subheadings inside, and an event carries no theme: a generated file was
/// written, measured against the real hub, and would have replaced 147
/// lines of structured prose with 367 flat ones. It was deleted rather than
/// kept for later. The file stays the owner's until an event can carry a
/// theme; its preamble already lives in the record as a document.
pub const GENERATED: [&str; 4] = ["План.md", "Изменения.md", "Дневник.md", "README.md"];

/// A file the export produced.
#[derive(Debug, Clone, Serialize)]
pub struct Written {
    pub file: String,
    pub bytes: usize,
    /// Whether the file on disk already said exactly this.
    pub unchanged: bool,
}

/// What a hub writes under a heading whose list is empty, when the hub
/// itself said nothing.
///
/// The parser drops such a line on the way in - it is not a question - so
/// the export puts it back. Without it a hub with nothing waiting lost the
/// line on every run, and the file churned between two shapes. A hub that
/// wrote its own wording keeps it: turnout says `_Пусто._`.
pub const EMPTY_QUEUE: &str = "- (пусто)";

/// The marker that says a file is generated.
///
/// An HTML comment because it must survive being read by Obsidian, by git
/// and by the parser without becoming visible in the rendered note. It
/// carries no timestamp on purpose: a stamp would change on every run and
/// make a byte-stable file churn anyway.
pub const MARK: &str = "<!-- generated by rigger; edits here are overwritten -->";

/// The lines that differ between what is on disk and what the record says,
/// as a unified-ish diff: a few lines of context around each run of
/// changes, `-` for what the file has and `+` for what would replace it.
///
/// Without this `--check` named the files that would change and left the
/// person to guess what was in them - which is no use at all when the
/// question is "have I written something here that the record would
/// throw away".
pub fn diff(before: &str, after: &str, context: usize) -> Vec<String> {
    let old: Vec<&str> = before.lines().collect();
    let new: Vec<&str> = after.lines().collect();
    // The common head and tail, which is where nearly all of a hub sits:
    // an export usually rewrites one block in the middle.
    let head = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
    let tail = old[head..].iter().rev().zip(new[head..].iter().rev()).take_while(|(a, b)| a == b).count();
    if head == old.len() && old.len() == new.len() {
        return Vec::new();
    }
    let mut out = Vec::new();
    let from = head.saturating_sub(context);
    for line in &old[from..head] {
        out.push(format!("  {line}"));
    }
    for line in &old[head..old.len() - tail] {
        out.push(format!("- {line}"));
    }
    for line in &new[head..new.len() - tail] {
        out.push(format!("+ {line}"));
    }
    let tail_start = old.len() - tail;
    for line in &old[tail_start..(tail_start + context).min(old.len())] {
        out.push(format!("  {line}"));
    }
    out
}

/// Whether a file was generated by rigger.
pub fn is_generated(text: &str) -> bool {
    text.lines().take(5).any(|l| l.trim() == MARK)
}

/// The plan: the stages still to come, with the prose that surrounds them.
pub fn plan(prose: &[Prose], stages: &[Stage], questions: &[String]) -> String {
    let mut out = String::new();
    out.push_str(MARK);
    out.push_str("\n\n");

    interleave(&mut out, prose, stages, questions);
    finish(out)
}

/// The changelog: what shipped, newest first.
pub fn changes(prose: &[Prose], stages: &[Stage]) -> String {
    let mut out = String::new();
    out.push_str(MARK);
    out.push_str("\n\n");
    interleave(&mut out, prose, stages, &[]);
    finish(out)
}

/// Writes the prose and the stages back in the order the file had them.
///
/// A hub groups its stages under block headings, and the blocks are prose;
/// each stage remembers how many runs stood before it. Without that the
/// export could only pile every stage below every heading, which on a real
/// plan lost the grouping entirely and moved twenty stages.
fn interleave(out: &mut String, prose: &[Prose], stages: &[Stage], questions: &[String]) {
    let mut next = 0usize;
    for (n, run) in prose.iter().enumerate() {
        write_run(out, run, questions);
        // `after_prose` counts the runs that stood before the stage, so a
        // stage recorded as following five runs is written once five have
        // been written - that is, after the run at index four.
        while next < stages.len() && stages[next].after_prose <= n + 1 {
            write_stage(out, &stages[next]);
            next += 1;
        }
    }
    // Whatever is left belongs after the last run: the stages of the final
    // block, and every stage of a record that has no prose at all.
    for stage in &stages[next..] {
        write_stage(out, stage);
    }
}

/// The diary: one entry per sitting, newest first.
pub fn diary(prose: &[Prose], entries: &[DiaryEntry]) -> String {
    let mut out = String::new();
    out.push_str(MARK);
    out.push_str("\n\n");
    for run in prose {
        write_run(&mut out, run, &[]);
    }
    for entry in entries {
        // The heading as written. A hub puts the time of day beside the date
        // (`2026-09-03 (ночь, позже) · v0.2.2`), and composing one from the
        // date plus a remainder inserted a separator that was never there.
        let _ = match &entry.heading {
            Some(heading) => writeln!(out, "## {}\n", heading.trim()),
            None => writeln!(out, "## {}\n", entry.date),
        };
        if !entry.body.trim().is_empty() {
            out.push_str(entry.body.trim());
            // When no rule follows, the blank lines after the entry are
            // the separator, and a diary chooses how many: this line has
            // one that leaves two where it writes no rule at all.
            out.push('\n');
            for _ in 0..if entry.followed_by_rule { 1 } else { entry.gap_after } {
                out.push('\n');
            }
        }
        // The rule that stood between this entry and the next belongs to
        // neither, so it is written back between them rather than kept in
        // either body.
        if entry.followed_by_rule {
            out.push_str("---\n");
            for _ in 0..entry.gap_after {
                out.push('\n');
            }
        }
    }
    finish(out)
}

/// The README: its prose, with the state block written from the record.
///
/// The block is a list the record holds, so it is written from there rather
/// than from the prose captured alongside it - otherwise a line recorded
/// today would stand in the file twice. Everything else in the README is a
/// person's writing and passes through untouched.
pub fn readme(prose: &[Prose], state: &[StateLine]) -> String {
    let mut out = String::new();
    out.push_str(MARK);
    out.push_str("\n\n");
    for run in prose {
        // A hub that writes prose under that heading rather than a list has
        // no state lines, and its prose is what belongs there: one hub of
        // this line writes two paragraphs, and printing an empty list in
        // their place deleted them.
        if !state.is_empty()
            && run
                .heading
                .as_deref()
                .is_some_and(|h| h.trim_start_matches('#').trim().starts_with("Состояние"))
        {
            out.push_str(run.heading.as_deref().unwrap().trim_end());
            out.push_str("\n\n");
            // Each line keeps the gap it had: a hub need not be consistent,
            // and one of this line spaces its three newest entries and
            // packs the fifty-three below them.
            for line in state {
                let _ = match &line.stamp {
                    Some(stamp) => writeln!(out, "- **{}** — {}", stamp.trim(), line.body.trim()),
                    None => writeln!(out, "- {}", line.body.trim()),
                };
                for _ in 0..line.gap_after {
                    out.push('\n');
                }
            }
            out.push('\n');
            continue;
        }
        write_run(&mut out, run, &[]);
    }
    finish(out)
}

/// One run of prose, and the questions if this is the run they belong under.
fn write_run(out: &mut String, run: &Prose, questions: &[String]) {
    if let Some(heading) = &run.heading {
        out.push_str(heading.trim_end());
        out.push_str("\n\n");
    }
    // The owner's open questions are a list the record holds, so they are
    // written from it rather than from the prose captured alongside them -
    // otherwise answering one in the record would leave it standing in the
    // file, and the captured copy would print it a second time.
    if run.heading.as_deref().is_some_and(|h| h.contains("Ждёт решения владельца")) {
        write_questions(out, run, questions);
        return;
    }
    if !run.body.trim().is_empty() {
        out.push_str(run.body.trim());
        // The blank lines the hub left after this run, not one for
        // everybody: a rule with the next heading straight under it and
        // a rule with two blank lines after it are both written in this
        // line, and one gap for all of them moved every line below the
        // first run that disagreed.
        out.push('\n');
        for _ in 0..run.gap_after {
            out.push('\n');
        }
    }
}

/// Whether a line opens a list item, and the marker it used.
///
/// `1. `, `- `, `* ` and the checkbox forms all open one; the marker comes
/// back whole so an export can write the same one. A numbered item yields
/// an empty marker, because its number is its own.
fn item_marker(line: &str) -> Option<&str> {
    let t = line.trim_start();
    for lead in ["- [ ] ", "- [x] ", "- [X] ", "- ", "* "] {
        if t.starts_with(lead) {
            return Some(lead);
        }
    }
    let (n, _) = t.split_once(". ")?;
    (!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then_some("")
}

/// How a hub writes its queue of questions: the marker its items use, and
/// whether a blank line stands between them.
///
/// The record holds the questions, not their shape, and writing one shape
/// for everybody rewrote every hub that had chosen another - furca and
/// scheda pack their numbered items, this project's own hub spaces them,
/// austeris and nooma use bullets. What a person chose stays chosen.
struct ListStyle<'a> {
    marker: &'a str,
    spaced: bool,
}

fn list_style(body: &str) -> ListStyle<'_> {
    let mut style = ListStyle { marker: "", spaced: false };
    let mut seen = false;
    let mut blank_since_item = false;
    for line in body.lines() {
        match item_marker(line) {
            Some(marker) => {
                match seen {
                    false => {
                        style.marker = marker;
                        seen = true;
                    }
                    // Only a blank line standing between two items spaces
                    // the list; one before the prose that follows it does
                    // not, and counting it would space every hub.
                    true if blank_since_item => style.spaced = true,
                    true => {}
                }
                blank_since_item = false;
            }
            None if line.trim().is_empty() => blank_since_item = true,
            None => blank_since_item = false,
        }
    }
    style
}

/// The line a hub writes in place of a queue that is empty.
///
/// `parse_questions` drops it - it is not a question - but the prose
/// captured beside the list still carries it, so an export can put back
/// the words the hub chose rather than one phrase for everybody. A hub
/// that wrote nothing there gets the placeholder every other hub uses.
fn empty_marker(body: &str) -> String {
    body.lines()
        .find(|l| item_marker(l).is_some())
        .map(|l| l.trim().to_string())
        .unwrap_or_else(|| match body.trim().is_empty() {
            // Nothing at all under the heading: the hub has not said how it
            // writes an empty queue, so it gets the common placeholder.
            true => EMPTY_QUEUE.to_string(),
            // Prose, and no list: turnout writes `_Пусто._` this way, and
            // `after` already holds it - adding a placeholder would print
            // the same fact twice.
            false => String::new(),
        })
}

/// The prose that stood before the list and the prose that stood after it.
///
/// A hub may introduce its queue and may close it: austeris ends the
/// section with a rule, nooma follows the list with a paragraph, and
/// putting either on the wrong side of the list reorders the section.
fn split_around_list(body: &str) -> (String, String) {
    let lines: Vec<&str> = body.lines().collect();
    let first = lines.iter().position(|l| item_marker(l).is_some());
    let last = lines.iter().rposition(|l| item_marker(l).is_some());
    match (first, last) {
        (Some(first), Some(last)) => (lines[..first].join("\n").trim().to_string(), lines[last + 1..].join("\n").trim().to_string()),
        // No list at all: the placeholder is dropped on the way in, so
        // whatever stands here is prose that follows the queue.
        _ => (String::new(), body.trim().to_string()),
    }
}

/// The questions under their heading, and whatever else was written there.
///
/// The list comes from the record - answering a question there has to
/// remove it from the file - but everything about how it is written comes
/// from the body the hub had: the marker, the spacing between items, and
/// which side of the list the surrounding sentences sat on.
fn write_questions(out: &mut String, run: &Prose, questions: &[String]) {
    let body = run.body.trim();
    let style = list_style(body);
    let (before, after) = split_around_list(body);
    let placeholder = empty_marker(body);

    if !before.is_empty() {
        out.push_str(&before);
        out.push_str("\n\n");
    }
    // An empty queue is written as the placeholder, not as a blank space
    // under a heading: `parse_questions` drops such a line - it is not a
    // question - so the export has to put it back, or a hub with nothing
    // waiting would lose it on every run. Which line to put back comes
    // from the body, which still carries it: hubs write `- (пусто)`, and
    // turnout writes `_Пусто._` as prose, where it needs no help.
    if questions.is_empty() && !placeholder.is_empty() {
        out.push_str(&placeholder);
        out.push_str("\n\n");
    }
    let last = questions.len().saturating_sub(1);
    for (n, question) in questions.iter().enumerate() {
        match style.marker {
            "" => {
                let _ = write!(out, "{}. {}", n + 1, question.trim());
            }
            lead => {
                let _ = write!(out, "{lead}{}", question.trim());
            }
        }
        out.push('\n');
        if style.spaced || n == last {
            out.push('\n');
        }
    }
    if !after.is_empty() {
        out.push_str(&after);
        // The blank lines this run ended with, like any other run: one
        // hub closes the queue with a rule and two blank lines under it.
        out.push('\n');
        for _ in 0..run.gap_after {
            out.push('\n');
        }
    }
}

fn write_stage(out: &mut String, stage: &Stage) {
    // The depth the hub used. A hub nests its stages under block headings,
    // and writing them all at one level would flatten the document.
    let hashes = "#".repeat(stage.depth.clamp(1, 6));
    // The heading as it was written, when the record kept it. Composing one
    // from the parts cannot be right: the same hub writes both `выпущен`
    // and `выпущена`, because the word agrees with whatever noun the owner
    // had in mind, and the first live run rewrote three real headings by
    // choosing a gender for them.
    let heading = match stage.heading.trim() {
        "" => {
            let title = stage.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
            let shipped = stage.shipped_on.as_deref().map(|d| format!(" — выпущен {d}")).unwrap_or_default();
            format!("{}{title}{shipped}", stage.version)
        }
        written => written.to_string(),
    };
    let _ = writeln!(out, "{hashes} {heading}\n");

    // Prose before the tasks and prose after them, each on its own side.
    // A plan explains a stage first and concludes it afterwards, and most
    // stages of this line do both: writing them all on one side moved the
    // closing line of 123 stages above their own lists.
    let notes = stage.notes.trim();
    let closes_here = stage.tasks.is_empty() && stage.notes_after.trim().is_empty();
    if !notes.is_empty() {
        out.push_str(notes);
        // When nothing follows this prose, it is the end of the stage,
        // and the blank lines the hub left there are its own: this line
        // writes a rule with the next heading straight under it as
        // readily as one with two blank lines after it.
        out.push('\n');
        for _ in 0..if closes_here { stage.gap_after } else { 1 } {
            out.push('\n');
        }
    }
    for task in &stage.tasks {
        let mark = if task.done { "x" } else { " " };
        let _ = writeln!(out, "- [{mark}] {}", task.title.trim());
    }
    if !stage.tasks.is_empty() {
        out.push('\n');
    }
    let after = stage.notes_after.trim();
    if !after.is_empty() {
        out.push_str(after);
        // The blank lines the hub left after this stage, not one for
        // everybody: a rule with the next heading straight under it
        // and a rule with two blank lines after it are both written in
        // this line.
        out.push('\n');
        for _ in 0..stage.gap_after {
            out.push('\n');
        }
    }
}

/// The line ending a file already uses, so an export does not convert it.
///
/// 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 single line. That is not a diff anybody reads; it is the file
/// being rewritten. What a person chose stays chosen.
pub fn line_ending(existing: &str) -> &'static str {
    match existing.contains("\r\n") {
        true => "\r\n",
        false => "\n",
    }
}

/// Rewrites a generated file in the ending its predecessor used.
pub fn with_line_ending(text: &str, ending: &str) -> String {
    match ending {
        "\r\n" => text.replace('\n', "\r\n"),
        _ => text.to_string(),
    }
}

/// One trailing newline, whatever the sections left behind.
///
/// Byte stability is the point: a file that ends in a different number of
/// blank lines depending on what its last section was would show up as a
/// diff in the vault's git history on every run.
fn finish(mut out: String) -> String {
    while out.ends_with('\n') {
        out.pop();
    }
    out.push('\n');
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hub::Task;

    fn stage(version: &str, title: Option<&str>, shipped: Option<&str>, notes: &str, tasks: &[(&str, bool)]) -> Stage {
        Stage {
            version: version.to_string(),
            title: title.map(str::to_string),
            shipped_on: shipped.map(str::to_string),
            notes: notes.to_string(),
            depth: 2,
            notes_after: String::new(),
            heading: String::new(),
            after_prose: usize::MAX,
            gap_after: 1,
            rank: 0,
            tasks: tasks
                .iter()
                .map(|(t, done)| Task {
                    title: t.to_string(),
                    done: *done,
                })
                .collect(),
        }
    }

    fn run(position: usize, heading: Option<&str>, body: &str) -> Prose {
        Prose {
            file: "План.md".to_string(),
            position,
            heading: heading.map(str::to_string),
            body: body.to_string(),
            gap_after: 1,
        }
    }

    #[test]
    fn a_generated_file_says_so_at_the_top() {
        let text = plan(&[], &[stage("v0.1.0", Some("First"), None, "", &[])], &[]);
        assert!(is_generated(&text), "{text}");
        assert!(text.starts_with(MARK), "{text}");
        // And a file nobody generated is not mistaken for one.
        assert!(!is_generated("# План\n\nПрроза.\n"));
    }

    /// The mark carries no timestamp on purpose: a stamp would change on
    /// every run, and the file would churn in the vault's git history even
    /// when the record had not moved.
    #[test]
    fn the_same_record_writes_the_same_bytes() {
        let prose = [run(0, Some("# План"), "Преамбула.")];
        let stages = [stage("v0.1.0", Some("First"), None, "Заметка.", &[("one", false)])];
        let first = plan(&prose, &stages, &["Вопрос?".to_string()]);
        let second = plan(&prose, &stages, &["Вопрос?".to_string()]);
        assert_eq!(first, second);
        assert!(!MARK.contains(char::is_numeric), "the mark carries a number that will change: {MARK}");
    }

    #[test]
    fn a_file_ends_with_exactly_one_newline() {
        // Whatever the last section was: a stage with tasks, a stage
        // without, a run of prose, or nothing at all.
        for text in [
            plan(&[], &[stage("v0.1.0", None, None, "", &[("one", false)])], &[]),
            plan(&[], &[stage("v0.1.0", None, None, "note", &[])], &[]),
            plan(&[run(0, Some("# План"), "Преамбула.")], &[], &[]),
            plan(&[], &[], &[]),
        ] {
            assert!(text.ends_with('\n'), "{text:?}");
            assert!(!text.ends_with("\n\n"), "{text:?}");
        }
    }

    #[test]
    fn a_stage_keeps_its_prose_and_its_tasks() {
        let text = changes(
            &[],
            &[stage(
                "v0.2.0",
                Some("Second"),
                Some("2026-09-05"),
                "Что было сделано и почему.",
                &[("done thing", true), ("open thing", false)],
            )],
        );
        assert!(text.contains("## v0.2.0 · Second — выпущен 2026-09-05"), "{text}");
        assert!(text.contains("Что было сделано и почему."), "{text}");
        assert!(text.contains("- [x] done thing"), "{text}");
        assert!(text.contains("- [ ] open thing"), "{text}");
    }

    /// The questions are a list the record holds, not prose. Written from
    /// the record, answering one makes it leave the file; kept as prose it
    /// would stand there after it had been settled.
    #[test]
    fn the_owners_questions_are_written_under_their_heading() {
        let prose = [run(0, Some("# План"), "Преамбула."), run(1, Some("## Ждёт решения владельца"), "")];
        let text = plan(&prose, &[], &["Первый вопрос?".to_string(), "Второй.".to_string()]);
        let at_heading = text.find("Ждёт решения владельца").unwrap();
        let at_first = text.find("Первый вопрос?").unwrap();
        assert!(at_heading < at_first, "{text}");
        assert!(text.contains("1. Первый вопрос?"), "{text}");
        assert!(text.contains("2. Второй."), "{text}");
    }

    /// The queue is written in the shape the hub had. Three hubs of this
    /// line write three shapes - packed numbers, spaced numbers, bullets -
    /// and one shape for everybody rewrote two of them on every run.
    #[test]
    fn the_queue_keeps_the_shape_the_hub_wrote_it_in() {
        let questions = ["Первый?".to_string(), "Второй?".to_string()];
        let shape = |body: &str| {
            let prose = [run(0, Some("## Ждёт решения владельца"), body)];
            plan(&prose, &[], &questions)
        };
        assert!(
            shape("1. Старый первый.\n2. Старый второй.").contains("1. Первый?\n2. Второй?"),
            "packed numbers"
        );
        assert!(
            shape("1. Старый первый.\n\n2. Старый второй.").contains("1. Первый?\n\n2. Второй?"),
            "spaced numbers"
        );
        assert!(shape("- Старый первый.\n- Старый второй.").contains("- Первый?\n- Второй?"), "bullets");
        assert!(shape("- [ ] Старый первый.").contains("- [ ] Первый?"), "checkboxes");
    }

    /// Prose on either side of the queue stays on its side. austeris closes
    /// the section with a rule and nooma follows the list with a paragraph;
    /// writing both after the list reordered whichever came first.
    #[test]
    fn prose_around_the_queue_keeps_its_side() {
        let prose = [run(0, Some("## Ждёт решения владельца"), "Введение.\n\n- Старый.\n\n---")];
        let text = plan(&prose, &[], &["Вопрос?".to_string()]);
        let at_intro = text.find("Введение.").unwrap();
        let at_question = text.find("Вопрос?").unwrap();
        let at_rule = text.find("---").unwrap();
        assert!(at_intro < at_question && at_question < at_rule, "{text}");
    }

    /// A stage explained before its list and concluded after it keeps both
    /// halves on their own side. 123 stages of this line are written that
    /// way, and one field with a flag for its side wrote the closing line
    /// of every one of them above its own list.
    #[test]
    fn a_stage_keeps_prose_on_both_sides_of_its_list() {
        let mut s = stage("v0.1.0", None, None, "Что делаем.", &[("Первая", false), ("Вторая", false)]);
        s.notes_after = "**Результат:** сделано.".to_string();
        let text = plan(&[], &[s], &[]);
        let at_intro = text.find("Что делаем.").unwrap();
        let at_task = text.find("Первая").unwrap();
        let at_result = text.find("**Результат:**").unwrap();
        assert!(at_intro < at_task, "the opening prose stands above the list\n{text}");
        assert!(at_task < at_result, "the closing prose stands below the list\n{text}");
    }

    #[test]
    fn a_diary_entry_keeps_its_date_and_heading() {
        let entries = [
            DiaryEntry {
                date: "2026-09-05".to_string(),
                heading: Some("2026-09-05 · v0.13.0 «Сессии»".to_string()),
                body: "Что делали.".to_string(),
                followed_by_rule: false,
                gap_after: 1,
                rank: 0,
            },
            DiaryEntry {
                date: "2026-09-01".to_string(),
                heading: None,
                body: "Раньше.".to_string(),
                followed_by_rule: false,
                gap_after: 1,
                rank: 0,
            },
        ];
        let text = diary(&[], &entries);
        assert!(text.contains("## 2026-09-05 · v0.13.0 «Сессии»"), "{text}");
        assert!(text.contains("## 2026-09-01\n"), "{text}");
        // Newest first, as the hub writes it.
        assert!(text.find("2026-09-05").unwrap() < text.find("2026-09-01").unwrap(), "{text}");
    }

    /// A project nobody has written prose for still gets its stages: an
    /// export of a bare record is not an empty file.
    #[test]
    fn stages_are_written_even_without_any_prose() {
        let text = plan(&[], &[stage("v0.1.0", Some("First"), None, "", &[])], &[]);
        assert!(text.contains("## v0.1.0 · First"), "{text}");
    }

    /// And with prose, the stages come after it rather than before: the runs
    /// carry the file's shape, and a stage above the preamble would read as
    /// a different document.
    #[test]
    fn stages_follow_the_prose_that_framed_them() {
        let prose = [run(0, Some("# План"), "Преамбула."), run(1, Some("## Блок"), "")];
        let text = plan(&prose, &[stage("v0.1.0", None, None, "", &[])], &[]);
        assert!(text.find("Преамбула.").unwrap() < text.find("## v0.1.0").unwrap(), "{text}");
        assert!(text.find("## Блок").unwrap() < text.find("## v0.1.0").unwrap(), "{text}");
    }
}