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}