use clap::error::ErrorKind;
use keyhog::args::{CliDedupScope, Command, OutputFormat, ScanArgs};
use std::path::PathBuf;
fn scan_args(extra: &[&str]) -> ScanArgs {
let mut argv: Vec<String> = vec!["keyhog".to_string(), "scan".to_string()];
argv.extend(extra.iter().copied().map(String::from));
let cli = keyhog::args::try_parse_from(argv).expect("scan args must parse");
match cli.command {
Some(Command::Scan(args)) => *args,
other => panic!("expected Command::Scan, got {}", other.is_some()),
}
}
fn scan_parse_error(extra: &[&str]) -> clap::Error {
let mut argv: Vec<String> = vec!["keyhog".to_string(), "scan".to_string()];
argv.extend(extra.iter().copied().map(String::from));
match keyhog::args::try_parse_from(argv) {
Ok(_) => panic!("scan args must be rejected: {extra:?}"),
Err(e) => e,
}
}
#[test]
fn default_detectors_dir_is_literal_detectors() {
let args = scan_args(&[]);
assert_eq!(args.detectors, PathBuf::from("detectors"));
}
#[test]
fn default_output_format_is_text() {
let args = scan_args(&[]);
assert_eq!(args.format, OutputFormat::Text);
}
#[test]
fn default_dedup_scope_is_credential() {
let args = scan_args(&[]);
assert_eq!(args.dedup, CliDedupScope::Credential);
}
#[test]
fn optional_tuning_fields_default_to_none() {
let args = scan_args(&[]);
assert_eq!(args.ml_threshold, None);
assert_eq!(args.min_confidence, None);
assert_eq!(args.threads, None);
assert_eq!(args.max_file_size, None);
assert_eq!(args.min_secret_len, None);
assert_eq!(args.decode_depth, None);
}
#[test]
fn format_flag_parses_json_and_sarif() {
assert_eq!(scan_args(&["--format", "json"]).format, OutputFormat::Json);
assert_eq!(
scan_args(&["--format", "sarif"]).format,
OutputFormat::Sarif
);
assert_eq!(
scan_args(&["--format", "jsonl"]).format,
OutputFormat::Jsonl
);
}
#[test]
fn format_flag_rejects_unknown_variant() {
let err = scan_parse_error(&["--format", "yaml"]);
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn dedup_flag_parses_file_and_none() {
assert_eq!(scan_args(&["--dedup", "file"]).dedup, CliDedupScope::File);
assert_eq!(scan_args(&["--dedup", "none"]).dedup, CliDedupScope::None);
assert_eq!(
scan_args(&["--dedup", "credential"]).dedup,
CliDedupScope::Credential
);
}
#[test]
fn min_confidence_in_range_parses_exact() {
assert_eq!(
scan_args(&["--min-confidence", "0.85"]).min_confidence,
Some(0.85)
);
assert_eq!(
scan_args(&["--min-confidence", "0.0"]).min_confidence,
Some(0.0)
);
assert_eq!(
scan_args(&["--min-confidence", "1.0"]).min_confidence,
Some(1.0)
);
}
#[test]
fn min_confidence_out_of_range_rejected() {
let err = scan_parse_error(&["--min-confidence", "1.5"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(
err.to_string().contains("must be between 0.0 and 1.0"),
"message should name the range, got: {err}"
);
}
#[test]
fn ml_threshold_nan_and_out_of_range_rejected_but_valid_accepted() {
assert_eq!(
scan_args(&["--ml-threshold", "0.5"]).ml_threshold,
Some(0.5)
);
let nan = scan_parse_error(&["--ml-threshold", "NaN"]);
assert_eq!(nan.kind(), ErrorKind::ValueValidation);
assert!(
nan.to_string().contains("finite"),
"NaN rejection should mention finite, got: {nan}"
);
let hi = scan_parse_error(&["--ml-threshold", "2.0"]);
assert_eq!(hi.kind(), ErrorKind::ValueValidation);
}
#[test]
fn decode_depth_zero_rejected_three_accepted() {
assert_eq!(scan_args(&["--decode-depth", "3"]).decode_depth, Some(3));
let err = scan_parse_error(&["--decode-depth", "0"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(
err.to_string().contains("decode depth must be between 1"),
"got: {err}"
);
}
#[test]
fn min_secret_len_zero_rejected_sixteen_accepted() {
assert_eq!(
scan_args(&["--min-secret-len", "16"]).min_secret_len,
Some(16)
);
let err = scan_parse_error(&["--min-secret-len", "0"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(err.to_string().contains(">= 1"), "got: {err}");
}
#[test]
fn threads_zero_rejected_positive_accepted() {
assert_eq!(scan_args(&["--threads", "4"]).threads, Some(4));
let err = scan_parse_error(&["--threads", "0"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(
err.to_string().contains("--threads must be >= 1"),
"got: {err}"
);
}
#[test]
fn unknown_flag_is_rejected_as_unknown_argument() {
let err = scan_parse_error(&["--definitely-not-a-flag", "x"]);
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[cfg(feature = "verify")]
#[test]
fn default_verify_timeout_and_rate_are_none() {
let args = scan_args(&[]);
assert_eq!(args.timeout, None);
assert_eq!(args.verify_concurrency, None);
}
#[cfg(feature = "verify")]
#[test]
fn verify_rate_default_is_five_point_zero() {
let args = scan_args(&[]);
assert!(
(args.verify_rate - 5.0).abs() < f64::EPSILON,
"default --verify-rate must be 5.0, got {}",
args.verify_rate
);
}
#[cfg(feature = "verify")]
#[test]
fn oob_timeout_and_server_defaults() {
let args = scan_args(&[]);
assert_eq!(args.oob_timeout, 30);
assert_eq!(args.oob_server, "oast.fun");
}
#[cfg(feature = "verify")]
#[test]
fn timeout_and_concurrency_flags_parse_exact_values() {
let args = scan_args(&["--verify", "--timeout", "42", "--verify-concurrency", "7"]);
assert_eq!(args.timeout, Some(42));
assert_eq!(args.verify_concurrency, Some(7));
}
#[cfg(feature = "verify")]
#[test]
fn timeout_non_numeric_value_rejected() {
let err = scan_parse_error(&["--verify", "--timeout", "abc"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
}
#[cfg(feature = "verify")]
#[test]
fn zero_verification_concurrency_is_rejected() {
let err = scan_parse_error(&["--verify", "--verify-concurrency", "0"]);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(err.to_string().contains("value must be >= 1"));
}
#[cfg(feature = "verify")]
#[test]
fn ambiguous_legacy_rate_flag_is_not_an_alias() {
let err = scan_parse_error(&["--rate", "7"]);
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[cfg(feature = "verify")]
#[test]
fn verify_rate_bounds_rejected_but_valid_accepted() {
assert!(
(scan_args(&["--verify", "--verify-rate", "50"]).verify_rate - 50.0).abs() < f64::EPSILON,
"in-range --verify-rate 50 must parse to 50.0"
);
let zero = scan_parse_error(&["--verify", "--verify-rate", "0"]);
assert_eq!(zero.kind(), ErrorKind::ValueValidation);
assert!(zero.to_string().contains("> 0 rps"), "got: {zero}");
let over = scan_parse_error(&["--verify", "--verify-rate", "20000"]);
assert_eq!(over.kind(), ErrorKind::ValueValidation);
assert!(over.to_string().contains("10_000 rps"), "got: {over}");
}
#[cfg(feature = "verify")]
#[test]
fn verification_execution_knobs_require_verification() {
for args in [
&["--timeout", "5"][..],
&["--verify-concurrency", "5"][..],
&["--verify-rate", "5"][..],
] {
let err = scan_parse_error(args);
assert_eq!(
err.kind(),
ErrorKind::MissingRequiredArgument,
"args={args:?}"
);
assert!(err.to_string().contains("--verify"), "args={args:?}: {err}");
}
}