vivac 0.11.1

Provenance tree for work: every node knows which node it was born from
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
//! The `brief`: a deterministic render bounded in tokens.
//!
//! `BRIEF-SPEC.md`. It answers three questions in order of importance: where
//! we are and how we got here, what governs this point, and **what is out of
//! scope right now**. The third is the one no other tool emits: every memory
//! tool dumps what is relevant, and the problem in agentic development is the
//! opposite one, bounding.
//!
//! Two rules override everything else:
//!
//! - **Same log + same `--now` + same anchor state -> same bytes.** Without
//!   `--now` determinism would be impossible, because ages are relative to
//!   the moment.
//! - **The spine is never truncated.** If it does not fit, the budget is
//!   wrong and it says so, but it comes out whole: it is the answer to
//!   question 1, and without it the brief has no reason to exist.

use crate::anchor::Anchor;
use crate::args::Args;
use crate::event::{Kind, State};
use crate::failure::R;
use crate::model::{Node, Tree};
use std::collections::HashSet;

const BUDGET: usize = 1500;
/// The whole brief is pure ASCII.
///
/// `BRIEF-SPEC.md` §7 draws the spine with box-drawing characters, but the DX
/// pillar demands it degrade without breaking "in cmd.exe as well as Windows
/// Terminal", and there any code page that is not UTF-8 turns them into
/// garbage. What is normative in §7 are the markers --that the focus be
/// visible, that a flag carry its reason, that an empty section not show--
const RULE: &str = "------------------------------------------------------------";

/// One section of the brief. The vector order is the one in §3, which is both
/// render order and priority order: truncation starts from the bottom.
struct Section {
    lines: Vec<String>,
    truncable: bool,
}

impl Section {
    fn fixed(lines: Vec<String>) -> Section {
        Section {
            lines,
            truncable: false,
        }
    }
    fn loose(lines: Vec<String>) -> Section {
        Section {
            lines,
            truncable: true,
        }
    }
}

/// Token estimator. It is an estimate and the ceiling is indicative: what
/// matters is that it be **deterministic**, so two runs of the same log
/// truncate the same way.
fn tokens(s: &str) -> usize {
    s.chars().count().div_ceil(4)
}

fn tokens_of(sections: &[Section]) -> usize {
    sections
        .iter()
        .flat_map(|s| s.lines.iter())
        .map(|l| tokens(l) + 1)
        .sum()
}

/// Truncates a list keeping the first `n`. An item from the middle is never
/// dropped in silence.
fn trim_list(mut v: Vec<String>, n: usize, which: &str) -> Vec<String> {
    if v.len() > n {
        let left_over = v.len() - n;
        v.truncate(n);
        v.push(format!("      ... and {left_over} more (vivac {which})"));
    }
    v
}

fn heading(title: &str, body: Vec<String>) -> Vec<String> {
    // Empty sections are omitted whole, heading included: a brief with nothing
    // parked does not say "DO NOT TOUCH NOW: (empty)".
    if body.is_empty() {
        return vec![];
    }
    let mut v = vec![String::new(), format!(" {title}")];
    v.extend(body);
    v
}

/// Constraints that govern the path.
///
/// **By `spawns` only.** Inheriting through `depends_on` as well would turn
/// the computation from O(depth) into O(graph), and would lose the property
/// that inheritance is legible by looking at the stack on screen.
pub(crate) fn constraints<'a>(a: &'a Tree, lineage: &[&Node]) -> Vec<&'a Node> {
    let on_lineage: HashSet<u64> = lineage.iter().map(|n| n.num).collect();
    let mut v: Vec<&Node> = a
        .nodes_iter()
        .filter(|n| n.kind == Kind::Constraint && n.state.is_open())
        .filter(|n| {
            // Project-wide, or reachable from the path. Project-wide means
            // hanging off a root **or being one**: `MODEL.md` §9.5 blesses
            // `parent: PROJECT`, and a node with no parent at all is the
            // strongest form of that, not a weaker one.
            let project_wide = n.parent.is_none()
                || n.parent
                    .and_then(|p| a.node_by_num(p))
                    .is_some_and(|p| p.parent.is_none());
            project_wide
                || a.ancestors(n.num)
                    .iter()
                    .any(|p| on_lineage.contains(&p.num))
        })
        .collect();
    // At risk first --the ones carrying a flag-- and then by alias.
    v.sort_by_key(|n| (n.flags.is_empty(), n.num));
    v
}

/// `t533` piece (c) (`f134`, `f55`): a node whose state is not open carries
/// its word, in brackets, right after the title -- the same word `why` and
/// `tree` already show (`render.rs`, `label`). Title and mark share the 44
/// columns the title alone used to have, so the two fit together; the mark
/// is never the part that gives. An open node keeps exactly the bytes it
/// always has.
fn spine_label(a: &Tree, n: &Node) -> String {
    if n.state.is_open() {
        return clip(n.title(a), 44);
    }
    let mark = format!("  [{}]", n.state.word(n.kind));
    let budget = 44usize.saturating_sub(mark.chars().count());
    format!("{}{mark}", clip(n.title(a), budget))
}

fn spine(a: &Tree, lineage: &[&Node]) -> Vec<String> {
    let mut v = Vec::new();
    for (i, n) in lineage.iter().enumerate() {
        let first = i == 0;
        let is_last = i == lineage.len() - 1;
        // Continuation: the trunk carries on while anything is left below.
        let cont = if is_last { "        " } else { "  |     " };

        let branch = if first {
            " GOAL ".to_string()
        } else if is_last {
            "  `-- ".to_string()
        } else {
            "  |-- ".to_string()
        };
        let flags: Vec<&str> = n.flags.keys().map(|b| b.word()).collect();
        let flag = if flags.is_empty() {
            String::new()
        } else {
            format!("  ! {}", flags.join(" "))
        };
        let here_mark = if is_last { "   <== HERE" } else { "" };
        v.push(format!(
            "{branch}{:<6} {}{flag}{here_mark}",
            n.alias(),
            spine_label(a, n)
        ));
        let why = n.why(a);
        if !first && !why.is_empty() {
            v.push(format!("{cont}why: {}", clip(why, 52)));
        }
        let governs = n.governs(a);
        if !governs.is_empty() {
            v.push(format!("{cont}governs: {}", governs.join(" ")));
        }
        if !is_last {
            v.push("  |".to_string());
        }
    }
    v
}

/// Cuts on a word boundary without exceeding `n`, **counting the ellipsis**.
/// Budgeting for it matters: otherwise the cut overruns on exactly the
/// tightest lines of the brief, which are the ones being truncated.
pub(crate) fn clip(s: &str, n: usize) -> String {
    if s.chars().count() <= n {
        return s.to_string();
    }
    let t: String = s.chars().take(n.saturating_sub(3)).collect();
    match t.rsplit_once(' ') {
        Some((a, _)) if !a.is_empty() => format!("{a}..."),
        _ => format!("{t}..."),
    }
}

/// Project level: hanging off nothing, or off a node that itself hangs off
/// nothing. `MODEL.md` §9.5 blesses `parent: PROJECT`, and a node with no
/// parent at all is the strongest form of that, not a weaker one.
/// `constraints()` above has drawn the line this way from the start; `t533`
/// piece (b) widens `standing()`'s own clause to the same shape, and the
/// no-focus path in `to_text` stands on nothing else.
fn project_wide(a: &Tree, n: &Node) -> bool {
    n.parent.is_none()
        || n.parent
            .and_then(|p| a.node_by_num(p))
            .is_some_and(|p| p.parent.is_none())
}

/// Standing decisions that reach the focus: project-level, on the path, or
/// with a `governs` overlapping the focus's own. Superseded ones never
/// appear. Always called with a real focus; with none, `to_text` reads
/// `project_wide` on its own instead, since there is neither a path nor a
/// `governs` to overlap.
pub(crate) fn standing<'a>(a: &'a Tree, focus: &Node, on_lineage: &HashSet<u64>) -> Vec<&'a Node> {
    let mut dec: Vec<&Node> = a
        .nodes_iter()
        .filter(|n| n.kind == Kind::Decision && n.state.is_open())
        .filter(|n| {
            project_wide(a, n)
                || on_lineage.contains(&n.num)
                || n.parent.is_some_and(|p| on_lineage.contains(&p))
                || n.governs(a)
                    .iter()
                    .any(|g| focus.governs(a).iter().any(|f| crate::glob::covers(g, f)))
        })
        .collect();
    dec.sort_by_key(|n| n.num);
    dec
}

/// Whether a node could ever show up in `OPEN GOALS`, kind-wise: a goal, or a
/// node with no parent at all, and never a pillar, a rule, a decision or a
/// constraint -- the same governance kinds `Node::is_front` already keeps out
/// of pending work. `t533` §3.6 (`f73`, `f456`).
fn is_goal_shaped(n: &Node) -> bool {
    !matches!(
        n.kind,
        Kind::Decision | Kind::Constraint | Kind::Pillar | Kind::Rule
    ) && (n.kind == Kind::Goal || n.parent.is_none())
}

/// What `BORN FROM HERE` lists: `focus`'s own open, front children, plus how
/// many more open fronts hang further down without being listed one by one.
///
/// `f49`: a blocking question is left out here, because `BLOCKS` already
/// lists it -- showing it twice says the same thing in two places for no
/// reason. A blocking task still shows, asterisk and all: only a question is
/// also a row of its own in `BLOCKS`.
fn born_from_here(a: &Tree, focus: &Node) -> Vec<String> {
    let mut children: Vec<String> = a
        .children(focus.num)
        .into_iter()
        .filter(|c| c.is_front())
        .filter(|c| !(c.kind == Kind::Question && c.blocks))
        .map(|c| {
            format!(
                "  {} {:<6} {}",
                if c.blocks { '*' } else { ' ' },
                c.alias(),
                c.title(a)
            )
        })
        .collect();
    // Closing a parent cannot make its open children invisible. They are
    // counted and the place to look is named; listing them here would drag in
    // the whole tree, which is exactly the noise the focus exists to keep
    // out.
    let direct: std::collections::HashSet<&str> = a
        .children(focus.num)
        .iter()
        .map(|c| c.id.as_str())
        .collect();
    let deep = a
        .descendants(focus.num)
        .into_iter()
        .filter(|n| n.is_front() && !direct.contains(n.id.as_str()))
        .filter(|n| !a.children(n.num).iter().any(|c| c.is_front()))
        .count();
    if deep > 0 {
        children.push(format!(
            "    + {deep} further down, outside this level   vivac open"
        ));
    }
    children
}

/// The fixed block that takes the spine's place with no focus (`t533`
/// §3.6). Never truncated, the same as the spine.
fn no_focus_block(a: &Tree) -> Vec<String> {
    let mut v = vec![" No active focus.".to_string()];

    let mut goals: Vec<&Node> = a
        .nodes_iter()
        .filter(|n| n.state.is_open() && is_goal_shaped(n))
        .collect();
    goals.sort_by_key(|n| n.num);

    if !goals.is_empty() {
        v.push(String::new());
        v.push(" OPEN GOALS".to_string());
        for m in &goals {
            v.push(format!(
                "  {:<6} {:<40} {} open below",
                m.alias(),
                clip(m.title(a), 40),
                a.counts(m.num).open_count
            ));
        }
    }

    v.push(String::new());
    if a.is_empty_tree() {
        v.push(" Start with:  vivac push \"<title>\" --why \"<reason>\"".to_string());
    } else if let Some(first) = goals.first() {
        v.push(format!(" Pick up with:  vivac focus {}", first.alias()));
        v.push(" Or open another:  vivac push \"<title>\" --why \"<reason>\"".to_string());
    } else {
        // Nothing open of that shape, but something parked would qualify if
        // it were open. `f50`: the action names a real id, never `<id>`.
        let mut parked: Vec<&Node> = a
            .nodes_iter()
            .filter(|n| n.state == State::Suspended && is_goal_shaped(n))
            .collect();
        parked.sort_by_key(|n| n.num);
        match parked.first() {
            Some(p) => {
                v.push(format!(" Pick up with:  vivac focus {}", p.alias()));
                v.push(" Or open another:  vivac push \"<title>\" --why \"<reason>\"".to_string());
            }
            None => {
                v.push(" Open the next one:  vivac push \"<title>\" --why \"<reason>\"".to_string())
            }
        }
    }
    v
}

pub fn brief(a: &Tree, anchor_of: &dyn Anchor, args: &Args, project: &str) -> R {
    print!("{}", to_text(a, anchor_of, args, project)?);
    Ok(())
}

/// The brief as text. `session start --hook` prints it straight to stdout
/// (`f403`, `f404`): Claude Code turns plain-text stdout on `SessionStart`
/// into context the agent can see and act on, so there is nothing further to
/// wrap it in.
pub fn to_text(
    a: &Tree,
    anchor_of: &dyn Anchor,
    args: &Args,
    project: &str,
) -> Result<String, crate::failure::Failure> {
    let today = args.opt("now").unwrap_or("").to_string();
    let today = if today.is_empty() {
        crate::clock::now_rfc3339()
    } else {
        today
    };
    let date = crate::clock::date_of(&today).to_string();
    let budget: usize = args
        .opt("budget")
        .and_then(|s| s.parse().ok())
        .unwrap_or(BUDGET);

    let lineage: Vec<&Node> = match a.stack.last() {
        Some(&num) => a.ancestors(num),
        None => vec![],
    };
    let focus: Option<&Node> = lineage.last().copied();

    let mut s: Vec<Section> = Vec::new();

    // 1. Header. 2. Spine, or -- with no focus -- the fixed block that takes
    // its place (`t533` §3.6). Neither is ever truncated, and the header is
    // the same either way: `lane` has never named more than one lane.
    s.push(Section::fixed(vec![
        format!("vivac · project: {project} · lane: main · {date}"),
        RULE.to_string(),
        String::new(),
    ]));
    s.push(Section::fixed(match focus {
        Some(_) => spine(a, &lineage),
        None => no_focus_block(a),
    }));

    // 3. Focus: what hangs off it unclosed. Standing decisions do not go in
    //    --they are not pending work and they have their own section (8)--,
    //    and whatever hangs further down is counted without being listed.
    //    Empty, and so omitted, with no focus to hang anything off.
    let born = focus.map(|f| born_from_here(a, f)).unwrap_or_default();
    s.push(Section::fixed(heading("BORN FROM HERE", born)));

    // 4. Invariants.
    let invariants: Vec<String> = constraints(a, &lineage)
        .iter()
        .map(|c| {
            let risk = if c.flags.is_empty() { "" } else { "   AT RISK" };
            format!("  {:<6} {}{risk}", c.alias(), c.title(a))
        })
        .collect();
    s.push(Section::fixed(heading("INVARIANTS", invariants)));

    // 5. Blocking questions: all of them, untruncated.
    let on_lineage: HashSet<u64> = lineage.iter().map(|n| n.num).collect();
    let questions: Vec<String> = a
        .nodes_iter()
        .filter(|n| n.kind == Kind::Question && n.state.is_open() && n.blocks)
        .filter(|n| {
            a.ancestors(n.num)
                .iter()
                .any(|p| on_lineage.contains(&p.num))
        })
        .map(|n| format!("  {:<6} {}", n.alias(), n.title(a)))
        .collect();
    let mut questions = questions;
    questions.sort();
    s.push(Section::fixed(heading("BLOCKS", questions)));

    // 6. Flags on the path, or one hop off it.
    let mut flagged: Vec<&Node> = a
        .nodes_iter()
        .filter(|n| !n.flags.is_empty())
        .filter(|n| {
            on_lineage.contains(&n.num) || n.parent.is_some_and(|p| on_lineage.contains(&p))
        })
        .collect();
    flagged.sort_by_key(|n| n.num);
    let flag_lines: Vec<String> = flagged
        .iter()
        .flat_map(|n| {
            n.flags.iter().map(move |(b, reason)| {
                format!(
                    "  {:<6} {:<10} {}",
                    n.alias(),
                    b.word(),
                    clip(a.text(*reason), 44)
                )
            })
        })
        .collect();
    s.push(Section::loose(heading(
        "FLAGGED",
        trim_list(flag_lines, 3, "stats"),
    )));

    // 7. Out of scope: every parked node of the project, regardless of the
    // focus (`d536`) -- so this section and `parked`'s own count agree
    // (`f60`). **This is the product's differentiator**, and it only has
    // content if `park` costs the same as `pop`.
    let mut parked_nodes: Vec<&Node> = a
        .nodes_iter()
        .filter(|n| n.state == State::Suspended)
        .collect();
    parked_nodes.sort_by_key(|n| n.num);
    let out_of_scope: Vec<String> = parked_nodes
        .iter()
        .flat_map(|n| {
            let hangs_off = n
                .parent
                .and_then(|p| a.node_by_num(p))
                .map(|p| format!("hangs off {}", p.alias()))
                .unwrap_or_default();
            let mut v = vec![format!(
                "  {:<6} {:<40} {hangs_off}",
                n.alias(),
                clip(n.title(a), 40)
            )];
            let outcome = n.outcome(a);
            if !outcome.is_empty() {
                v.push(format!("         \"{}\"", clip(outcome, 56)));
            }
            v
        })
        .collect();
    s.push(Section::loose(heading(
        "DO NOT TOUCH NOW",
        trim_list(out_of_scope, 6, "parked"),
    )));

    // 8. Standing decisions: project-level, on the path, or with a `governs`
    // overlapping the focus's own. Superseded ones never appear. With no
    // focus, only the project-level ones reach it: there is neither a path
    // nor a `governs` of the focus's own to overlap.
    //
    // **Project-level had been missing**, and it is the case that matters
    // most: a decision that governs the whole product hangs off nothing, so
    // it was on no path and reached no brief. The invariants above had the
    // clause and the decisions did not, which was an asymmetry and not a
    // choice.
    let dec: Vec<&Node> = match focus {
        Some(f) => standing(a, f, &on_lineage),
        None => {
            let mut d: Vec<&Node> = a
                .nodes_iter()
                .filter(|n| n.kind == Kind::Decision && n.state.is_open() && project_wide(a, n))
                .collect();
            d.sort_by_key(|n| n.num);
            d
        }
    };
    let decisions: Vec<String> = dec
        .iter()
        .map(|n| format!("  {:<6} {}", n.alias(), clip(n.title(a), 52)))
        .collect();
    s.push(Section::loose(heading(
        "STANDING DECISIONS",
        trim_list(decisions, 3, "tree"),
    )));

    // 9. Last vivac. Restoring is always restore + diff: a vivac is never
    // presented without saying what changed since.
    let vv: Vec<String> = match a.last_vivac() {
        None => vec![],
        Some(v) => {
            let mut l = vec![format!(
                "  {} · {} · {}{}",
                v.alias(),
                v.kind.word(),
                crate::clock::date_of(&v.ts),
                if v.anchor.is_empty_tree() {
                    String::new()
                } else {
                    format!(" · {}", v.anchor.short())
                }
            )];
            if !v.next_intent.is_empty() {
                l.push(format!(
                    "         you were about to: {}",
                    clip(&v.next_intent, 52)
                ));
            }
            // With no anchor no diff lines are invented: they are omitted, and
            // the date above stands in, which is the plain age there really is.
            if !v.anchor.is_empty_tree() {
                let changes = anchor_of.changed_since(&v.anchor);
                if !changes.is_empty() {
                    let touching = changes
                        .iter()
                        .filter(|c| {
                            v.working_set
                                .iter()
                                .any(|g| crate::glob::covers(g, &c.file_path))
                        })
                        .count();
                    l.push(format!(
                        "         {} changes since, {touching} touching what it governs",
                        changes.len()
                    ));
                }
            }
            l
        }
    };
    s.push(Section::loose(heading("LAST VIVAC", vv)));

    // 10. Freshness.
    let stale_ones: Vec<String> = lineage
        .iter()
        .filter(|n| n.flags.contains_key(&crate::event::Flag::Stale))
        .map(|n| format!("  {:<6} {}", n.alias(), n.title(a)))
        .collect();
    s.push(Section::loose(heading("UNTOUCHED FOR A WHILE", stale_ones)));

    emit(s, budget, a)
}

/// Assembles under budget. It is a **soft ceiling**: truncatable sections are
/// dropped from the bottom up until it fits; if it still does not fit, it is
/// emitted anyway with a warning. Going over budget is a sign the tree needs
/// pruning, not that the brief should lie by silent omission.
fn emit(mut s: Vec<Section>, budget: usize, a: &Tree) -> Result<String, crate::failure::Failure> {
    let requested = tokens_of(&s);
    while tokens_of(&s) > budget {
        match s.iter().rposition(|x| x.truncable && !x.lines.is_empty()) {
            Some(i) => s[i].lines.clear(),
            None => break,
        }
    }
    let spent = tokens_of(&s);

    let mut o = String::new();
    for l in s.iter().flat_map(|x| x.lines.iter()) {
        o.push_str(l);
        o.push('\n');
    }
    let parked_nodes = a
        .nodes_iter()
        .filter(|n| n.state == State::Suspended)
        .count();
    o.push_str(&format!(
        "
{RULE}
 {spent} tokens · depth {} · {parked_nodes} parked
",
        a.stack_depth()
    ));
    if spent > budget {
        o.push_str(&format!(
            "
 ! the brief is over budget ({spent}/{budget}).
   The spine is never truncated: what is left over is tree, not render.
   What can be pruned:  vivac triage
"
        ));
    } else if requested > budget {
        o.push_str(&format!(
            "
 ! {} tokens trimmed to fit in {budget}.
",
            requested - spent
        ));
    }
    Ok(o)
}

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

    #[test]
    fn the_estimator_is_deterministic() {
        assert_eq!(tokens("same"), 1);
        assert_eq!(tokens("same tokens"), 3);
        assert_eq!(tokens(""), 0);
        // Same text, same number, always.
        assert_eq!(tokens("abcdefgh"), tokens("12345678"));
    }

    #[test]
    fn trimming_says_what_is_missing() {
        let v: Vec<String> = (0..10).map(|i| format!("l{i}")).collect();
        let r = trim_list(v, 3, "parked");
        assert_eq!(r.len(), 4);
        assert_eq!(r[0], "l0");
        assert!(r[3].contains("7 more"), "{}", r[3]);
    }

    #[test]
    fn an_empty_section_leaves_no_heading() {
        assert!(heading("DO NOT TOUCH NOW", vec![]).is_empty());
        assert_eq!(heading("X", vec!["  a".into()]).len(), 3);
    }

    #[test]
    fn clipping_respects_words() {
        assert_eq!(clip("hello world", 20), "hello world");
        assert!(clip("a fairly long sentence that does not fit", 20).ends_with("..."));
        assert!(
            clip("a fairly long sentence that does not fit", 20)
                .chars()
                .count()
                <= 20
        );
    }
}