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 if this flag should be hidden by default.
174 #[serde(skip_serializing_if = "is_false")]
175 pub hidden: bool,
176 /// The deprecation reason, when deprecated.
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub deprecated: Option<String>,
179 /// True when inherited from an ancestor node.
180 #[serde(skip_serializing_if = "is_false")]
181 pub inherited: bool,
182 /// Display grouping from the source, e.g. tar's "Main operation mode".
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub group: Option<String>,
185 /// The flag's description.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub description: Option<String>,
188 /// The flag's default value, if documented.
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub default: Option<String>,
191 /// An environment variable that also sets this flag, if documented.
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub env_var: Option<String>,
194 /// Which source(s) contributed this flag's fields.
195 pub provenance: ProvenanceSnapshot,
196}
197
198impl From<&Flag> for FlagSnapshot {
199 fn from(f: &Flag) -> Self {
200 FlagSnapshot {
201 short: f.short,
202 long: f.long.clone(),
203 value_name: f.value_name.clone(),
204 value_kind: f.value_kind,
205 choices: f.choices.iter().map(|t| t.as_str().to_string()).collect(),
206 repeatable: f.repeatable,
207 required: f.required,
208 negatable: f.negatable,
209 hidden: f.hidden,
210 deprecated: f.deprecated.as_ref().map(|t| t.as_str().to_string()),
211 inherited: f.inherited,
212 group: f.group.clone(),
213 description: f.description.as_ref().map(|t| t.as_str().to_string()),
214 default: f.default.as_ref().map(|t| t.as_str().to_string()),
215 env_var: f.env_var.clone(),
216 provenance: ProvenanceSnapshot::from(&f.provenance),
217 }
218 }
219}
220
221/// Snapshot form of [`Positional`].
222#[derive(Debug, Clone, PartialEq, Serialize)]
223pub struct PositionalSnapshot {
224 /// The argument's name as shown in usage, e.g. `"pathspec"`.
225 pub name: String,
226 /// True if this positional must be supplied.
227 #[serde(skip_serializing_if = "is_false")]
228 pub required: bool,
229 /// True if this positional accepts multiple values (`...`).
230 #[serde(skip_serializing_if = "is_false")]
231 pub variadic: bool,
232 /// The positional's description.
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub description: Option<String>,
235 /// Which source(s) contributed this positional's fields.
236 pub provenance: ProvenanceSnapshot,
237}
238
239impl From<&Positional> for PositionalSnapshot {
240 fn from(p: &Positional) -> Self {
241 PositionalSnapshot {
242 name: p.name.clone(),
243 required: p.required,
244 variadic: p.variadic,
245 description: p.description.as_ref().map(|t| t.as_str().to_string()),
246 provenance: ProvenanceSnapshot::from(&p.provenance),
247 }
248 }
249}
250
251/// Snapshot form of [`Example`].
252#[derive(Debug, Clone, PartialEq, Serialize)]
253pub struct ExampleSnapshot {
254 /// The example command line, verbatim.
255 pub command: String,
256 /// An optional explanation of what the example does.
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub explanation: Option<String>,
259}
260
261impl From<&Example> for ExampleSnapshot {
262 fn from(e: &Example) -> Self {
263 ExampleSnapshot {
264 command: e.command.as_str().to_string(),
265 explanation: e.explanation.as_ref().map(|t| t.as_str().to_string()),
266 }
267 }
268}
269
270/// Snapshot form of [`CommandNode`]. See this module's doc comment for the
271/// normalization rules; in short, `Option`/`Vec` fields are omitted when
272/// empty, `provenance.confidence` is rounded, and `subcommands` order is
273/// preserved exactly as `CommandNode` stored it.
274#[derive(Debug, Clone, PartialEq, Serialize)]
275pub struct NodeSnapshot {
276 /// The command's own name, e.g. `"rebase"` (not the full path).
277 pub name: String,
278 /// Alternate names this command is also invoked as.
279 #[serde(skip_serializing_if = "Vec::is_empty")]
280 pub aliases: Vec<String>,
281 /// A one-line hint.
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub summary: Option<String>,
284 /// Long-form prose.
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub description: Option<String>,
287 /// Raw usage patterns, kept verbatim.
288 #[serde(skip_serializing_if = "Vec::is_empty")]
289 pub usage: Vec<String>,
290 /// Positional arguments.
291 #[serde(skip_serializing_if = "Vec::is_empty")]
292 pub positionals: Vec<PositionalSnapshot>,
293 /// This node's own flags.
294 #[serde(skip_serializing_if = "Vec::is_empty")]
295 pub flags: Vec<FlagSnapshot>,
296 /// Worked examples.
297 #[serde(skip_serializing_if = "Vec::is_empty")]
298 pub examples: Vec<ExampleSnapshot>,
299 /// Display grouping from the source.
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub group: Option<String>,
302 /// The deprecation reason, when deprecated.
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub deprecated: Option<String>,
305 /// The framework Tier A′ identified for this node, if any.
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub detected_framework: Option<String>,
308 /// Which source(s) contributed this node's own fields.
309 pub provenance: ProvenanceSnapshot,
310 /// True if this command should be hidden from the tree by default.
311 #[serde(skip_serializing_if = "is_false")]
312 pub hidden: bool,
313 /// True when this node's `subcommands` list is known-complete.
314 #[serde(skip_serializing_if = "is_false")]
315 pub children_filled: bool,
316 /// True when this node was recovered from a bare-word block under a
317 /// recognized command heading (spec §7 Tier B rule 1) rather than
318 /// conjured from layout alone.
319 #[serde(skip_serializing_if = "is_false")]
320 pub heading_attested: bool,
321 /// The tool's raw `--help` output, one line per entry, set only when no
322 /// parse produced anything structurally plausible.
323 #[serde(skip_serializing_if = "Vec::is_empty")]
324 pub unparsed: Vec<String>,
325 /// Direct subcommands, in exactly the order `CommandNode` stored them —
326 /// **never** reordered. See this module's doc comment.
327 #[serde(skip_serializing_if = "Vec::is_empty")]
328 pub subcommands: Vec<NodeSnapshot>,
329 /// What this node's own `--help` text said about being an incomplete
330 /// document, if anything (spec §6 rule 2b). Omitted entirely for the
331 /// overwhelmingly common case, no confession printed at all.
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub confession: Option<ConfessionSnapshot>,
334}
335
336/// Snapshot form of [`crate::node::Confession`].
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct ConfessionSnapshot {
339 /// The directive word, verbatim from the tool's own text.
340 pub word: String,
341 /// The flag printed alongside it (`"--help"` or `"-h"`).
342 pub flag: String,
343 /// True when the advertised argv was actually re-probed and this
344 /// node's fields reflect that document; false when the confession was
345 /// detected but not followed (an unrecognised word, a failed probe, a
346 /// rule 0 refusal) and the node still reflects the truncated text.
347 ///
348 /// **Always written, unlike this module's other booleans.** The
349 /// omit-when-false rule the rest of the format follows ([`is_false`])
350 /// rests on `false` being the unremarkable default, so its absence
351 /// says nothing worth reading. Here the polarity is the other way
352 /// round: `false` is the *noteworthy* state — it is precisely what
353 /// caps a tree at `incomplete` — and encoding the interesting half of
354 /// a two-state field as a missing key would make the fixture that
355 /// exists to demonstrate that state (`corpus/curl/8.5.0`) show it by
356 /// omission, indistinguishable on sight from a snapshot written
357 /// before this field existed.
358 pub followed: bool,
359}
360
361impl From<&crate::node::Confession> for ConfessionSnapshot {
362 fn from(c: &crate::node::Confession) -> Self {
363 ConfessionSnapshot {
364 word: c.word.clone(),
365 flag: c.flag.clone(),
366 followed: c.followed,
367 }
368 }
369}
370
371impl From<&CommandNode> for NodeSnapshot {
372 fn from(n: &CommandNode) -> Self {
373 NodeSnapshot {
374 name: n.name.clone(),
375 aliases: n.aliases.clone(),
376 summary: n.summary.as_ref().map(|t| t.as_str().to_string()),
377 description: n.description.as_ref().map(|t| t.as_str().to_string()),
378 usage: n.usage.iter().map(|t| t.as_str().to_string()).collect(),
379 positionals: n.positionals.iter().map(PositionalSnapshot::from).collect(),
380 flags: n.flags.iter().map(FlagSnapshot::from).collect(),
381 examples: n.examples.iter().map(ExampleSnapshot::from).collect(),
382 group: n.group.clone(),
383 deprecated: n.deprecated.as_ref().map(|t| t.as_str().to_string()),
384 detected_framework: n.detected_framework.clone(),
385 provenance: ProvenanceSnapshot::from(&n.provenance),
386 hidden: n.hidden,
387 children_filled: n.children_filled,
388 heading_attested: n.heading_attested,
389 unparsed: n.unparsed.iter().map(|t| t.as_str().to_string()).collect(),
390 // The order-preservation this whole module exists to protect:
391 // straight `iter().map().collect()` over `n.subcommands`, no
392 // sort, no re-grouping.
393 subcommands: n.subcommands.iter().map(NodeSnapshot::from).collect(),
394 confession: n.confession.as_ref().map(ConfessionSnapshot::from),
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::provenance::Provenance;
403 use crate::text::Text;
404
405 fn node_with_confidence(confidence: f32) -> CommandNode {
406 let mut n = CommandNode::new(
407 "tool",
408 Provenance::with_confidence(Source::HelpText, confidence),
409 );
410 n.summary = Some(Text::sanitize("does a thing"));
411 n.flags.push(Flag::long(
412 "verbose",
413 Provenance::with_confidence(Source::HelpText, confidence),
414 ));
415 n
416 }
417
418 fn render(node: &CommandNode) -> String {
419 serde_yaml::to_string(&to_snapshot(node)).expect("snapshot serializes")
420 }
421
422 #[test]
423 fn serializing_the_same_node_twice_is_identical() {
424 let node = node_with_confidence(0.8734);
425 assert_eq!(render(&node), render(&node));
426 }
427
428 /// Both halves of the rounding requirement in one test, deliberately: a
429 /// test that only checked the "doesn't move" half would pass even if
430 /// confidence were rounded to a constant, which would silently delete
431 /// the field's entire signal value.
432 #[test]
433 fn confidence_rounding_absorbs_noise_but_not_real_change() {
434 let base = render(&node_with_confidence(0.821));
435 // Sub-threshold wobble: both round to 0.82. Must not move the
436 // snapshot.
437 let wobble = render(&node_with_confidence(0.8199999));
438 assert_eq!(
439 base, wobble,
440 "a sub-hundredth confidence wobble must not change the snapshot"
441 );
442 assert!(base.contains("0.82"), "rounded value must still appear");
443
444 // A real change: 0.821 -> 0.75 rounds to a different value and must
445 // move the snapshot.
446 let changed = render(&node_with_confidence(0.75));
447 assert_ne!(
448 base, changed,
449 "a genuine confidence change must still move the snapshot"
450 );
451 assert!(changed.contains("0.75"));
452 }
453
454 #[test]
455 fn subcommand_order_is_preserved_not_sorted() {
456 let mut root = CommandNode::new("git", Provenance::single(Source::HelpText));
457 for name in ["zebra", "apple", "mango"] {
458 root.subcommands
459 .push(CommandNode::new(name, Provenance::single(Source::HelpText)));
460 }
461 let out = render(&root);
462
463 let zebra = out.find("zebra").expect("zebra present");
464 let apple = out.find("apple").expect("apple present");
465 let mango = out.find("mango").expect("mango present");
466
467 // Insertion order (zebra, apple, mango), NOT alphabetical
468 // (apple, mango, zebra) and not any other reordering. This is the
469 // regression test for a future "tidy-up" that sorts subcommands.
470 assert!(
471 zebra < apple && apple < mango,
472 "subcommand order must be preserved exactly as built, got: {out}"
473 );
474 }
475
476 #[test]
477 fn empty_and_none_fields_are_omitted() {
478 let node = CommandNode::new("bare", Provenance::single(Source::HelpText));
479 let out = render(&node);
480 assert!(!out.contains("aliases"), "empty Vec must be omitted");
481 assert!(!out.contains("summary"), "None Option must be omitted");
482 assert!(!out.contains("subcommands"), "empty Vec must be omitted");
483 assert!(!out.contains("flags"), "empty Vec must be omitted");
484 }
485
486 #[test]
487 fn a_field_losing_its_value_still_shows_up_as_a_removed_key() {
488 let mut with_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
489 with_summary.summary = Some(Text::sanitize("hi"));
490 let without_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
491
492 assert!(render(&with_summary).contains("summary"));
493 assert!(!render(&without_summary).contains("summary"));
494 }
495
496 /// A synthetic-but-representative tree, snapshotted through `insta`
497 /// directly (rather than the plain `serde_yaml::to_string` the property
498 /// tests above use) to prove the crate is actually wired up to `insta`
499 /// and to give a reviewer a small, hand-checkable `.snap` file before
500 /// any real corpus fixture exists. The real end-to-end proof — the
501 /// format surviving contact with genuine `--help` output through the
502 /// actual extraction pipeline — lives in `mandible-extract`'s own
503 /// tests, since this crate has no tier/parser to run.
504 #[test]
505 fn snapshot_of_a_representative_synthetic_tree() {
506 let mut root =
507 CommandNode::new("git", Provenance::with_confidence(Source::HelpText, 0.9123));
508 root.summary = Some(Text::sanitize("the stupid content tracker"));
509
510 let mut commit = CommandNode::new("commit", Provenance::single(Source::HelpText));
511 commit.summary = Some(Text::sanitize("Record changes to the repository"));
512 commit.flags.push({
513 let mut f = Flag::long("amend", Provenance::single(Source::HelpText));
514 f.description = Some(Text::sanitize("amend the previous commit"));
515 f
516 });
517
518 let mut status = CommandNode::new("status", Provenance::single(Source::HelpText));
519 status.summary = Some(Text::sanitize("Show the working tree status"));
520
521 // Deliberately not alphabetical (commit, status) — matches how
522 // real `--help` output groups commands, and this snapshot doubles
523 // as a visible example that the order survives untouched.
524 root.subcommands.push(commit);
525 root.subcommands.push(status);
526
527 insta::assert_yaml_snapshot!(to_snapshot(&root));
528 }
529}