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
//! LANE 5 (test-cli-e2e), every subcommand's `--help` surface and the
//! bad-subcommand boundary, driven over the SHIPPED binary
//! (`CARGO_BIN_EXE_keyhog`).
//!
//! These are the cheapest, most-load-bearing e2e contracts a packager hits:
//! 1. EVERY documented subcommand answers `--help` with exit 0 AND the help
//! text names the subcommand (`Usage: keyhog <name> …`), a renamed or
//! dropped subcommand, or a help renderer that panics, fails here.
//! 2. EVERY subcommand answers `-h` (the short alias) identically, clap wires
//! both, but a `mut_subcommand`/`mut_arg` override (the dynamic-detector-
//! count help in `args::command`) can silently break one spelling.
//! 3. The top-level `--help` / `-h` / no-args paths exit 0 and list every
//! subcommand by name (the menu a first-run user sees).
//! 4. A bogus subcommand and a bogus top-level flag exit 2 (user error,
//! per the documented exit-code contract) and say so on stderr.
//!
//! DATA-DRIVEN: the subcommand list is the single source of truth; each entry
//! produces ~4 assertion cases (long help exit, name-in-help, short help exit,
//! short==long agreement) → 15 subcommands × 4 ≈ 60 cases here, plus the
//! top-level and negative cases. Every assert pins an EXACT exit code and an
//! EXACT substring (never `!is_empty`).
use std::process::Command;
fn binary() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
/// Every subcommand the CLI ships, as the user types them, read from the
/// compiled clap model rather than a hand-kept copy. A hand-kept list drifts
/// silently: before this was derived it omitted `config` and
/// `bloom-diagnostic`, so two shipped surfaces had no help contract at all.
/// Hidden subcommands are excluded because they are not part of the advertised
/// menu `top_level_help_lists_every_subcommand_by_name` checks.
fn subcommands() -> Vec<String> {
keyhog::args::command()
.get_subcommands()
.filter(|sub| !sub.is_hide_set())
.map(|sub| sub.get_name().to_owned())
.collect()
}
/// Deriving the matrix from the clap model makes an ADDED subcommand
/// automatically contract-covered, but it would also let a REMOVED or renamed
/// subcommand pass silently, both sides move together. This pin is the other
/// half: the advertised menu is a compatibility surface, so changing it must
/// be a deliberate, reviewed diff here.
#[test]
fn advertised_subcommand_set_is_exactly_the_shipped_menu() {
assert_eq!(
subcommands(),
[
"scan",
"config",
"hook",
"detectors",
"explain",
"diff",
"calibrate",
"calibrate-autoroute",
"watch",
"completion",
"backend",
"doctor",
"bloom-diagnostic",
"update",
"repair",
"uninstall",
"scan-system",
"daemon",
],
"the advertised subcommand menu changed; update the docs, completions, \
and reference pages in the same change"
);
}
/// `action-report` is deliberately hidden: it is the GitHub Action's internal
/// receipt renderer, not an operator command. It must still answer `--help`
/// so a workflow debugging session is not a dead end.
#[test]
fn hidden_action_report_still_answers_help() {
let (code, stdout, stderr) = run(&["action-report", "--help"]);
assert_eq!(
code,
Some(0),
"hidden `action-report --help` must still exit 0; stderr={stderr}"
);
assert!(
stdout.contains("keyhog action-report"),
"hidden `action-report --help` must render its own Usage line; got:\n{stdout}"
);
}
fn run(args: &[&str]) -> (Option<i32>, String, String) {
let out = Command::new(binary())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `keyhog {}`: {e}", args.join(" ")));
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn every_subcommand_long_help_exits_zero_and_names_itself() {
for sc in subcommands() {
let sc = sc.as_str();
let (code, stdout, stderr) = run(&[sc, "--help"]);
assert_eq!(
code,
Some(0),
"`keyhog {sc} --help` must exit 0; stderr={stderr}"
);
// clap renders `Usage: keyhog <name> …` to stdout for --help. The
// subcommand name MUST appear so a stale/misrouted help can't pass.
let needle = format!("keyhog {sc}");
assert!(
stdout.contains(&needle),
"`keyhog {sc} --help` stdout must contain {needle:?} (the Usage line); got:\n{stdout}"
);
}
}
#[test]
fn every_subcommand_short_help_exits_zero() {
for sc in subcommands() {
let sc = sc.as_str();
let (code, stdout, stderr) = run(&[sc, "-h"]);
assert_eq!(
code,
Some(0),
"`keyhog {sc} -h` must exit 0; stderr={stderr}"
);
assert!(
stdout.contains(&format!("keyhog {sc}")),
"`keyhog {sc} -h` stdout must contain the Usage line; got:\n{stdout}"
);
}
}
#[test]
fn short_and_long_help_agree_for_every_subcommand() {
// `-h` is the terse alias of `--help`; clap derives both. They are not
// byte-identical (short vs long arg descriptions), but BOTH must carry the
// Usage line, the dynamic-help `mut_subcommand` wiring in `args::command`
// could regress one spelling while leaving the other intact, which this
// pins.
for sc in subcommands() {
let sc = sc.as_str();
let long = run(&[sc, "--help"]);
let short = run(&[sc, "-h"]);
assert_eq!(
long.0, short.0,
"`keyhog {sc} --help` and `-h` must share an exit code"
);
let usage = format!("keyhog {sc}");
assert!(
long.1.contains(&usage) && short.1.contains(&usage),
"both help spellings for `{sc}` must contain {usage:?}"
);
}
}
#[test]
fn top_level_help_lists_every_subcommand_by_name() {
for flag in ["--help", "-h"] {
let (code, stdout, stderr) = run(&[flag]);
assert_eq!(
code,
Some(0),
"`keyhog {flag}` must exit 0; stderr={stderr}"
);
for sc in subcommands() {
let sc = sc.as_str();
assert!(
stdout.contains(sc),
"`keyhog {flag}` must list the `{sc}` subcommand in its menu; got:\n{stdout}"
);
}
}
}
#[test]
fn no_args_prints_help_and_exits_zero() {
// `keyhog` with no subcommand prints the top-level help (main.rs `None`
// arm) and exits 0 (the friendly first-run path).
let (code, stdout, _stderr) = run(&[]);
assert_eq!(code, Some(0), "bare `keyhog` must exit 0");
assert!(
stdout.contains("keyhog") && stdout.contains("scan"),
"bare `keyhog` must print the help menu naming `scan`; got:\n{stdout}"
);
}
#[test]
fn unknown_subcommand_exits_two_and_reports_it() {
let (code, _stdout, stderr) = run(&["definitely-not-a-subcommand"]);
assert_eq!(
code,
Some(2),
"an unknown subcommand is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("unrecognized subcommand")
|| stderr.contains("definitely-not-a-subcommand"),
"stderr must name the bad subcommand; got:\n{stderr}"
);
}
#[test]
fn unknown_top_level_flag_exits_two() {
let (code, _stdout, stderr) = run(&["--no-such-flag"]);
assert_eq!(
code,
Some(2),
"an unknown top-level flag is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("unexpected argument") || stderr.contains("--no-such-flag"),
"stderr must name the bad flag; got:\n{stderr}"
);
}
#[test]
fn version_flag_exits_zero_and_prints_version_block() {
// `-V` / `--version` both fast-path to `print_version_info` (exit 0). The
// block carries the build provenance every scan must trace to (commit +
// detector digest) (assert the labels, not just non-emptiness).
for flag in ["-V", "--version"] {
let (code, stdout, stderr) = run(&[flag]);
assert_eq!(
code,
Some(0),
"`keyhog {flag}` must exit 0; stderr={stderr}"
);
assert!(
stdout.contains("KeyHog v"),
"`keyhog {flag}` must print the version banner; got:\n{stdout}"
);
assert!(
stdout.contains("Commit:") && stdout.contains("Detector Set:"),
"`keyhog {flag}` must print build provenance (Commit + Detector Set); got:\n{stdout}"
);
}
}