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 if this flag should be hidden by default.
211    pub hidden: bool,
212    /// `Some(reason)` when this flag is deprecated.
213    pub deprecated: Option<Text>,
214    /// True when this flag was declared on an ancestor node and propagated
215    /// down (cobra "persistent flag" / carapace `persistentflags`).
216    /// Rendered in a separate, dimmed group in the detail pane.
217    pub inherited: bool,
218    /// Display grouping from the source, e.g. tar's `"Main operation mode"`.
219    pub group: Option<String>,
220    /// The flag's description.
221    pub description: Option<Text>,
222    /// The flag's default value, if documented.
223    pub default: Option<Text>,
224    /// An environment variable that also sets this flag, if documented.
225    pub env_var: Option<String>,
226    /// Which source(s) contributed this flag's fields.
227    pub provenance: Provenance,
228}
229
230impl Flag {
231    /// A minimal flag with only a long spelling.
232    pub fn long(name: impl Into<String>, provenance: Provenance) -> Flag {
233        Flag {
234            short: None,
235            long: Some(name.into()),
236            value_name: None,
237            value_kind: ValueKind::None,
238            choices: Vec::new(),
239            repeatable: false,
240            required: false,
241            negatable: false,
242            hidden: false,
243            deprecated: None,
244            inherited: false,
245            group: None,
246            description: None,
247            default: None,
248            env_var: None,
249            provenance,
250        }
251    }
252
253    /// The canonical identity key used for cross-source matching and
254    /// addressing: prefer the long name, fall back to the short letter.
255    /// Returns `None` for a degenerate flag with neither (which cannot be
256    /// addressed and is only matched positionally during merge).
257    pub fn key(&self) -> Option<crate::noderef::FlagKey> {
258        if let Some(long) = &self.long {
259            Some(crate::noderef::FlagKey::Long(long.clone()))
260        } else {
261            self.short.map(crate::noderef::FlagKey::Short)
262        }
263    }
264
265    /// True if `key` addresses this flag, checking both spellings
266    /// regardless of which one is considered canonical.
267    pub fn matches_key(&self, key: &crate::noderef::FlagKey) -> bool {
268        match key {
269            crate::noderef::FlagKey::Long(l) => self.long.as_deref() == Some(l.as_str()),
270            crate::noderef::FlagKey::Short(s) => self.short == Some(*s),
271        }
272    }
273
274    /// A human-readable spelling for display and clipboard copy, e.g.
275    /// `"-i, --interactive"`, `"--output FILE"`, or `"-S, --[no-]staged"`
276    /// for a negatable boolean — the `[no-]` is reconstructed for display
277    /// from `negatable`, never stored in `long` itself (see the field's
278    /// doc comment).
279    pub fn spelling(&self) -> String {
280        let mut parts = Vec::new();
281        if let Some(s) = self.short {
282            parts.push(format!("-{s}"));
283        }
284        if let Some(l) = &self.long {
285            if self.negatable {
286                parts.push(format!("--[no-]{l}"));
287            } else {
288                parts.push(format!("--{l}"));
289            }
290        }
291        let mut spelling = parts.join(", ");
292        if let Some(name) = &self.value_name {
293            match self.value_kind {
294                ValueKind::Required => spelling.push_str(&format!(" {name}")),
295                ValueKind::Optional => spelling.push_str(&format!("[={name}]")),
296                ValueKind::None => {}
297            }
298        }
299        spelling
300    }
301}
302
303/// Whether a flag takes a value, and if so, whether it's required.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
305pub enum ValueKind {
306    /// The flag is a boolean switch; it takes no value.
307    #[default]
308    None,
309    /// The flag must be given a value.
310    Required,
311    /// The flag may optionally be given a value.
312    Optional,
313}
314
315/// A positional argument, e.g. `<pathspec>...`.
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub struct Positional {
318    /// The argument's name as shown in usage, e.g. `"pathspec"`.
319    pub name: String,
320    /// True if this positional must be supplied.
321    pub required: bool,
322    /// True if this positional accepts multiple values (`...`).
323    pub variadic: bool,
324    /// The positional's description.
325    pub description: Option<Text>,
326    /// Which source(s) contributed this positional's fields.
327    pub provenance: Provenance,
328}
329
330/// A worked example: a command line plus an optional explanation.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct Example {
333    /// The example command line, verbatim.
334    pub command: Text,
335    /// An optional explanation of what the example does.
336    pub explanation: Option<Text>,
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn is_command_name_shaped_accepts_real_names() {
345        assert!(is_command_name_shaped("commit"));
346        assert!(is_command_name_shaped("http-push"));
347        assert!(is_command_name_shaped("sha3-256"));
348        assert!(is_command_name_shaped("v7"));
349    }
350
351    #[test]
352    fn is_command_name_shaped_rejects_prose_and_placeholders() {
353        // A wrapped description continuation line — spec [M-10]'s exact
354        // phantom-subcommand example.
355        assert!(!is_command_name_shaped("treat them as errors"));
356        // Uppercase placeholder tokens (`BYTES`, `FORMAT`) are never real
357        // command names.
358        assert!(!is_command_name_shaped("BYTES"));
359        assert!(!is_command_name_shaped(""));
360        // Must start with a letter, not a digit.
361        assert!(!is_command_name_shaped("42start"));
362    }
363}