Skip to main content

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}