mandible_core/snapshot.rs
1//! Stable, human-reviewable snapshot serialization of [`CommandNode`] trees.
2//!
3//! This is the format `corpus/README.md`'s `expected.snap` fixtures are
4//! written in (spec §13.2), and the format the (not-yet-built) `cargo xtask
5//! corpus` runner will diff against. It lives here rather than in `xtask` or
6//! `mandible-extract` because this crate owns the IR, and both a
7//! workspace-level `xtask` and crate-level tests (`mandible-extract`'s own
8//! pipeline tests) need to agree on exactly one definition of "what a
9//! snapshot looks like" — two independent definitions could silently drift.
10//!
11//! # Why this is a *separate* serialization from `CommandNode`'s own derive
12//!
13//! `CommandNode` (and `Flag`, `Positional`, `Provenance`, ...) already derive
14//! `Serialize`/`Deserialize` for round-tripping — e.g. the `Transcript`
15//! replay seam (`mandible-extract/src/exec/probe.rs`). That derive is
16//! full-fidelity by design: every field, every `None`, every empty `Vec`,
17//! exactly as stored, because a round trip must be lossless.
18//!
19//! A snapshot has a different job: it exists to be *read* by a human running
20//! `cargo insta review`, and reviewability trades off against completeness.
21//! A 23-node `git` tree with every `None` and every empty `Vec` spelled out
22//! buries the handful of fields a reviewer actually needs to look at — which
23//! is exactly the condition under which a diff gets accepted blind, defeating
24//! the review step and therefore the regression net it exists to build. So
25//! this module normalizes two things, deliberately no more:
26//!
27//! - **Omits empty collections and `None` fields**, via `NodeSnapshot` and
28//! friends mirroring `CommandNode`'s shape but with
29//! `skip_serializing_if` on every `Option`/`Vec` field. This is safe in
30//! the direction that matters: a field going `Some(x)` -> `None`, or a
31//! `Vec` losing its last element, still shows up in a diff as a *removed
32//! key* — the loss stays visible, just spelled as an absence rather than a
33//! changed value.
34//! - **Rounds `Provenance::confidence` to 2 decimal places** (see
35//! [`round_confidence`]). A heuristic tier's float noise — the same parse,
36//! differing in the seventh bit of an `f32` between two runs — would
37//! otherwise churn the snapshot with no signal for a reviewer to act on. A
38//! confidence change large enough to round to a different value still
39//! moves the snapshot, so a genuine confidence regression stays visible.
40//! - **Omits `bool` fields when `false`** (via [`is_false`]), extending the
41//! same "loss is still visible as a removed key" reasoning to booleans.
42//! This one isn't in the brief verbatim, but the evidence for it is
43//! concrete: a generated snapshot of a two-flag synthetic tree, before
44//! this rule existed, spent 3 lines per node (`hidden`/`children_filled`/
45//! `heading_attested`, all `false`) and 4 lines per flag
46//! (`repeatable`/`required`/`hidden`/`inherited`, all `false`) restating
47//! the default — for `tar`'s real 171-flag fixture that's ~800 lines of
48//! pure noise before a reviewer reaches anything that varies. `false`
49//! staying implicit and only `true` appearing is exactly [`ValueKind`]'s
50//! own existing precedent in this format (below): the common case is
51//! silent, the notable case is a visible key.
52//!
53//! # What this module deliberately does *not* normalize
54//!
55//! **`subcommands` order is untouched.** [`NodeSnapshot::from`] does not
56//! sort it, does not dedupe it beyond what the IR itself already guarantees,
57//! and does not otherwise reorder it for tidiness. Order is a meaningful
58//! structural fact — `git --help` groups its commands ("start a working
59//! area", "work on the current change", ...) in an order the source chose,
60//! not alphabetically — and a grammar change that silently reordered them
61//! would be exactly the class of regression this snapshot format exists to
62//! catch. Sorting it away would make that regression permanently invisible,
63//! which is strictly worse than the extra review noise a stable-but-not-
64//! alphabetical order occasionally costs.
65//!
66//! **There is nothing else to normalize.** Every field `CommandNode` (and
67//! `Flag`, `Positional`, `Example`, `Provenance`) exposes already reaches
68//! serialization through a `Vec`/`SmallVec` in source order — an audit of
69//! `mandible-core` and the extraction pipeline in `mandible-extract` found
70//! no `HashMap`/`HashSet` whose iteration order reaches an emitted
71//! `CommandNode`; `mandible-core::merge`'s internal `HashMap` buckets are
72//! read back out through a separately tracked first-seen-order `Vec`, never
73//! iterated directly. And there is no timing field on `CommandNode` to
74//! strip — elapsed time lives on `mandible-extract::ExtractionResult`, one
75//! layer above the IR this module snapshots, so it never reaches here.
76
77use crate::node::{CommandNode, Example, Flag, Positional, ValueKind};
78use crate::provenance::{Provenance, Source};
79use serde::Serialize;
80
81/// Build a [`NodeSnapshot`] from a [`CommandNode`], applying this module's
82/// normalization rules (confidence rounding, omission of empty/`None`
83/// fields) without touching anything order-sensitive. This is the one
84/// function a corpus runner or a snapshot test needs.
85pub fn to_snapshot(node: &CommandNode) -> NodeSnapshot {
86 NodeSnapshot::from(node)
87}
88
89/// Round a confidence score to 2 decimal places.
90///
91/// 2 decimals is coarse enough to absorb the float noise a heuristic tier's
92/// scoring produces between otherwise-identical runs, and fine enough that a
93/// real confidence change (a grammar edit that makes a tier genuinely more
94/// or less sure) still lands on a different rounded value and therefore
95/// still moves the snapshot. See this module's doc comment.
96fn round_confidence(c: f32) -> f32 {
97 (c * 100.0).round() / 100.0
98}
99
100/// Snapshot form of [`Provenance`]: `sources` rendered through
101/// [`Source::label`] (already the human-readable form used by `--doctor` and
102/// the detail pane's footer, so this introduces no second vocabulary) and
103/// `confidence` rounded per [`round_confidence`]. Both fields are omitted
104/// when empty/`None`.
105#[derive(Debug, Clone, PartialEq, Serialize)]
106pub struct ProvenanceSnapshot {
107 /// Contributing source labels, in contribution order (earliest first) —
108 /// order preserved, not sorted, same reasoning as `subcommands`.
109 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub sources: Vec<String>,
111 /// Heuristic confidence, rounded to 2 decimals. Absent for
112 /// structured/authoritative sources, which never set it.
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub confidence: Option<f32>,
115}
116
117impl From<&Provenance> for ProvenanceSnapshot {
118 fn from(p: &Provenance) -> Self {
119 ProvenanceSnapshot {
120 sources: p.sources.iter().map(Source::label).collect(),
121 confidence: p.confidence.map(round_confidence),
122 }
123 }
124}
125
126/// True when `v` is [`ValueKind::None`] (the default, boolean-switch case) —
127/// used to skip the field for the common case so a long list of plain
128/// boolean flags (most real flag lists) doesn't repeat `value_kind: None` on
129/// every row.
130fn is_no_value(v: &ValueKind) -> bool {
131 matches!(v, ValueKind::None)
132}
133
134/// True when `b` is `false`. Used to skip boolean fields in their (near-
135/// universal) default state — see this module's doc comment. A flip from
136/// `true` back to `false` still shows up in a diff as the key disappearing,
137/// same as `Some` -> `None`.
138fn is_false(b: &bool) -> bool {
139 !*b
140}
141
142/// Snapshot form of [`Flag`]. Field order matches `Flag`'s own declaration;
143/// every `Option`/`Vec` field is omitted when empty, every `bool` field is
144/// omitted when `false`.
145#[derive(Debug, Clone, PartialEq, Serialize)]
146pub struct FlagSnapshot {
147 /// Short spelling, e.g. `'i'` for `-i`.
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub short: Option<char>,
150 /// Long spelling, e.g. `"interactive"` for `--interactive`.
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub long: Option<String>,
153 /// The value placeholder, e.g. `"FILE"` in `--output FILE`.
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub value_name: Option<String>,
156 /// Whether this flag takes no value, a required value, or an optional
157 /// one. Omitted for the common no-value case.
158 #[serde(skip_serializing_if = "is_no_value")]
159 pub value_kind: ValueKind,
160 /// Enumerated choices, e.g. `{json|yaml|table}` for `--format`.
161 #[serde(skip_serializing_if = "Vec::is_empty")]
162 pub choices: Vec<String>,
163 /// True if this flag may be given more than once.
164 #[serde(skip_serializing_if = "is_false")]
165 pub repeatable: bool,
166 /// True if this flag is required.
167 #[serde(skip_serializing_if = "is_false")]
168 pub required: bool,
169 /// True if the tool documents this boolean's negation inline
170 /// (`--[no-]foo`). `long` holds the base name either way.
171 #[serde(skip_serializing_if = "is_false")]
172 pub negatable: bool,
173 /// True when `long` is spelled with one dash rather than two (`-help`,
174 /// `-vv`). `long` holds the bare name either way.
175 #[serde(skip_serializing_if = "is_false")]
176 pub single_dash: bool,
177 /// True if this flag should be hidden by default.
178 #[serde(skip_serializing_if = "is_false")]
179 pub hidden: bool,
180 /// The deprecation reason, when deprecated.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub deprecated: Option<String>,
183 /// True when inherited from an ancestor node.
184 #[serde(skip_serializing_if = "is_false")]
185 pub inherited: bool,
186 /// Display grouping from the source, e.g. tar's "Main operation mode".
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub group: Option<String>,
189 /// The flag's description.
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 /// The flag's default value, if documented.
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub default: Option<String>,
195 /// An environment variable that also sets this flag, if documented.
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub env_var: Option<String>,
198 /// Which source(s) contributed this flag's fields.
199 pub provenance: ProvenanceSnapshot,
200}
201
202impl From<&Flag> for FlagSnapshot {
203 fn from(f: &Flag) -> Self {
204 FlagSnapshot {
205 short: f.short,
206 long: f.long.clone(),
207 value_name: f.value_name.clone(),
208 value_kind: f.value_kind,
209 choices: f.choices.iter().map(|t| t.as_str().to_string()).collect(),
210 repeatable: f.repeatable,
211 required: f.required,
212 negatable: f.negatable,
213 single_dash: f.single_dash,
214 hidden: f.hidden,
215 deprecated: f.deprecated.as_ref().map(|t| t.as_str().to_string()),
216 inherited: f.inherited,
217 group: f.group.clone(),
218 description: f.description.as_ref().map(|t| t.as_str().to_string()),
219 default: f.default.as_ref().map(|t| t.as_str().to_string()),
220 env_var: f.env_var.clone(),
221 provenance: ProvenanceSnapshot::from(&f.provenance),
222 }
223 }
224}
225
226/// Snapshot form of [`Positional`].
227#[derive(Debug, Clone, PartialEq, Serialize)]
228pub struct PositionalSnapshot {
229 /// The argument's name as shown in usage, e.g. `"pathspec"`.
230 pub name: String,
231 /// True if this positional must be supplied.
232 #[serde(skip_serializing_if = "is_false")]
233 pub required: bool,
234 /// True if this positional accepts multiple values (`...`).
235 #[serde(skip_serializing_if = "is_false")]
236 pub variadic: bool,
237 /// The positional's description.
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub description: Option<String>,
240 /// Which source(s) contributed this positional's fields.
241 pub provenance: ProvenanceSnapshot,
242}
243
244impl From<&Positional> for PositionalSnapshot {
245 fn from(p: &Positional) -> Self {
246 PositionalSnapshot {
247 name: p.name.clone(),
248 required: p.required,
249 variadic: p.variadic,
250 description: p.description.as_ref().map(|t| t.as_str().to_string()),
251 provenance: ProvenanceSnapshot::from(&p.provenance),
252 }
253 }
254}
255
256/// Snapshot form of [`Example`].
257#[derive(Debug, Clone, PartialEq, Serialize)]
258pub struct ExampleSnapshot {
259 /// The example command line, verbatim.
260 pub command: String,
261 /// An optional explanation of what the example does.
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub explanation: Option<String>,
264}
265
266impl From<&Example> for ExampleSnapshot {
267 fn from(e: &Example) -> Self {
268 ExampleSnapshot {
269 command: e.command.as_str().to_string(),
270 explanation: e.explanation.as_ref().map(|t| t.as_str().to_string()),
271 }
272 }
273}
274
275/// Snapshot form of [`CommandNode`]. See this module's doc comment for the
276/// normalization rules; in short, `Option`/`Vec` fields are omitted when
277/// empty, `provenance.confidence` is rounded, and `subcommands` order is
278/// preserved exactly as `CommandNode` stored it.
279#[derive(Debug, Clone, PartialEq, Serialize)]
280pub struct NodeSnapshot {
281 /// The command's own name, e.g. `"rebase"` (not the full path).
282 pub name: String,
283 /// Alternate names this command is also invoked as.
284 #[serde(skip_serializing_if = "Vec::is_empty")]
285 pub aliases: Vec<String>,
286 /// A one-line hint.
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub summary: Option<String>,
289 /// Long-form prose.
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub description: Option<String>,
292 /// Raw usage patterns, kept verbatim.
293 #[serde(skip_serializing_if = "Vec::is_empty")]
294 pub usage: Vec<String>,
295 /// Positional arguments.
296 #[serde(skip_serializing_if = "Vec::is_empty")]
297 pub positionals: Vec<PositionalSnapshot>,
298 /// This node's own flags.
299 #[serde(skip_serializing_if = "Vec::is_empty")]
300 pub flags: Vec<FlagSnapshot>,
301 /// Worked examples.
302 #[serde(skip_serializing_if = "Vec::is_empty")]
303 pub examples: Vec<ExampleSnapshot>,
304 /// Display grouping from the source.
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub group: Option<String>,
307 /// The deprecation reason, when deprecated.
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub deprecated: Option<String>,
310 /// The framework Tier A′ identified for this node, if any.
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub detected_framework: Option<String>,
313 /// Which source(s) contributed this node's own fields.
314 pub provenance: ProvenanceSnapshot,
315 /// True if this command should be hidden from the tree by default.
316 #[serde(skip_serializing_if = "is_false")]
317 pub hidden: bool,
318 /// True when this node's `subcommands` list is known-complete.
319 #[serde(skip_serializing_if = "is_false")]
320 pub children_filled: bool,
321 /// True when this node was recovered from a bare-word block under a
322 /// recognized command heading (spec §7 Tier B rule 1) rather than
323 /// conjured from layout alone.
324 #[serde(skip_serializing_if = "is_false")]
325 pub heading_attested: bool,
326 /// The tool's raw `--help` output, one line per entry, set only when no
327 /// parse produced anything structurally plausible.
328 #[serde(skip_serializing_if = "Vec::is_empty")]
329 pub unparsed: Vec<String>,
330 /// Direct subcommands, in exactly the order `CommandNode` stored them —
331 /// **never** reordered. See this module's doc comment.
332 #[serde(skip_serializing_if = "Vec::is_empty")]
333 pub subcommands: Vec<NodeSnapshot>,
334 /// What this node's own `--help` text said about being an incomplete
335 /// document, if anything (spec §6 rule 2b). Omitted entirely for the
336 /// overwhelmingly common case, no confession printed at all.
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub confession: Option<ConfessionSnapshot>,
339}
340
341/// Snapshot form of [`crate::node::Confession`].
342#[derive(Debug, Clone, PartialEq, Serialize)]
343pub struct ConfessionSnapshot {
344 /// The directive word, verbatim from the tool's own text.
345 pub word: String,
346 /// The flag printed alongside it (`"--help"` or `"-h"`).
347 pub flag: String,
348 /// True when the advertised argv was actually re-probed and this
349 /// node's fields reflect that document; false when the confession was
350 /// detected but not followed (an unrecognised word, a failed probe, a
351 /// rule 0 refusal) and the node still reflects the truncated text.
352 ///
353 /// **Always written, unlike this module's other booleans.** The
354 /// omit-when-false rule the rest of the format follows ([`is_false`])
355 /// rests on `false` being the unremarkable default, so its absence
356 /// says nothing worth reading. Here the polarity is the other way
357 /// round: `false` is the *noteworthy* state — it is precisely what
358 /// caps a tree at `incomplete` — and encoding the interesting half of
359 /// a two-state field as a missing key would make the fixture that
360 /// exists to demonstrate that state (`corpus/curl/8.5.0`) show it by
361 /// omission, indistinguishable on sight from a snapshot written
362 /// before this field existed.
363 pub followed: bool,
364}
365
366impl From<&crate::node::Confession> for ConfessionSnapshot {
367 fn from(c: &crate::node::Confession) -> Self {
368 ConfessionSnapshot {
369 word: c.word.clone(),
370 flag: c.flag.clone(),
371 followed: c.followed,
372 }
373 }
374}
375
376impl From<&CommandNode> for NodeSnapshot {
377 fn from(n: &CommandNode) -> Self {
378 NodeSnapshot {
379 name: n.name.clone(),
380 aliases: n.aliases.clone(),
381 summary: n.summary.as_ref().map(|t| t.as_str().to_string()),
382 description: n.description.as_ref().map(|t| t.as_str().to_string()),
383 usage: n.usage.iter().map(|t| t.as_str().to_string()).collect(),
384 positionals: n.positionals.iter().map(PositionalSnapshot::from).collect(),
385 flags: n.flags.iter().map(FlagSnapshot::from).collect(),
386 examples: n.examples.iter().map(ExampleSnapshot::from).collect(),
387 group: n.group.clone(),
388 deprecated: n.deprecated.as_ref().map(|t| t.as_str().to_string()),
389 detected_framework: n.detected_framework.clone(),
390 provenance: ProvenanceSnapshot::from(&n.provenance),
391 hidden: n.hidden,
392 children_filled: n.children_filled,
393 heading_attested: n.heading_attested,
394 unparsed: n.unparsed.iter().map(|t| t.as_str().to_string()).collect(),
395 // The order-preservation this whole module exists to protect:
396 // straight `iter().map().collect()` over `n.subcommands`, no
397 // sort, no re-grouping.
398 subcommands: n.subcommands.iter().map(NodeSnapshot::from).collect(),
399 confession: n.confession.as_ref().map(ConfessionSnapshot::from),
400 }
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::provenance::Provenance;
408 use crate::text::Text;
409
410 fn node_with_confidence(confidence: f32) -> CommandNode {
411 let mut n = CommandNode::new(
412 "tool",
413 Provenance::with_confidence(Source::HelpText, confidence),
414 );
415 n.summary = Some(Text::sanitize("does a thing"));
416 n.flags.push(Flag::long(
417 "verbose",
418 Provenance::with_confidence(Source::HelpText, confidence),
419 ));
420 n
421 }
422
423 fn render(node: &CommandNode) -> String {
424 serde_yaml::to_string(&to_snapshot(node)).expect("snapshot serializes")
425 }
426
427 #[test]
428 fn serializing_the_same_node_twice_is_identical() {
429 let node = node_with_confidence(0.8734);
430 assert_eq!(render(&node), render(&node));
431 }
432
433 /// Both halves of the rounding requirement in one test, deliberately: a
434 /// test that only checked the "doesn't move" half would pass even if
435 /// confidence were rounded to a constant, which would silently delete
436 /// the field's entire signal value.
437 #[test]
438 fn confidence_rounding_absorbs_noise_but_not_real_change() {
439 let base = render(&node_with_confidence(0.821));
440 // Sub-threshold wobble: both round to 0.82. Must not move the
441 // snapshot.
442 let wobble = render(&node_with_confidence(0.8199999));
443 assert_eq!(
444 base, wobble,
445 "a sub-hundredth confidence wobble must not change the snapshot"
446 );
447 assert!(base.contains("0.82"), "rounded value must still appear");
448
449 // A real change: 0.821 -> 0.75 rounds to a different value and must
450 // move the snapshot.
451 let changed = render(&node_with_confidence(0.75));
452 assert_ne!(
453 base, changed,
454 "a genuine confidence change must still move the snapshot"
455 );
456 assert!(changed.contains("0.75"));
457 }
458
459 #[test]
460 fn subcommand_order_is_preserved_not_sorted() {
461 let mut root = CommandNode::new("git", Provenance::single(Source::HelpText));
462 for name in ["zebra", "apple", "mango"] {
463 root.subcommands
464 .push(CommandNode::new(name, Provenance::single(Source::HelpText)));
465 }
466 let out = render(&root);
467
468 let zebra = out.find("zebra").expect("zebra present");
469 let apple = out.find("apple").expect("apple present");
470 let mango = out.find("mango").expect("mango present");
471
472 // Insertion order (zebra, apple, mango), NOT alphabetical
473 // (apple, mango, zebra) and not any other reordering. This is the
474 // regression test for a future "tidy-up" that sorts subcommands.
475 assert!(
476 zebra < apple && apple < mango,
477 "subcommand order must be preserved exactly as built, got: {out}"
478 );
479 }
480
481 #[test]
482 fn empty_and_none_fields_are_omitted() {
483 let node = CommandNode::new("bare", Provenance::single(Source::HelpText));
484 let out = render(&node);
485 assert!(!out.contains("aliases"), "empty Vec must be omitted");
486 assert!(!out.contains("summary"), "None Option must be omitted");
487 assert!(!out.contains("subcommands"), "empty Vec must be omitted");
488 assert!(!out.contains("flags"), "empty Vec must be omitted");
489 }
490
491 #[test]
492 fn a_field_losing_its_value_still_shows_up_as_a_removed_key() {
493 let mut with_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
494 with_summary.summary = Some(Text::sanitize("hi"));
495 let without_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
496
497 assert!(render(&with_summary).contains("summary"));
498 assert!(!render(&without_summary).contains("summary"));
499 }
500
501 /// A synthetic-but-representative tree, snapshotted through `insta`
502 /// directly (rather than the plain `serde_yaml::to_string` the property
503 /// tests above use) to prove the crate is actually wired up to `insta`
504 /// and to give a reviewer a small, hand-checkable `.snap` file before
505 /// any real corpus fixture exists. The real end-to-end proof — the
506 /// format surviving contact with genuine `--help` output through the
507 /// actual extraction pipeline — lives in `mandible-extract`'s own
508 /// tests, since this crate has no tier/parser to run.
509 #[test]
510 fn snapshot_of_a_representative_synthetic_tree() {
511 let mut root =
512 CommandNode::new("git", Provenance::with_confidence(Source::HelpText, 0.9123));
513 root.summary = Some(Text::sanitize("the stupid content tracker"));
514
515 let mut commit = CommandNode::new("commit", Provenance::single(Source::HelpText));
516 commit.summary = Some(Text::sanitize("Record changes to the repository"));
517 commit.flags.push({
518 let mut f = Flag::long("amend", Provenance::single(Source::HelpText));
519 f.description = Some(Text::sanitize("amend the previous commit"));
520 f
521 });
522
523 let mut status = CommandNode::new("status", Provenance::single(Source::HelpText));
524 status.summary = Some(Text::sanitize("Show the working tree status"));
525
526 // Deliberately not alphabetical (commit, status) — matches how
527 // real `--help` output groups commands, and this snapshot doubles
528 // as a visible example that the order survives untouched.
529 root.subcommands.push(commit);
530 root.subcommands.push(status);
531
532 insta::assert_yaml_snapshot!(to_snapshot(&root));
533 }
534}