use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const PLANTED: &str = "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";
const NO_SECRETS_LINE: &str = "No secrets detected in the scanned files.";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn plant_tokens(n: usize) -> (TempDir, Vec<PathBuf>) {
let dir = TempDir::new().expect("tempdir");
let mut paths = Vec::with_capacity(n);
for i in 0..n {
let p = dir.path().join(format!("leak_{i}.txt"));
std::fs::write(&p, format!("{PLANTED}\n")).expect("write leak file");
paths.push(p);
}
(dir, paths)
}
fn clean_file() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let p = dir.path().join("notes.txt");
std::fs::write(&p, "just ordinary prose with plain everyday words here\n")
.expect("write clean file");
(dir, p)
}
fn run_scan(
format: &str,
dedup: Option<&str>,
extra: &[&str],
paths: &[&Path],
) -> (Option<i32>, String, String) {
let mut cmd = Command::new(binary());
cmd.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--no-suppress-test-fixtures",
]);
if let Some(scope) = dedup {
cmd.args(["--dedup", scope]);
}
cmd.args(extra);
cmd.args(["--format", format]);
for p in paths {
cmd.arg(p);
}
let output = cmd.output().expect("spawn keyhog scan");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn text_summary_count(out: &str) -> Option<usize> {
for line in out.lines() {
if let Some(idx) = line.find(" secret") {
let prefix = &line[..idx];
let digits: String = prefix
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect();
if !digits.is_empty() {
if line.contains("found") {
return digits.parse().ok();
}
}
}
}
None
}
fn json_finding_count(out: &str) -> usize {
let v: serde_json::Value = serde_json::from_str(out).expect("json stdout must parse");
v.as_array()
.expect("json report must be a top-level array")
.len()
}
#[test]
fn text_single_finding_says_one_secret_found() {
let (_dir, paths) = plant_tokens(1);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (code, out, err) = run_scan("text", None, &[], &refs);
let combined = format!("{out}\n{err}");
assert_eq!(code, Some(1), "one finding must exit 1; stderr={err}");
assert!(
combined.contains("1 secret found"),
"singular summary must read '1 secret found', got:\n{combined}"
);
assert!(
!combined.contains("1 secrets found"),
"one finding must NOT pluralize to '1 secrets found', got:\n{combined}"
);
assert_eq!(text_summary_count(&combined), Some(1), "parsed count == 1");
}
#[test]
fn text_two_findings_uses_plural_form() {
let (_dir, paths) = plant_tokens(2);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (code, out, err) = run_scan("text", Some("none"), &[], &refs);
let combined = format!("{out}\n{err}");
assert_eq!(code, Some(1), "two findings must exit 1; stderr={err}");
assert!(
combined.contains("2 secrets found"),
"two findings must pluralize to '2 secrets found', got:\n{combined}"
);
assert_eq!(text_summary_count(&combined), Some(2), "parsed count == 2");
}
#[test]
fn text_large_scan_reports_exact_total() {
let (_dir, paths) = plant_tokens(17);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (code, out, err) = run_scan("text", Some("none"), &[], &refs);
let combined = format!("{out}\n{err}");
assert_eq!(code, Some(1), "17 findings must exit 1; stderr={err}");
assert!(
combined.contains("17 secrets found"),
"large scan must read '17 secrets found', got:\n{combined}"
);
assert_eq!(
text_summary_count(&combined),
Some(17),
"parsed count == 17"
);
}
#[test]
fn text_summary_unverified_tally_equals_count() {
let (_dir, paths) = plant_tokens(5);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (_code, out, err) = run_scan("text", Some("none"), &[], &refs);
let combined = format!("{out}\n{err}");
assert!(
combined.contains("5 secrets found"),
"found tally must be 5, got:\n{combined}"
);
assert!(
combined.contains("5 unverified"),
"with no --verify all 5 findings are unverified, got:\n{combined}"
);
assert!(
!combined.contains(" live") && !combined.contains(" dead"),
"no verification ran -> no live/dead segments, got:\n{combined}"
);
}
#[test]
fn json_array_length_equals_planted_count() {
let (_dir, paths) = plant_tokens(5);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (code, out, err) = run_scan("json", Some("none"), &[], &refs);
assert_eq!(code, Some(1), "5 findings must exit 1; stderr={err}");
assert_eq!(
json_finding_count(&out),
5,
"json array must hold 5 findings"
);
}
#[test]
fn text_and_json_counts_agree() {
let (_dir, paths) = plant_tokens(5);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (_c1, json_out, _e1) = run_scan("json", Some("none"), &[], &refs);
let (_c2, text_out, text_err) = run_scan("text", Some("none"), &[], &refs);
let json_n = json_finding_count(&json_out);
let text_n = text_summary_count(&format!("{text_out}\n{text_err}"))
.expect("text summary must carry a count");
assert_eq!(json_n, 5, "json count anchor");
assert_eq!(text_n, 5, "text count anchor");
assert_eq!(
text_n, json_n,
"text roll-up and json array must report the SAME total"
);
}
#[test]
fn large_total_agrees_across_text_and_json() {
let (_dir, paths) = plant_tokens(17);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (_c1, json_out, _e1) = run_scan("json", Some("none"), &[], &refs);
let (_c2, text_out, text_err) = run_scan("text", Some("none"), &[], &refs);
assert_eq!(json_finding_count(&json_out), 17, "json holds 17");
assert_eq!(
text_summary_count(&format!("{text_out}\n{text_err}")),
Some(17),
"text roll-up reads 17"
);
}
#[test]
fn default_credential_dedup_collapses_identical_values() {
let (_dir, paths) = plant_tokens(5);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (jcode, json_out, _je) = run_scan("json", None, &[], &refs);
assert_eq!(jcode, Some(1), "still one finding -> exit 1");
assert_eq!(
json_finding_count(&json_out),
1,
"identical values collapse to ONE finding under credential dedup"
);
let (_tc, text_out, text_err) = run_scan("text", None, &[], &refs);
let combined = format!("{text_out}\n{text_err}");
assert!(
combined.contains("1 secret found"),
"credential-deduped roll-up must read '1 secret found', got:\n{combined}"
);
}
#[test]
fn directory_scan_reports_exact_total() {
let (dir, _paths) = plant_tokens(5);
let root = dir.path();
let (jcode, json_out, _je) = run_scan("json", Some("none"), &[], &[root]);
assert_eq!(jcode, Some(1), "directory with findings exits 1");
assert_eq!(
json_finding_count(&json_out),
5,
"directory walk must surface all 5 planted findings"
);
let (_tc, text_out, text_err) = run_scan("text", Some("none"), &[], &[root]);
assert!(
format!("{text_out}\n{text_err}").contains("5 secrets found"),
"directory text roll-up must read '5 secrets found'"
);
}
#[test]
fn stream_preview_line_count_equals_finding_count() {
let (_dir, paths) = plant_tokens(5);
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let (code, out, err) = run_scan("json", Some("none"), &["--stream"], &refs);
assert_eq!(code, Some(1), "5 findings -> exit 1");
let json_n = json_finding_count(&out);
let stream_n = err.lines().filter(|l| l.contains("[stream]")).count();
assert_eq!(json_n, 5, "json anchor == 5");
assert_eq!(
stream_n, 5,
"one [stream] line per reported finding, got {stream_n}"
);
assert_eq!(stream_n, json_n, "stream line count must equal json count");
}
#[test]
fn duplicate_root_does_not_double_count() {
let (_dir, paths) = plant_tokens(1);
let p = paths[0].as_path();
let (code, out, err) = run_scan("json", Some("none"), &[], &[p, p]);
assert_eq!(code, Some(1), "one finding across folded roots -> exit 1");
assert_eq!(
json_finding_count(&out),
1,
"a duplicate root must not inflate the count; stderr={err}"
);
}
#[test]
fn clean_scan_zero_findings_and_honest_line() {
let (_dir, path) = clean_file();
let p = path.as_path();
let (tcode, text_out, text_err) = run_scan("text", None, &[], &[p]);
let combined = format!("{text_out}\n{text_err}");
assert_eq!(tcode, Some(0), "clean scan must exit 0");
assert!(
combined.contains(NO_SECRETS_LINE),
"clean text scan must print the honest no-secrets line, got:\n{combined}"
);
assert_eq!(
text_summary_count(&combined),
None,
"clean scan must NOT emit a 'N secret(s) found' roll-up"
);
let (jcode, json_out, _je) = run_scan("json", None, &[], &[p]);
assert_eq!(jcode, Some(0), "clean json scan must exit 0");
assert_eq!(
json_out.trim_end(),
"[]",
"clean json report must be exactly the empty array, got: {json_out:?}"
);
assert_eq!(json_finding_count(&json_out), 0, "clean json count == 0");
}
#[test]
fn exit_code_tracks_presence_of_findings() {
let (_leak_dir, leak_paths) = plant_tokens(3);
let leak_refs: Vec<&Path> = leak_paths.iter().map(|p| p.as_path()).collect();
let (leak_code, leak_out, _le) = run_scan("json", Some("none"), &[], &leak_refs);
assert_eq!(json_finding_count(&leak_out), 3, "3 planted findings");
assert_eq!(leak_code, Some(1), "findings present -> exit 1");
let (_clean_dir, clean_path) = clean_file();
let (clean_code, clean_out, _ce) = run_scan("json", None, &[], &[clean_path.as_path()]);
assert_eq!(json_finding_count(&clean_out), 0, "no findings");
assert_eq!(clean_code, Some(0), "no findings -> exit 0");
}