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}