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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! LANE: cli watch/config surface, driven over the SHIPPED binary
//! (`CARGO_BIN_EXE_keyhog`), never the library, so these prove the exact
//! argument-parsing contract a packager / CI author / editor-integration hits.
//!
//! What is pinned here (every assert names an EXACT exit code and an EXACT
//! string; never `!is_empty`):
//! 1. `--help` surfaces list the REAL flag names for the top level, `scan`,
//! `watch`, and `config` (a renamed/dropped flag fails here).
//! 2. A config-affecting flag actually REACHES the resolved config that
//! `keyhog config --effective` dumps (parse → merge → render round-trip),
//! asserted against the concrete emitted `key = value` line.
//! 3. Every documented mutually-exclusive flag pair errors with clap's exact
//! "cannot be used with" diagnostic and the user-error exit code 2.
//! 4. A missing required flag (`config` without `--effective`) and an unknown
//! flag both exit 2 with clap's exact diagnostic.
//!
//! HOST-INDEPENDENCE: none of these invocations execute a scan backend. The
//! `config --effective` path resolves config and renders it WITHOUT probing an
//! accelerator (default `backend = auto`, no `--require-gpu`), so the emitted
//! lines are identical on a GPU box and a GPU-less CI runner. Nothing here
//! asserts that a SIMD/GPU/Hyperscan backend is present.
//!
//! Exit codes are the documented contract: 0 = success, 2 = user error
//! (clap parse failure), per `crate::exit_codes` (`EXIT_USER_ERROR = 2`).
use std::process::Command;
fn binary() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
/// Run the shipped binary and return (exit code, stdout, stderr).
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(),
)
}
// ---------------------------------------------------------------------------
// 1. --help surfaces list the real flag / subcommand names
// ---------------------------------------------------------------------------
#[test]
fn top_level_help_lists_config_and_watch_subcommands() {
let (code, stdout, stderr) = run(&["--help"]);
assert_eq!(
code,
Some(0),
"`keyhog --help` must exit 0; stderr={stderr}"
);
// The top-level menu must name the surfaces this lane covers.
for needle in ["scan", "watch", "config", "daemon"] {
assert!(
stdout.contains(needle),
"top-level --help must list the `{needle}` subcommand; got:\n{stdout}"
);
}
}
#[test]
fn scan_help_lists_config_affecting_flags() {
let (code, stdout, stderr) = run(&["scan", "--help"]);
assert_eq!(
code,
Some(0),
"`keyhog scan --help` must exit 0; stderr={stderr}"
);
// These flags are always compiled (not feature-gated) and each affects the
// resolved scan config. A rename drops the packager/CI contract silently.
for flag in [
"--fast",
"--deep",
"--precision",
"--min-confidence",
"--decode-depth",
"--no-config",
"--config",
"--backend",
"--format",
"--daemon=off",
"--dedup",
] {
assert!(
stdout.contains(flag),
"`keyhog scan --help` must document `{flag}`; got:\n{stdout}"
);
}
}
#[test]
fn watch_help_lists_its_real_flags() {
let (code, stdout, stderr) = run(&["watch", "--help"]);
assert_eq!(
code,
Some(0),
"`keyhog watch --help` must exit 0; stderr={stderr}"
);
// Exactly the WatchArgs surface: paths + detectors + cache-dir + backend + quiet.
for flag in ["--detectors", "--cache-dir", "--backend", "--quiet"] {
assert!(
stdout.contains(flag),
"`keyhog watch --help` must document `{flag}`; got:\n{stdout}"
);
}
// `watch` intentionally does NOT expose the scan-only `--format` flag; its
// output is the live watch stream, not a formatted report.
assert!(
!stdout.contains("--format"),
"`keyhog watch --help` must NOT expose --format (watch has no report format); got:\n{stdout}"
);
}
#[test]
fn config_help_requires_effective_and_reuses_scan_flags() {
let (code, stdout, stderr) = run(&["config", "--help"]);
assert_eq!(
code,
Some(0),
"`keyhog config --help` must exit 0; stderr={stderr}"
);
// `config` flattens ScanArgs, so its own `--effective` gate AND the shared
// config-affecting scan flags must both be documented.
for flag in ["--effective", "--min-confidence", "--no-config"] {
assert!(
stdout.contains(flag),
"`keyhog config --help` must document `{flag}`; got:\n{stdout}"
);
}
}
// ---------------------------------------------------------------------------
// 2. A config-affecting flag reaches the rendered effective config
// ---------------------------------------------------------------------------
#[test]
fn config_effective_min_confidence_override_reaches_output() {
// --no-config makes the resolve hermetic (shipped defaults only), so the
// ONLY thing that can move `min_confidence` is our explicit override.
let (code, stdout, stderr) = run(&[
"config",
"--effective",
"--no-config",
"--daemon=off",
"--min-confidence",
"0.85",
]);
assert_eq!(
code,
Some(0),
"`keyhog config --effective` must exit 0 (renders, never scans); stderr={stderr}"
);
assert!(
stdout.contains("[effective-config]"),
"effective dump must carry its header; got:\n{stdout}"
);
assert!(
stdout.contains("min_confidence = 0.85"),
"the --min-confidence 0.85 override must reach the resolved config; got:\n{stdout}"
);
}
#[test]
fn config_effective_decode_depth_and_threads_reach_output() {
let (code, stdout, stderr) = run(&[
"config",
"--effective",
"--no-config",
"--daemon=off",
"--decode-depth",
"3",
"--threads",
"4",
]);
assert_eq!(
code,
Some(0),
"config --effective must exit 0; stderr={stderr}"
);
// --decode-depth 3 maps to max_decode_depth = 3 (scanner.rs: config.max_decode_depth = depth).
assert!(
stdout.contains("max_decode_depth = 3"),
"the --decode-depth 3 override must reach max_decode_depth; got:\n{stdout}"
);
// --threads 4 maps straight through to the runtime thread count.
assert!(
stdout.contains("threads = 4"),
"the --threads 4 override must reach the resolved threads; got:\n{stdout}"
);
}
#[test]
fn config_effective_entropy_threshold_override_reaches_output() {
let (code, stdout, stderr) = run(&[
"config",
"--effective",
"--no-config",
"--daemon=off",
"--entropy-threshold",
"6.5",
]);
assert_eq!(
code,
Some(0),
"config --effective must exit 0; stderr={stderr}"
);
// --entropy-threshold 6.5 maps to scanner.entropy_threshold (scanner.rs:141).
assert!(
stdout.contains("entropy_threshold = 6.5"),
"the --entropy-threshold 6.5 override must reach the resolved config; got:\n{stdout}"
);
}
// ---------------------------------------------------------------------------
// 3. Missing-required and unknown-flag boundaries → exit 2
// ---------------------------------------------------------------------------
#[test]
fn config_without_effective_exits_two() {
// `--effective` is `required = true`: clap rejects the invocation at parse
// time BEFORE the subcommand body's anyhow bail, so it is the clap
// user-error path (exit 2), not a system error.
let (code, _stdout, stderr) = run(&["config", "--no-config"]);
assert_eq!(
code,
Some(2),
"`keyhog config` without --effective is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("--effective"),
"the parse error must name the missing --effective flag; got:\n{stderr}"
);
}
#[test]
fn unknown_scan_flag_exits_two() {
let (code, _stdout, stderr) = run(&["scan", "--definitely-not-a-flag"]);
assert_eq!(
code,
Some(2),
"an unknown scan flag is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("unexpected argument"),
"clap must report the unexpected argument; got:\n{stderr}"
);
}
#[test]
fn unknown_top_level_flag_exits_two() {
let (code, _stdout, stderr) = run(&["--not-a-real-top-level-flag"]);
assert_eq!(
code,
Some(2),
"an unknown top-level flag is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("unexpected argument"),
"clap must report the unexpected top-level argument; got:\n{stderr}"
);
}
// ---------------------------------------------------------------------------
// 4. Mutually-exclusive flag pairs → clap "cannot be used with" + exit 2
// ---------------------------------------------------------------------------
/// One assertion body for every documented conflict pair on the scan/config
/// surface: run `scan <a> <b>`, demand exit 2 AND clap's exact conflict
/// diagnostic. `extra` supplies any value tokens a flag needs.
fn assert_conflict(args: &[&str]) {
let (code, _stdout, stderr) = run(args);
assert_eq!(
code,
Some(2),
"`keyhog {}` is a mutually-exclusive combination → exit 2; stderr={stderr}",
args.join(" ")
);
assert!(
stderr.contains("cannot be used with"),
"clap must render the exact conflict diagnostic for `{}`; got:\n{stderr}",
args.join(" ")
);
}
#[test]
fn fast_and_deep_are_mutually_exclusive_exit_two() {
// --fast conflicts_with_all includes "deep".
assert_conflict(&["scan", "--fast", "--deep"]);
}
#[test]
fn no_gpu_and_require_gpu_are_mutually_exclusive_exit_two() {
// --no-gpu conflicts_with "require_gpu" (and vice versa).
assert_conflict(&["scan", "--no-gpu", "--require-gpu"]);
}
#[test]
fn no_config_and_config_are_mutually_exclusive_exit_two() {
// --no-config conflicts_with "config"; the config subcommand inherits it.
assert_conflict(&[
"config",
"--effective",
"--no-config",
"--config",
"/tmp/x.toml",
]);
}
#[test]
fn baseline_and_create_baseline_are_mutually_exclusive_exit_two() {
// --baseline conflicts_with_all ["create_baseline", "update_baseline"].
assert_conflict(&[
"scan",
"--baseline",
"a.json",
"--create-baseline",
"b.json",
]);
}
#[test]
fn positional_path_and_path_flag_are_mutually_exclusive_exit_two() {
// The positional PATH arg has conflicts_with = "path".
assert_conflict(&["scan", "somedir", "--path", "otherdir"]);
}