Skip to main content

mandible_core/
node.rs

1//! The intermediate representation itself: [`CommandNode`], [`Flag`],
2//! [`Positional`], [`Example`]. See spec §4.
3
4use crate::provenance::Provenance;
5use crate::text::Text;
6use serde::{Deserialize, Serialize};
7
8/// One command or subcommand in the tree: `git`, `git rebase`,
9/// `git rebase --onto`'s parent, and so on.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct CommandNode {
12    /// The command's own name, e.g. `"rebase"` (not the full path).
13    pub name: String,
14    /// Alternate names this command is also invoked as, e.g. `["stage"]`
15    /// for `git add`, or a cobra alias like `"co"` for `"checkout"`.
16    pub aliases: Vec<String>,
17    /// A one-line hint, shown in tree rows and as the detail pane's
18    /// headline.
19    pub summary: Option<Text>,
20    /// Long-form prose, shown in the detail pane body.
21    pub description: Option<Text>,
22    /// Raw usage patterns, kept verbatim (not re-flowed).
23    pub usage: Vec<Text>,
24    /// This node's own flags (not including inherited ones from ancestors —
25    /// those are represented by [`Flag::inherited`] on the flags that
26    /// originated in an ancestor and were propagated down).
27    pub flags: Vec<Flag>,
28    /// Positional arguments.
29    pub positionals: Vec<Positional>,
30    /// Direct subcommands.
31    pub subcommands: Vec<CommandNode>,
32    /// Worked examples.
33    pub examples: Vec<Example>,
34    /// True if this command should be hidden from the tree by default.
35    pub hidden: bool,
36    /// `Some(reason)` when this command is deprecated.
37    pub deprecated: Option<Text>,
38    /// True when this node's `subcommands` list is known-complete. False
39    /// means the subtree has not been extracted yet (spec §5, lazy
40    /// extraction) and the runner should request it on expand.
41    pub children_filled: bool,
42    /// Display grouping from the source, e.g. carapace's `group: "main"` for
43    /// `git`'s porcelain commands. Extension beyond the spec's base schema,
44    /// permitted by spec §4 (carapace's `group` is a real display grouping).
45    pub group: Option<String>,
46    /// The tool's raw `--help` output, one sanitized [`Text`] per line, set
47    /// **only** when no parse produced anything structurally plausible
48    /// (spec §7 Tier B step 3 / batch 6 part 4: no flags, no subcommands,
49    /// no usage). Non-empty means "we are showing you the author's own
50    /// text untouched because inventing structure would be worse" — when
51    /// this is non-empty, `flags`/`subcommands`/`usage`/`description` are
52    /// all empty by construction, `provenance.confidence` is `0.0`, and a
53    /// consumer (the TUI's detail pane) must render this as a preformatted
54    /// block, not re-wrap or markdown-treat it. One `Text` per line
55    /// deliberately: it reuses `Text`'s own single-line invariant instead
56    /// of introducing a second, weaker sanitizer for a raw blob.
57    pub unparsed: Vec<Text>,
58    /// The framework Tier A′ identified for this node's `--help` output
59    /// (`Framework::name()`, e.g. `"clap (v3/v4)"`), if any — for display
60    /// only (`--doctor`, the detail pane's provenance footer), never for
61    /// parsing decisions on the consumer side. `mandible-core` cannot
62    /// depend on `mandible-extract::framework::Framework` (that would be a
63    /// cyclic crate dependency), so this is carried as the already-
64    /// rendered short name rather than the enum itself.
65    pub detected_framework: Option<String>,
66    /// Which source(s) contributed this node's own fields (not its
67    /// children's — each child has its own `Provenance`).
68    pub provenance: Provenance,
69    /// True when this node was recovered from a bare-word block sitting
70    /// under a **recognized** command heading (spec §7 Tier B rule 1: a
71    /// literal heading-vocabulary match, or a chain started by one, e.g.
72    /// git's group headings) — as opposed to being conjured from layout
73    /// alone. This is *positive evidence the node names a real command*,
74    /// independent of whether the source `--help` text bothered to
75    /// describe it: `openssl --help`'s `Standard commands:` grid lists
76    /// `asn1parse`, `ca`, `ciphers`, ... with no per-entry description at
77    /// all, and every one is a real subcommand.
78    ///
79    /// Set only at the handful of call sites already gated on a recognized
80    /// heading (`help_text::sections::emit_subcommands`,
81    /// `help_text::sections::process_word_grid`); every other constructor
82    /// (`CommandNode::new`, Tier A/E's own node-building) leaves this
83    /// `false`. That is what lets the coverage harness's structure-sanity
84    /// check (spec §13.1, `xtask::coverage::structure_sanity`) stop
85    /// flagging openssl's 151 genuinely empty-but-real nodes as
86    /// `suspicious` while still flagging an empty node with no such
87    /// evidence — [M-10]'s exact shape — regardless of whether its name
88    /// happens to look plausible.
89    pub heading_attested: bool,
90    /// What this node's own `--help` text said about being an incomplete
91    /// document, if anything (spec §6 rule 2b: the "truncation confession"
92    /// convention — curl's `--help` ending "For all options use the manual
93    /// or \"--help all\"."). `None` means the tool's text printed no such
94    /// confession at all, which is the overwhelming common case and is
95    /// never treated as evidence of anything.
96    pub confession: Option<Confession>,
97}
98
99/// A truncation confession a tool's own `--help` text printed, and what
100/// this extraction did about it (spec §6 rule 2b).
101///
102/// Two states share this one type deliberately, rather than a bare `bool`:
103/// a confession that was *detected* is worth recording even when it
104/// couldn't be *followed* (an unrecognised word, a failed or refused
105/// follow-up probe) — that is exactly the case the `incomplete` status
106/// exists to name honestly, and a reader (`--doctor`, the detail pane's
107/// footer) needs the word and flag to explain *why* a tree is capped, not
108/// just that it is.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct Confession {
111    /// The directive word, taken verbatim from the tool's own text — e.g.
112    /// `"all"` for curl. Never fabricated, never guessed (spec §6 rule 2b).
113    pub word: String,
114    /// The flag the directive printed alongside `word` — `"--help"` or
115    /// `"-h"`.
116    pub flag: String,
117    /// True when the advertised argv (`<flag> <word>`) was actually
118    /// re-probed and this node's own fields were built from *that*
119    /// document. False means the confession was detected but not
120    /// followed — an unrecognised word/shape, a failed probe, or a rule 0
121    /// refusal — and this node still reflects the original, truncated
122    /// text; the status ladder caps at `incomplete` for exactly this case.
123    pub followed: bool,
124}
125
126/// True if `s` looks like a real command/subcommand name: lowercase,
127/// starting with a letter, and otherwise only letters/digits/`_`/`.`/`-`
128/// (`^[a-z][a-z0-9_.-]*$`, spec §7 Tier B rule 3).
129///
130/// This is the shared definition of "looks like a name, not a fabricated
131/// fragment" — used by any extraction tier deciding whether a candidate
132/// bare-word entry is really a subcommand (rejecting prose fragments like
133/// *"treat them as errors"* or placeholder tokens like `BYTES`), and by
134/// the coverage harness (spec §13.1) as one half of its structure-sanity
135/// check: a tier that starts emitting names failing this test again is
136/// exactly the class of regression [M-10] was.
137pub fn is_command_name_shaped(s: &str) -> bool {
138    // A trailing `.`/`-`/`_` is sentence or hyphenation punctuation, never
139    // part of a command name. Interior ones are legitimate (`mount.nfs`,
140    // `apt-get`, `foo_bar`), which is why the character class below allows
141    // them at all — but allowing them at the end let prose fragments like
142    // *"testing."* and *"skipped."* through the name-shape check and into
143    // the tree as fabricated subcommands ([M-10]).
144    if s.ends_with(['.', '-', '_']) {
145        return false;
146    }
147    let mut chars = s.chars();
148    match chars.next() {
149        Some(c) if c.is_ascii_lowercase() => {}
150        _ => return false,
151    }
152    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'))
153}
154
155impl CommandNode {
156    /// A minimal, empty node with the given name and provenance. Useful as
157    /// a starting point for tiers and for tests.
158    pub fn new(name: impl Into<String>, provenance: Provenance) -> CommandNode {
159        CommandNode {
160            name: name.into(),
161            aliases: Vec::new(),
162            summary: None,
163            description: None,
164            usage: Vec::new(),
165            flags: Vec::new(),
166            positionals: Vec::new(),
167            subcommands: Vec::new(),
168            examples: Vec::new(),
169            hidden: false,
170            deprecated: None,
171            children_filled: false,
172            group: None,
173            unparsed: Vec::new(),
174            detected_framework: None,
175            provenance,
176            heading_attested: false,
177            confession: None,
178        }
179    }
180}
181
182/// A single flag/option, e.g. `-i, --interactive`.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct Flag {
185    /// Short spelling, e.g. `Some('i')` for `-i`.
186    pub short: Option<char>,
187    /// Long spelling, e.g. `Some("interactive".into())` for `--interactive`.
188    pub long: Option<String>,
189    /// The value placeholder, e.g. `"FILE"` in `--output FILE`.
190    pub value_name: Option<String>,
191    /// Whether this flag takes no value, a required value, or an optional
192    /// one.
193    pub value_kind: ValueKind,
194    /// Enumerated choices, e.g. `{json|yaml|table}` for `--format`.
195    pub choices: Vec<Text>,
196    /// True if this flag may be given more than once.
197    pub repeatable: bool,
198    /// True if this flag is required.
199    pub required: bool,
200    /// True if the tool documents this boolean flag's negation inline —
201    /// GNU getopt_long's `--[no-]foo` convention (git's own `--help`
202    /// formatter renders every negatable boolean this way). `long` always
203    /// holds the *base* name (`"foo"`, never `"[no-]foo"` or `"no-foo"`):
204    /// this field is what lets the negatability survive the parse without
205    /// smuggling `[`/`]` into the spelling users search and copy. See
206    /// `mandible-extract/src/help_text/grammar.rs`'s `try_long` for where
207    /// this is recognized, structurally, from the bracketed-prefix shape —
208    /// never from a tool name.
209    pub negatable: bool,
210    /// True when [`Self::long`] is spelled with **one** dash rather than two
211    /// — the single-dash long-option convention (`qemu -help`, `find -name`,
212    /// `gcc -fdump-scos`, `bpftrace -vv`), which is a real and common shape
213    /// that this model previously had no way to say.
214    ///
215    /// `long` holds the bare name either way (`"help"`, `"vv"`), so every
216    /// identity, merge and search path keeps working unchanged; this field
217    /// only decides how many dashes [`Self::spelling`] puts in front of it.
218    /// Storing `"-help"` in `long` instead would have put a dash inside the
219    /// spelling users search and copy, exactly the mistake
220    /// [`Self::negatable`]'s own doc comment records for `--[no-]foo`.
221    ///
222    /// Recognized structurally and never from a tool name: see
223    /// `help_text::sections::repair_repeated_character_flags` for the one
224    /// shape that currently sets it.
225    pub single_dash: bool,
226    /// True if this flag should be hidden by default.
227    pub hidden: bool,
228    /// `Some(reason)` when this flag is deprecated.
229    pub deprecated: Option<Text>,
230    /// True when this flag was declared on an ancestor node and propagated
231    /// down (cobra "persistent flag" / carapace `persistentflags`).
232    /// Rendered in a separate, dimmed group in the detail pane.
233    pub inherited: bool,
234    /// Display grouping from the source, e.g. tar's `"Main operation mode"`.
235    pub group: Option<String>,
236    /// The flag's description.
237    pub description: Option<Text>,
238    /// The flag's default value, if documented.
239    pub default: Option<Text>,
240    /// An environment variable that also sets this flag, if documented.
241    pub env_var: Option<String>,
242    /// Which source(s) contributed this flag's fields.
243    pub provenance: Provenance,
244}
245
246impl Flag {
247    /// A minimal flag with only a long spelling.
248    pub fn long(name: impl Into<String>, provenance: Provenance) -> Flag {
249        Flag {
250            short: None,
251            long: Some(name.into()),
252            value_name: None,
253            value_kind: ValueKind::None,
254            choices: Vec::new(),
255            repeatable: false,
256            required: false,
257            negatable: false,
258            single_dash: false,
259            hidden: false,
260            deprecated: None,
261            inherited: false,
262            group: None,
263            description: None,
264            default: None,
265            env_var: None,
266            provenance,
267        }
268    }
269
270    /// The canonical identity key used for cross-source matching and
271    /// addressing: prefer the long name, fall back to the short letter.
272    /// Returns `None` for a degenerate flag with neither (which cannot be
273    /// addressed and is only matched positionally during merge).
274    pub fn key(&self) -> Option<crate::noderef::FlagKey> {
275        if let Some(long) = &self.long {
276            Some(crate::noderef::FlagKey::Long(long.clone()))
277        } else {
278            self.short.map(crate::noderef::FlagKey::Short)
279        }
280    }
281
282    /// True if `key` addresses this flag, checking both spellings
283    /// regardless of which one is considered canonical.
284    pub fn matches_key(&self, key: &crate::noderef::FlagKey) -> bool {
285        match key {
286            crate::noderef::FlagKey::Long(l) => self.long.as_deref() == Some(l.as_str()),
287            crate::noderef::FlagKey::Short(s) => self.short == Some(*s),
288        }
289    }
290
291    /// A human-readable spelling for display and clipboard copy, e.g.
292    /// `"-i, --interactive"`, `"--output FILE"`, or `"-S, --[no-]staged"`
293    /// for a negatable boolean — the `[no-]` is reconstructed for display
294    /// from `negatable`, never stored in `long` itself (see the field's
295    /// doc comment).
296    ///
297    /// A [`Self::single_dash`] flag renders with one dash (`-help`, `-vv`),
298    /// reconstructed the same way and for the same reason: what a user has
299    /// to type is a display concern, and putting it in `long` would corrupt
300    /// the name every other code path matches on.
301    pub fn spelling(&self) -> String {
302        let mut parts = Vec::new();
303        if let Some(s) = self.short {
304            parts.push(format!("-{s}"));
305        }
306        if let Some(l) = &self.long {
307            let dashes = if self.single_dash { "-" } else { "--" };
308            if self.negatable {
309                parts.push(format!("{dashes}[no-]{l}"));
310            } else {
311                parts.push(format!("{dashes}{l}"));
312            }
313        }
314        let mut spelling = parts.join(", ");
315        if let Some(name) = &self.value_name {
316            match self.value_kind {
317                ValueKind::Required => spelling.push_str(&format!(" {name}")),
318                ValueKind::Optional => spelling.push_str(&format!("[={name}]")),
319                ValueKind::None => {}
320            }
321        }
322        spelling
323    }
324}
325
326/// Whether a flag takes a value, and if so, whether it's required.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
328pub enum ValueKind {
329    /// The flag is a boolean switch; it takes no value.
330    #[default]
331    None,
332    /// The flag must be given a value.
333    Required,
334    /// The flag may optionally be given a value.
335    Optional,
336}
337
338/// A positional argument, e.g. `<pathspec>...`.
339#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
340pub struct Positional {
341    /// The argument's name as shown in usage, e.g. `"pathspec"`.
342    pub name: String,
343    /// True if this positional must be supplied.
344    pub required: bool,
345    /// True if this positional accepts multiple values (`...`).
346    pub variadic: bool,
347    /// The positional's description.
348    pub description: Option<Text>,
349    /// Which source(s) contributed this positional's fields.
350    pub provenance: Provenance,
351}
352
353/// A worked example: a command line plus an optional explanation.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct Example {
356    /// The example command line, verbatim.
357    pub command: Text,
358    /// An optional explanation of what the example does.
359    pub explanation: Option<Text>,
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn is_command_name_shaped_accepts_real_names() {
368        assert!(is_command_name_shaped("commit"));
369        assert!(is_command_name_shaped("http-push"));
370        assert!(is_command_name_shaped("sha3-256"));
371        assert!(is_command_name_shaped("v7"));
372    }
373
374    #[test]
375    fn is_command_name_shaped_rejects_prose_and_placeholders() {
376        // A wrapped description continuation line — spec [M-10]'s exact
377        // phantom-subcommand example.
378        assert!(!is_command_name_shaped("treat them as errors"));
379        // Uppercase placeholder tokens (`BYTES`, `FORMAT`) are never real
380        // command names.
381        assert!(!is_command_name_shaped("BYTES"));
382        assert!(!is_command_name_shaped(""));
383        // Must start with a letter, not a digit.
384        assert!(!is_command_name_shaped("42start"));
385    }
386}