use std::path::Path;
use std::process::Command;
const ESC: u8 = 0x1b;
struct Out {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn run(dir: &Path, no_color: Option<bool>, args: &[&str]) -> Out {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_keyhog"));
cmd.current_dir(dir);
match no_color {
Some(true) => {
cmd.env("NO_COLOR", "1");
}
Some(false) => {
cmd.env_remove("NO_COLOR");
}
None => {}
}
let output = cmd
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn keyhog {args:?}: {e}"));
Out {
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
}
}
fn text(v: &[u8]) -> String {
String::from_utf8_lossy(v).into_owned()
}
fn has_esc(v: &[u8]) -> bool {
v.contains(&ESC)
}
#[test]
fn detectors_listing_is_plain_and_banner_matches_embedded_count() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["detectors"]);
assert_eq!(
out.code,
Some(0),
"detectors must exit 0; stderr={}",
text(&out.stderr)
);
assert!(
!has_esc(&out.stdout),
"captured detectors stdout must contain no ANSI escape byte; got:\n{}",
text(&out.stdout)
);
let stdout = text(&out.stdout);
let first = stdout.lines().next().unwrap_or_default();
let expected = format!(
"Loaded {} detectors (embedded):",
keyhog_core::embedded_detector_count()
);
assert_eq!(first, expected, "banner drift");
}
#[test]
fn no_color_does_not_alter_captured_detectors_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let with_nc = run(dir.path(), Some(true), &["detectors"]);
let without_nc = run(dir.path(), Some(false), &["detectors"]);
assert_eq!(with_nc.code, Some(0));
assert_eq!(without_nc.code, Some(0));
assert!(
!has_esc(&with_nc.stdout),
"NO_COLOR=1 output had an escape byte"
);
assert!(
!has_esc(&without_nc.stdout),
"plain output had an escape byte"
);
assert_eq!(
with_nc.stdout, without_nc.stdout,
"NO_COLOR=1 changed captured stdout bytes"
);
}
#[test]
fn version_banner_is_plain_and_exit_zero() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["--version"]);
assert_eq!(
out.code,
Some(0),
"--version must exit 0; stderr={}",
text(&out.stderr)
);
assert!(!has_esc(&out.stdout), "--version stdout had an escape byte");
let stdout = text(&out.stdout);
let first = stdout.lines().next().unwrap_or_default();
assert_eq!(first, format!("KeyHog v{}", env!("CARGO_PKG_VERSION")));
}
#[test]
fn version_bytes_identical_across_no_color_toggle() {
let dir = tempfile::tempdir().expect("tempdir");
let a = run(dir.path(), Some(true), &["-V"]);
let b = run(dir.path(), Some(false), &["-V"]);
assert_eq!(a.code, Some(0));
assert_eq!(b.code, Some(0));
assert_eq!(a.stdout, b.stdout, "NO_COLOR changed -V output bytes");
assert!(!has_esc(&a.stdout));
}
#[test]
fn top_level_help_is_plain_with_usage() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["--help"]);
assert_eq!(out.code, Some(0), "--help must exit 0");
assert!(!has_esc(&out.stdout), "--help stdout had an escape byte");
let stdout = text(&out.stdout);
assert!(
stdout.contains("Usage:"),
"help missing Usage: block:\n{stdout}"
);
}
#[test]
fn detectors_verbose_emits_field_labels() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["detectors", "--verbose"]);
assert_eq!(out.code, Some(0), "stderr={}", text(&out.stderr));
assert!(!has_esc(&out.stdout), "verbose stdout had an escape byte");
let stdout = text(&out.stdout);
assert!(
stdout.contains(" name: "),
"missing name label:\n{stdout}"
);
assert!(stdout.contains(" service: "), "missing service label");
assert!(stdout.contains(" severity: "), "missing severity label");
}
#[test]
fn detectors_default_omits_verbose_field_labels() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["detectors"]);
assert_eq!(out.code, Some(0));
let stdout = text(&out.stdout);
assert!(
!stdout.contains(" name: "),
"default grouped view leaked the verbose name label:\n{stdout}"
);
assert!(
stdout.contains(" - "),
"default view should list detector ids as ` - <id>` rows:\n{stdout}"
);
}
#[test]
fn detectors_verbose_block_matches_json_truth() {
let dir = tempfile::tempdir().expect("tempdir");
let json_out = run(dir.path(), Some(false), &["detectors", "--format", "json"]);
assert_eq!(json_out.code, Some(0), "json listing must exit 0");
let json_text = text(&json_out.stdout);
let arr: Vec<serde_json::Value> =
serde_json::from_str(&json_text).expect("detectors --format json must be a JSON array");
assert!(!arr.is_empty(), "embedded corpus must be non-empty");
let first = &arr[0];
let id = first["id"].as_str().expect("id string").to_string();
let name = first["name"].as_str().expect("name string").to_string();
let verbose = run(
dir.path(),
Some(false),
&["detectors", "--search", &id, "--verbose"],
);
assert_eq!(verbose.code, Some(0));
let vtext = text(&verbose.stdout);
assert!(
vtext.contains(&id),
"verbose output missing searched id `{id}`:\n{vtext}"
);
assert!(
vtext.contains(&format!(" name: {name}")),
"verbose block for `{id}` must print `name: {name}`:\n{vtext}"
);
}
#[test]
fn detectors_search_no_match_prints_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(
dir.path(),
Some(false),
&["detectors", "--search", "zzq-no-such-detector-zzq"],
);
assert_eq!(out.code, Some(0), "stderr={}", text(&out.stderr));
assert_eq!(text(&out.stdout), "", "no-match search must print nothing");
}
#[test]
fn detectors_json_no_match_is_empty_array() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(
dir.path(),
Some(false),
&[
"detectors",
"--format",
"json",
"--search",
"zzq-no-such-detector-zzq",
],
);
assert_eq!(out.code, Some(0));
assert!(!has_esc(&out.stdout), "json stdout had an escape byte");
assert_eq!(
text(&out.stdout),
"[]\n",
"empty JSON listing must be exactly `[]`"
);
}
#[test]
fn detectors_json_length_and_schema_match_corpus() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["detectors", "--format", "json"]);
assert_eq!(out.code, Some(0));
assert!(!has_esc(&out.stdout));
let arr: Vec<serde_json::Value> = serde_json::from_str(&text(&out.stdout)).expect("json array");
assert_eq!(
arr.len(),
keyhog_core::embedded_detector_count(),
"JSON listing length must equal embedded corpus size"
);
let obj = arr[0].as_object().expect("first element is an object");
for key in [
"id",
"name",
"service",
"severity",
"keywords",
"patterns",
"companions",
"verify",
] {
assert!(obj.contains_key(key), "detector JSON missing key `{key}`");
}
assert!(obj["verify"].is_boolean(), "`verify` must be a JSON bool");
assert!(
obj["keywords"].is_array(),
"`keywords` must be a JSON array"
);
}
#[test]
fn detectors_help_documents_verbose_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["detectors", "--help"]);
assert_eq!(out.code, Some(0));
let stdout = text(&out.stdout);
assert!(
stdout.contains("--verbose"),
"detectors --help missing --verbose:\n{stdout}"
);
assert!(
stdout.contains("Print the matching-policy summary"),
"detectors --help missing --verbose doc:\n{stdout}"
);
}
#[test]
fn calibrate_autoroute_help_documents_quiet_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["calibrate-autoroute", "--help"]);
assert_eq!(out.code, Some(0));
let stdout = text(&out.stdout);
assert!(
stdout.contains("--quiet"),
"calibrate-autoroute --help missing --quiet:\n{stdout}"
);
assert!(
stdout.contains("Suppress the per-probe progress lines"),
"calibrate-autoroute --help missing --quiet doc:\n{stdout}"
);
}
#[test]
fn scan_help_documents_quiet_and_no_color_but_not_verbose() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), Some(false), &["scan", "--help"]);
assert_eq!(out.code, Some(0));
let stdout = text(&out.stdout);
assert!(
!stdout.contains("--verbose"),
"scan --help unexpectedly lists --verbose:\n{stdout}"
);
assert!(
stdout.contains("--quiet"),
"scan --help must document --quiet:\n{stdout}"
);
assert!(
stdout.contains("--no-color"),
"scan --help must document --no-color:\n{stdout}"
);
}
#[test]
fn unknown_subcommand_exits_two_with_plain_stderr() {
let dir = tempfile::tempdir().expect("tempdir");
let a = run(dir.path(), Some(true), &["definitely-not-a-subcommand"]);
let b = run(dir.path(), Some(false), &["definitely-not-a-subcommand"]);
assert_eq!(a.code, Some(2), "clap usage error must exit 2");
assert_eq!(
b.code,
Some(2),
"clap usage error must exit 2 regardless of NO_COLOR"
);
assert!(
!has_esc(&a.stderr),
"usage error stderr had an escape byte under NO_COLOR=1"
);
assert!(!has_esc(&b.stderr), "usage error stderr had an escape byte");
}