use keyhog_core::{dedup_matches, DedupScope, MatchLocation, RawMatch, Severity};
use std::collections::HashMap;
use std::sync::Arc;
fn make_match(cred: &str, path: &str, line: usize, offset: usize) -> RawMatch {
RawMatch {
detector_id: Arc::from("det1"),
detector_name: Arc::from("det1"),
service: Arc::from("test"),
severity: Severity::High,
credential: keyhog_core::SensitiveString::from(cred),
credential_hash: [0; 32].into(),
companions: HashMap::new(),
location: MatchLocation {
source: Arc::from("fs"),
file_path: Some(Arc::from(path)),
line: Some(line),
offset,
commit: None,
author: None,
date: None,
},
entropy: None,
confidence: Some(0.9),
}
}
#[test]
fn dedup_selects_lowest_offset_as_primary_in_credential_scope() {
let m100 = make_match("SECRET", "file.env", 3, 100);
let m50 = make_match("SECRET", "file.env", 1, 50);
let m75 = make_match("SECRET", "file.env", 2, 75);
let deduped = dedup_matches(vec![m100, m50, m75], &DedupScope::Credential);
assert_eq!(deduped.len(), 1, "same credential → one group");
assert_eq!(
deduped[0].primary_location.offset, 50,
"lowest offset must be primary"
);
assert_eq!(
deduped[0].additional_locations.len(),
2,
"two higher offsets must be additional"
);
let additional_offsets: Vec<usize> = deduped[0]
.additional_locations
.iter()
.map(|loc| loc.offset)
.collect();
assert_eq!(additional_offsets, vec![75, 100]);
}
#[test]
fn dedup_primary_offset_stable_across_input_order() {
let m100 = make_match("SECRET", "file.env", 3, 100);
let m50 = make_match("SECRET", "file.env", 1, 50);
let m75 = make_match("SECRET", "file.env", 2, 75);
let forward = dedup_matches(
vec![m100.clone(), m50.clone(), m75.clone()],
&DedupScope::Credential,
);
let backward = dedup_matches(vec![m75, m50, m100], &DedupScope::Credential);
assert_eq!(forward[0].primary_location.offset, 50);
assert_eq!(backward[0].primary_location.offset, 50);
}
#[test]
fn dedup_file_scope_selects_primary_per_file() {
let m1_offset10 = make_match("SECRET", "file1.env", 1, 10);
let m1_offset20 = make_match("SECRET", "file1.env", 2, 20);
let m2_offset50 = make_match("SECRET", "file2.env", 1, 50);
let deduped = dedup_matches(
vec![m1_offset10, m1_offset20, m2_offset50],
&DedupScope::File,
);
assert_eq!(deduped.len(), 2, "File scope: 2 files → 2 groups");
let group1 = deduped
.iter()
.find(|m| m.primary_location.file_path.as_deref() == Some("file1.env"))
.unwrap();
let group2 = deduped
.iter()
.find(|m| m.primary_location.file_path.as_deref() == Some("file2.env"))
.unwrap();
assert_eq!(
group1.primary_location.offset, 10,
"file1: primary at lower offset"
);
assert_eq!(group1.additional_locations.len(), 1);
assert_eq!(group2.primary_location.offset, 50, "file2: only one match");
}
#[test]
fn dedup_offset_selection_with_same_line_different_offsets() {
let m_offset_90 = make_match("TOKEN", "config.yaml", 5, 90);
let m_offset_100 = make_match("TOKEN", "config.yaml", 5, 100);
let deduped = dedup_matches(vec![m_offset_100, m_offset_90], &DedupScope::Credential);
assert_eq!(deduped[0].primary_location.offset, 90);
assert!(deduped[0].additional_locations.is_empty());
}