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
//! `mandible`: the binary. Wires the extraction pipeline, the cache, and the
//! TUI together; also hosts the non-interactive `--doctor` diagnostic.
#![forbid(unsafe_code)]
// Size ceilings. Both lints are opt-in; the thresholds live in the
// workspace `clippy.toml`, and CI's `-D warnings` makes them gates.
#![warn(clippy::too_many_lines)]
#![warn(clippy::cognitive_complexity)]
mod about;
mod app_runner;
mod background;
mod cli;
mod discovery;
mod doctor;
mod pipeline;
mod report;
mod root_guard;
mod shell_init;
use clap::{CommandFactory, Parser};
use cli::Cli;
use mandible_tui::terminal::Sink;
use std::io::Write;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_env("MANDIBLE_LOG"))
.with_writer(std::io::stderr)
.try_init()
.ok();
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, bin_name, &mut std::io::stdout());
return Ok(());
}
if let Some(shell) = cli.shell_init {
print!("{}", shell.snippet());
return Ok(());
}
// Before any probe spawns (spec §6 rule 10): the TUI, `--doctor`,
// `--report` and `--review` all pass through here first, and nothing
// above this point resolves or runs a tool.
if let Some(refusal) = root_guard::refusal(nix::unistd::Uid::effective(), cli.allow_root) {
anyhow::bail!(refusal);
}
if let Some(seed) = cli.review {
if !Sink::Stdout.is_tty() {
anyhow::bail!(
"mandible --review requires an interactive terminal (stdout is not a tty)."
);
}
return app_runner::run_review(&cli.audit_dir, seed);
}
let Some(tool) = cli.target_tool() else {
// A program whose whole job is showing people help answers a bare
// invocation with help, not with a one-line usage string: clap has
// already generated the real thing, listing every mode this
// paragraph would otherwise have to keep in sync by hand.
//
// On stderr, and exiting non-zero, because this *is* the error
// path — `-h`/`--help` remain the ways to ask for help on stdout,
// and nothing that reads mandible's stdout (a shell binding, a
// pipe) is handed a screen of text it did not ask for.
//
// The short line below is what carries the failing exit, since
// `std::process::exit` is confined to `mandible-extract/src/exec/`
// workspace-wide (see the `--doctor` note further down). It says
// only what is missing; the help above it says everything else.
let mut cmd = Cli::command();
write!(std::io::stderr(), "{}", cmd.render_help())?;
anyhow::bail!("no tool given");
};
let tool = tool.to_string();
if let Some(refusal) = cli.subcommand_path_conflict() {
anyhow::bail!(refusal);
}
// `mandible mandible` shows the about screen rather than extracting
// the binary's own `--help`. Self-introspection is still available
// through `mandible --doctor mandible`, which runs the real pipeline
// against it — the form anyone actually wants for that purpose.
//
// Not under `--print-selection`, where stdout belongs to the calling
// shell: an easter egg printed there would be handed to the user as a
// command to run. In that mode mandible is a tool like any other, and
// browsing it composes `mandible` like browsing any other name does.
if cli.doctor.is_none()
&& cli.report.is_none()
&& !cli.print_selection
&& tool == env!("CARGO_PKG_NAME")
{
about::print();
return Ok(());
}
let resolved = mandible_extract::resolve_tool(&tool);
// Process-signalling and machine-state tools are no longer refused
// outright: `--help` is measured harmless on all of them and is where
// their flag list lives, so they open like any other tool and the exec
// chokepoint restricts them to that one shape (spec §6 rule 0). The
// visible consequence is that they never gain a subcommand tree, which
// is correct — they do not have one.
if let Some(report_tool) = &cli.report {
let loaded = pipeline::load(report_tool);
report::print_report(&loaded);
// Unlike `--doctor` below, this never bails on an empty root: the
// whole point of `--report` is to hand a maintainer *something*
// paste-ready even when extraction found nothing at all — the
// printed block already says so (`doctor::build_report`'s own
// "no tier produced a root node" line), and a non-zero exit here
// would just make that block harder to pipe/redirect for no
// benefit.
return Ok(());
}
if let Some(doctor_tool) = &cli.doctor {
let loaded = pipeline::load(doctor_tool);
let ok = loaded.root.is_some();
doctor::print_report(&loaded);
// Note: deliberately `anyhow::bail!` rather than
// `std::process::exit`, so `std::process` stays confined to
// `mandible-extract/src/exec/` workspace-wide (spec §6, §8) — even
// though `exit` doesn't spawn anything, keeping the grep-based
// invariant test literal and unambiguous is worth the minor
// indirection.
if !ok {
anyhow::bail!("doctor: no extraction tier produced a result for {doctor_tool:?}");
}
return Ok(());
}
// Which stream the UI is drawn on, decided once here and carried
// everywhere that touches the terminal (`mandible_tui::terminal::Sink`).
// Under `--print-selection` stdout is the calling shell's, so the UI
// moves to stderr and stdout carries the composed command and nothing
// else.
let sink = if cli.print_selection {
Sink::Stderr
} else {
Sink::Stdout
};
if !sink.is_tty() {
let stream = sink.name();
if cli.print_selection {
anyhow::bail!(
"mandible --print-selection draws on stderr, and {stream} is not a tty. \
Run it with stderr attached to your terminal — \
`sel=$(mandible --print-selection {tool})` does that, and \
`mandible --shell-init bash` prints a binding that does it for you."
);
}
anyhow::bail!(
"mandible requires an interactive terminal ({stream} is not a tty). \
Try running it directly in a terminal, or use `mandible --doctor {tool}` \
for a non-interactive report."
);
}
// Startup does *no* extraction. Resolving the name on `PATH` is a
// filesystem lookup with no subprocess spawn, so it stays here — it is
// the one failure worth reporting on the command line rather than
// inside a TUI the user then has to quit. Everything else, including
// the root node's own `--help` probe, happens on the background warmer
// once the first frame is already on screen.
//
// Blocking here is what made launching feel slow: extracting the root
// synchronously cost ~1.1s for `gh` and ~0.7s for `docker` before a
// single pixel was drawn, and the cobra-style tools where that matters
// most are exactly the ones with the biggest trees.
if resolved.path.is_none() {
anyhow::bail!(
"{tool:?} was not found on PATH. Run `mandible --doctor {tool}` for details on \
what was tried."
);
}
// Every probe runs inside this folder (spec §6 rule 8); without it the
// TUI would open on an empty page with no reason given.
if let Err(e) = mandible_extract::exec::scratch_root() {
anyhow::bail!(
"mandible runs each tool inside a private folder at ~/.cache/mandible and \
could not create it: {e}. Check that HOME is set and writable, then try again."
);
}
let stub = mandible_core::CommandNode::new(tool.clone(), mandible_core::Provenance::default());
let mut app = app_runner::new_app(tool, stub, sink);
app.print_selection = cli.print_selection;
// Held as an intent rather than acted on: the tree is a bare stub until
// the background root fill lands, so there is nothing to select yet
// (spec §5.2 step 1, §5.4).
app.requested_path = cli.requested_path();
app_runner::run(app, sink)
}