1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//! Command-line argument parsing.
use crate::shell_init::ShellInit;
use clap::{Parser, ValueHint};
use std::path::PathBuf;
/// mandible: a universal, interactive TUI reference for CLI tools.
#[derive(Parser, Debug)]
#[command(name = "mandible", version, about, long_about = None)]
#[command(group(clap::ArgGroup::new("diagnostic").args(["doctor", "report"]).multiple(true)))]
pub struct Cli {
/// The tool to open, e.g. "git". Required unless `--doctor`, `--report`,
/// or `--review` is given.
///
/// Completes to the command names on `$PATH`: the argument is a tool
/// mandible will run `--help` on, so the candidate set is exactly the
/// shell's own command table, not a list of files (spec §15).
#[arg(value_hint = ValueHint::CommandName)]
pub tool: Option<String>,
/// A subcommand path within TOOL to open at, e.g. `mandible cargo
/// clippy` or `mandible git remote add` (spec §5.4). Opens exactly
/// where browsing to that node would land; a name the tool's own help
/// never documents still lands when a `<tool>-<sub>` binary is on PATH,
/// marked unverified. TUI only — `--doctor` and `--report` describe a
/// whole tool, and take a tool name alone.
#[arg(value_name = "SUBCOMMAND")]
pub subcommand: Vec<String>,
/// Print extraction diagnostics for TOOL (tier statuses, node/flag
/// counts, %described, catalog vendoring date, timing) instead of
/// opening the TUI. See spec §5.3.
#[arg(long, value_name = "TOOL", value_hint = ValueHint::CommandName)]
pub doctor: Option<String>,
/// Print a paste-ready bug report for TOOL (mandible's version, TOOL's
/// version if resolvable, the `--doctor` diagnostic, and a raw `--help`
/// capture) instead of opening the TUI, followed by the issues URL.
/// Symmetric with `--doctor`, not a `report` subcommand: `mandible`
/// already takes a bare `[TOOL]` positional (`mandible git`), so a
/// `report` subcommand would be ambiguous against a tool literally
/// named `report`.
#[arg(long, value_name = "TOOL", value_hint = ValueHint::CommandName)]
pub report: Option<String>,
/// Print a shell completion script for SHELL to stdout and exit,
/// instead of opening the TUI. Packaged builds also install
/// pre-generated completions to the standard per-distro paths (spec
/// §15); this flag exists for `cargo install` users and anyone
/// scripting their own install (e.g. `mandible --completions zsh >
/// ~/.zfunc/_mandible`).
#[arg(long, value_name = "SHELL")]
pub completions: Option<clap_complete::Shell>,
/// Browse as usual, but make `Enter` print the selected command (plus
/// the selected flag, if search landed on one) to stdout and exit,
/// instead of expanding the row. The TUI itself draws on stderr, so
/// stdout carries the composed line and nothing else — which is what
/// lets a shell binding put it on the prompt, ready to edit:
/// `mandible --shell-init bash` prints one.
///
/// Nothing is printed if you quit instead (`q`, `Ctrl-C`), so the
/// binding leaves the line alone. `→`/`l` still expand.
#[arg(long, conflicts_with_all = ["doctor", "report", "review", "completions", "shell_init"])]
pub print_selection: bool,
/// Print the shell integration for SHELL (`bash` or `zsh`) to stdout
/// and exit: a widget that opens `mandible --print-selection` on the
/// word already on your command line and replaces the line with what
/// you select. Add `eval "$(mandible --shell-init bash)"` to your
/// shell's rc file.
///
/// Emitted by the binary rather than installed as a file, for the same
/// reason `--completions` is (spec §15): one generator, so no packaging
/// channel can ship a snippet that disagrees with the flags this
/// version has.
#[arg(long, value_name = "SHELL")]
pub shell_init: Option<ShellInit>,
/// Review `<dir>/<SEED>.toml`'s pending entries (`xtask audit sample`'s
/// output) one at a time, inside the real TUI: each tool opens exactly
/// as `mandible <tool>` would, and a verdict — `c`/`i`/`w`/`s` for
/// correct/incomplete/wrong/skip, then Enter — is written back to the
/// manifest immediately, before the next tool opens. Mutually exclusive
/// with `TOOL`/`--doctor`/`--report` in practice (checked first).
#[arg(long, value_name = "SEED")]
pub review: Option<u64>,
/// The directory `--review` reads and writes `<SEED>.toml` under.
/// Mirrors `xtask audit`'s own `--dir` default.
#[arg(long, default_value = "audit")]
pub audit_dir: PathBuf,
/// Let `--doctor` and `--report` probes run as long as they need.
/// Without it each probe is stopped after 30 seconds and the timeout is
/// reported as a failure.
#[arg(long, requires = "diagnostic")]
pub no_timeout: bool,
/// Proceed when running as root (uid 0) instead of refusing. mandible
/// never asks for or gains privileges itself; this only lifts the
/// refusal on privilege the user already brought. See spec §6 rule 10.
#[arg(long)]
pub allow_root: bool,
}
impl Cli {
/// The tool name this invocation is ultimately about, whether given as
/// the positional argument, as `--doctor`'s value, or as `--report`'s
/// value. `--report` is checked first: if both diagnostic flags are
/// somehow given together, the more specific ask (a bug report) wins
/// over the plainer diagnostic dump.
pub fn target_tool(&self) -> Option<&str> {
self.report
.as_deref()
.or(self.doctor.as_deref())
.or(self.tool.as_deref())
}
/// The cap the diagnostics run their probes under: the standard one,
/// or none with `--no-timeout`.
pub fn diagnostic_limit(&self) -> Option<std::time::Duration> {
if self.no_timeout {
None
} else {
Some(mandible_extract::exec::EXTRACT_LIMIT)
}
}
/// The full node path this invocation asks to open — the tool name
/// followed by [`Self::subcommand`] — or `None` when no subcommand was
/// given (`mandible git`, which opens at the root like it always has).
pub fn requested_path(&self) -> Option<Vec<String>> {
if self.subcommand.is_empty() {
return None;
}
let tool = self.tool.as_deref()?;
let mut path = vec![tool.to_string()];
path.extend(self.subcommand.iter().cloned());
Some(path)
}
/// The refusal for the one combination no mode can honour: extra words
/// alongside a whole-tool diagnostic.
///
/// A subcommand path addresses one node of one tool's tree, which is a
/// thing only the TUI has. `--doctor`/`--report` take their tool as the
/// flag's own value, so **every** positional beside them is extra — with
/// `--doctor cargo clippy`, clap binds `clippy` to the tool positional
/// and the diagnostic still describes `cargo`.
///
/// Said plainly rather than dropped. Before a path was accepted at all
/// this combination was a parse error, and silently ignoring it now
/// would print a report about `cargo` to a reader who believes they
/// asked about `cargo clippy` — trading a clear refusal for a confidently
/// mislabelled answer.
pub fn subcommand_path_conflict(&self) -> Option<String> {
if self.doctor.is_none() && self.report.is_none() {
return None;
}
let stray: Vec<&str> = self
.tool
.as_deref()
.into_iter()
.chain(self.subcommand.iter().map(String::as_str))
.collect();
if stray.is_empty() {
return None;
}
let words = stray.join(" ");
let tool = self.target_tool().unwrap_or_default();
Some(format!(
"--doctor and --report take a tool name only; drop {words:?} or run \
`mandible {tool} {words}` for the interactive tree"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(std::iter::once("mandible").chain(args.iter().copied()))
.expect("should parse")
}
/// Issue #70: `cargo clippy` is a real command that `cargo --help` never
/// lists, and mandible used to refuse the words outright with
/// "unexpected argument 'clippy' found".
#[test]
fn a_subcommand_path_parses_into_tool_and_words() {
let cli = parse(&["cargo", "clippy"]);
assert_eq!(cli.tool.as_deref(), Some("cargo"));
assert_eq!(cli.subcommand, vec!["clippy".to_string()]);
assert_eq!(
cli.requested_path(),
Some(vec!["cargo".to_string(), "clippy".to_string()])
);
}
#[test]
fn several_words_nest_in_order() {
let cli = parse(&["git", "remote", "add"]);
assert_eq!(
cli.requested_path(),
Some(vec![
"git".to_string(),
"remote".to_string(),
"add".to_string()
])
);
}
#[test]
fn a_bare_tool_requests_no_path() {
assert!(parse(&["git"]).requested_path().is_none());
}
/// A path alongside a whole-tool diagnostic is refused, never dropped:
/// silently ignoring it reports on `cargo` while the reader believes
/// they asked about `cargo clippy`. This was a parse error before a path
/// was accepted at all, and must not become a mislabelled answer.
#[test]
fn a_path_alongside_a_whole_tool_diagnostic_is_refused() {
let refusal = parse(&["--doctor", "cargo", "clippy"])
.subcommand_path_conflict()
.expect("must refuse rather than drop the words");
assert!(refusal.contains("clippy"), "{refusal}");
assert!(parse(&["--report", "cargo", "clippy"])
.subcommand_path_conflict()
.is_some());
// One stray word is as wrong as two: `--doctor cargo clippy` binds
// `clippy` to the tool positional, and the report is still `cargo`'s.
assert!(parse(&["--doctor", "cargo", "clippy"])
.subcommand
.is_empty());
}
#[test]
fn no_timeout_goes_with_a_diagnostic_and_nothing_else() {
assert!(parse(&["--doctor", "git", "--no-timeout"]).no_timeout);
assert!(parse(&["--report", "git", "--no-timeout"]).no_timeout);
assert!(!parse(&["--doctor", "git"]).no_timeout);
assert!(Cli::try_parse_from(["mandible", "git", "--no-timeout"]).is_err());
}
#[test]
fn no_timeout_lifts_the_cap_the_diagnostics_run_under() {
let standard = parse(&["--doctor", "git"]).diagnostic_limit();
assert_eq!(standard, Some(std::time::Duration::from_secs(30)));
assert_eq!(
parse(&["--doctor", "git", "--no-timeout"]).diagnostic_limit(),
None
);
assert_eq!(
parse(&["--report", "git", "--no-timeout"]).diagnostic_limit(),
None
);
}
#[test]
fn the_ordinary_forms_conflict_with_nothing() {
assert!(parse(&["cargo", "clippy"])
.subcommand_path_conflict()
.is_none());
assert!(parse(&["--doctor", "cargo"])
.subcommand_path_conflict()
.is_none());
}
}