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}
91
92/// True if `s` looks like a real command/subcommand name: lowercase,
93/// starting with a letter, and otherwise only letters/digits/`_`/`.`/`-`
94/// (`^[a-z][a-z0-9_.-]*$`, spec §7 Tier B rule 3).
95///
96/// This is the shared definition of "looks like a name, not a fabricated
97/// fragment" — used by any extraction tier deciding whether a candidate
98/// bare-word entry is really a subcommand (rejecting prose fragments like
99/// *"treat them as errors"* or placeholder tokens like `BYTES`), and by
100/// the coverage harness (spec §13.1) as one half of its structure-sanity
101/// check: a tier that starts emitting names failing this test again is
102/// exactly the class of regression [M-10] was.
103pub fn is_command_name_shaped(s: &str) -> bool {
104    // A trailing `.`/`-`/`_` is sentence or hyphenation punctuation, never
105    // part of a command name. Interior ones are legitimate (`mount.nfs`,
106    // `apt-get`, `foo_bar`), which is why the character class below allows
107    // them at all — but allowing them at the end let prose fragments like
108    // *"testing."* and *"skipped."* through the name-shape check and into
109    // the tree as fabricated subcommands ([M-10]).
110    if s.ends_with(['.', '-', '_']) {
111        return false;
112    }
113    let mut chars = s.chars();
114    match chars.next() {
115        Some(c) if c.is_ascii_lowercase() => {}
116        _ => return false,
117    }
118    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'))
119}
120
121impl CommandNode {
122    /// A minimal, empty node with the given name and provenance. Useful as
123    /// a starting point for tiers and for tests.
124    pub fn new(name: impl Into<String>, provenance: Provenance) -> CommandNode {
125        CommandNode {
126            name: name.into(),
127            aliases: Vec::new(),
128            summary: None,
129            description: None,
130            usage: Vec::new(),
131            flags: Vec::new(),
132            positionals: Vec::new(),
133            subcommands: Vec::new(),
134            examples: Vec::new(),
135            hidden: false,
136            deprecated: None,
137            children_filled: false,
138            group: None,
139            unparsed: Vec::new(),
140            detected_framework: None,
141            provenance,
142            heading_attested: false,
143        }
144    }
145}
146
147/// A single flag/option, e.g. `-i, --interactive`.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct Flag {
150    /// Short spelling, e.g. `Some('i')` for `-i`.
151    pub short: Option<char>,
152    /// Long spelling, e.g. `Some("interactive".into())` for `--interactive`.
153    pub long: Option<String>,
154    /// The value placeholder, e.g. `"FILE"` in `--output FILE`.
155    pub value_name: Option<String>,
156    /// Whether this flag takes no value, a required value, or an optional
157    /// one.
158    pub value_kind: ValueKind,
159    /// Enumerated choices, e.g. `{json|yaml|table}` for `--format`.
160    pub choices: Vec<Text>,
161    /// True if this flag may be given more than once.
162    pub repeatable: bool,
163    /// True if this flag is required.
164    pub required: bool,
165    /// True if this flag should be hidden by default.
166    pub hidden: bool,
167    /// `Some(reason)` when this flag is deprecated.
168    pub deprecated: Option<Text>,
169    /// True when this flag was declared on an ancestor node and propagated
170    /// down (cobra "persistent flag" / carapace `persistentflags`).
171    /// Rendered in a separate, dimmed group in the detail pane.
172    pub inherited: bool,
173    /// Display grouping from the source, e.g. tar's `"Main operation mode"`.
174    pub group: Option<String>,
175    /// The flag's description.
176    pub description: Option<Text>,
177    /// The flag's default value, if documented.
178    pub default: Option<Text>,
179    /// An environment variable that also sets this flag, if documented.
180    pub env_var: Option<String>,
181    /// Which source(s) contributed this flag's fields.
182    pub provenance: Provenance,
183}
184
185impl Flag {
186    /// A minimal flag with only a long spelling.
187    pub fn long(name: impl Into<String>, provenance: Provenance) -> Flag {
188        Flag {
189            short: None,
190            long: Some(name.into()),
191            value_name: None,
192            value_kind: ValueKind::None,
193            choices: Vec::new(),
194            repeatable: false,
195            required: false,
196            hidden: false,
197            deprecated: None,
198            inherited: false,
199            group: None,
200            description: None,
201            default: None,
202            env_var: None,
203            provenance,
204        }
205    }
206
207    /// The canonical identity key used for cross-source matching and
208    /// addressing: prefer the long name, fall back to the short letter.
209    /// Returns `None` for a degenerate flag with neither (which cannot be
210    /// addressed and is only matched positionally during merge).
211    pub fn key(&self) -> Option<crate::noderef::FlagKey> {
212        if let Some(long) = &self.long {
213            Some(crate::noderef::FlagKey::Long(long.clone()))
214        } else {
215            self.short.map(crate::noderef::FlagKey::Short)
216        }
217    }
218
219    /// True if `key` addresses this flag, checking both spellings
220    /// regardless of which one is considered canonical.
221    pub fn matches_key(&self, key: &crate::noderef::FlagKey) -> bool {
222        match key {
223            crate::noderef::FlagKey::Long(l) => self.long.as_deref() == Some(l.as_str()),
224            crate::noderef::FlagKey::Short(s) => self.short == Some(*s),
225        }
226    }
227
228    /// A human-readable spelling for display and clipboard copy, e.g.
229    /// `"-i, --interactive"`, `"--output FILE"`.
230    pub fn spelling(&self) -> String {
231        let mut parts = Vec::new();
232        if let Some(s) = self.short {
233            parts.push(format!("-{s}"));
234        }
235        if let Some(l) = &self.long {
236            parts.push(format!("--{l}"));
237        }
238        let mut spelling = parts.join(", ");
239        if let Some(name) = &self.value_name {
240            match self.value_kind {
241                ValueKind::Required => spelling.push_str(&format!(" {name}")),
242                ValueKind::Optional => spelling.push_str(&format!("[={name}]")),
243                ValueKind::None => {}
244            }
245        }
246        spelling
247    }
248}
249
250/// Whether a flag takes a value, and if so, whether it's required.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
252pub enum ValueKind {
253    /// The flag is a boolean switch; it takes no value.
254    #[default]
255    None,
256    /// The flag must be given a value.
257    Required,
258    /// The flag may optionally be given a value.
259    Optional,
260}
261
262/// A positional argument, e.g. `<pathspec>...`.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct Positional {
265    /// The argument's name as shown in usage, e.g. `"pathspec"`.
266    pub name: String,
267    /// True if this positional must be supplied.
268    pub required: bool,
269    /// True if this positional accepts multiple values (`...`).
270    pub variadic: bool,
271    /// The positional's description.
272    pub description: Option<Text>,
273    /// Which source(s) contributed this positional's fields.
274    pub provenance: Provenance,
275}
276
277/// A worked example: a command line plus an optional explanation.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct Example {
280    /// The example command line, verbatim.
281    pub command: Text,
282    /// An optional explanation of what the example does.
283    pub explanation: Option<Text>,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn is_command_name_shaped_accepts_real_names() {
292        assert!(is_command_name_shaped("commit"));
293        assert!(is_command_name_shaped("http-push"));
294        assert!(is_command_name_shaped("sha3-256"));
295        assert!(is_command_name_shaped("v7"));
296    }
297
298    #[test]
299    fn is_command_name_shaped_rejects_prose_and_placeholders() {
300        // A wrapped description continuation line — spec [M-10]'s exact
301        // phantom-subcommand example.
302        assert!(!is_command_name_shaped("treat them as errors"));
303        // Uppercase placeholder tokens (`BYTES`, `FORMAT`) are never real
304        // command names.
305        assert!(!is_command_name_shaped("BYTES"));
306        assert!(!is_command_name_shaped(""));
307        // Must start with a letter, not a digit.
308        assert!(!is_command_name_shaped("42start"));
309    }
310}