use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use crate::ErrorRepr;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyCollision {
pub key: String,
pub values: Vec<String>,
pub indices: Vec<usize>,
}
fn reduce<'a>(value: &'a str, key: &str, lang: Option<&str>) -> Result<Cow<'a, str>, ErrorRepr> {
match key {
"fold_case" => Ok(crate::case_fold::fold_case_cow(value)),
"search_key" => crate::presets::search_key(value, lang),
"catalog_key" => crate::presets::catalog_key(value, lang, false),
"canonicalize" => crate::presets::canonicalize(value),
"canonicalize_strict" => crate::presets::canonicalize_strict(value),
"normalize_confusables" => Ok(crate::confusables::normalize_confusables_fixed_cow(
value, "latin", "numeric",
)?),
_ => Err(ErrorRepr::InvalidKeyForm {
got: key.to_owned(),
}),
}
}
pub(crate) fn find_key_collisions(
values: &[&str],
key: &str,
lang: Option<&str>,
) -> Result<Vec<KeyCollision>, ErrorRepr> {
if values.len() > crate::MAX_BATCH_SIZE {
return Err(ErrorRepr::BatchTooLarge {
len: values.len(),
max: crate::MAX_BATCH_SIZE,
});
}
let mut groups: Vec<KeyCollision> = Vec::new();
let mut slot_of: HashMap<String, usize> = HashMap::new();
let mut seen_values: HashSet<&str> = HashSet::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let reduced = reduce(value, key, lang)?;
let first_sighting = seen_values.insert(value);
if let Some(&slot) = slot_of.get(reduced.as_ref()) {
let group = &mut groups[slot];
if first_sighting {
group.values.push((*value).to_owned());
}
group.indices.push(index);
} else {
let reduced = reduced.into_owned();
slot_of.insert(reduced.clone(), groups.len());
groups.push(KeyCollision {
key: reduced,
values: vec![(*value).to_owned()],
indices: vec![index],
});
}
}
groups.retain(|group| group.values.len() > 1);
Ok(groups)
}
#[cfg(test)]
mod tests {
use super::*;
const FOLD: &str = "fold_case";
fn collide(values: &[&str], key: &str) -> Vec<KeyCollision> {
find_key_collisions(values, key, None).expect("valid key form")
}
#[test]
fn the_node_tar_pair_is_reported() {
let found = collide(&["groß.txt", "gross.txt", "other.txt"], FOLD);
assert_eq!(found.len(), 1);
assert_eq!(found[0].key, "gross.txt");
assert_eq!(found[0].values, ["groß.txt", "gross.txt"]);
assert_eq!(found[0].indices, [0, 1]);
}
#[test]
fn a_clean_set_reports_nothing() {
assert!(collide(&["a.txt", "b.txt", "c.txt"], FOLD).is_empty());
assert!(collide(&[], FOLD).is_empty());
}
#[test]
fn one_name_twice_is_not_a_collision() {
assert!(collide(&["a.txt", "a.txt"], FOLD).is_empty());
}
#[test]
fn a_repeat_inside_a_real_collision_keeps_its_index() {
let found = collide(&["groß.txt", "gross.txt", "gross.txt"], FOLD);
assert_eq!(found.len(), 1);
assert_eq!(found[0].values, ["groß.txt", "gross.txt"]);
assert_eq!(found[0].indices, [0, 1, 2]);
}
#[test]
fn three_spellings_land_in_one_group() {
let found = collide(&["groß.txt", "gross.txt", "GROSS.TXT"], FOLD);
assert_eq!(found.len(), 1);
assert_eq!(found[0].values, ["groß.txt", "gross.txt", "GROSS.TXT"]);
}
#[test]
fn groups_come_back_in_first_appearance_order() {
let found = collide(&["zetaß", "alphaß", "zetass", "alphass"], FOLD);
assert_eq!(found.len(), 2);
assert_eq!(found[0].key, "zetass");
assert_eq!(found[1].key, "alphass");
}
#[test]
fn indices_are_ascending() {
let found = collide(&["groß", "x", "gross", "y", "GROSS"], FOLD);
assert_eq!(found[0].indices, [0, 2, 4]);
}
#[test]
fn the_reducer_decides_what_collides() {
let names = &["groß", "gross", "admin", "аdmin"];
let folded = collide(names, FOLD);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].values, ["groß", "gross"]);
let canonical = collide(names, "canonicalize");
assert_eq!(canonical.len(), 1);
assert_eq!(canonical[0].values, ["admin", "аdmin"]);
let searched = collide(names, "search_key");
assert_eq!(searched.len(), 2);
}
#[test]
fn every_key_form_token_resolves() {
for key in [
"fold_case",
"search_key",
"catalog_key",
"canonicalize",
"canonicalize_strict",
"normalize_confusables",
] {
find_key_collisions(&["a"], key, None)
.unwrap_or_else(|e| panic!("{key} rejected: {e}"));
}
}
#[test]
fn an_unknown_key_form_is_an_error_not_a_silent_default() {
let err = find_key_collisions(&["a"], "lower", None).unwrap_err();
assert!(matches!(err, ErrorRepr::InvalidKeyForm { .. }), "{err:?}");
}
#[test]
fn a_batch_over_the_cap_is_refused() {
let big = vec!["a"; crate::MAX_BATCH_SIZE + 1];
let err = find_key_collisions(&big, FOLD, None).unwrap_err();
assert!(matches!(err, ErrorRepr::BatchTooLarge { .. }), "{err:?}");
}
#[test]
fn reporting_agrees_with_collapsing() {
let names = &["groß.txt", "gross.txt", "GROSS.TXT", "other.txt", "andere"];
let found = collide(names, FOLD);
for group in &found {
for value in &group.values {
assert_eq!(reduce(value, FOLD, None).unwrap(), group.key);
}
}
let grouped: Vec<&str> = found
.iter()
.flat_map(|g| g.values.iter().map(String::as_str))
.collect();
for name in names {
if grouped.contains(name) {
continue;
}
let key = reduce(name, FOLD, None).unwrap();
assert!(
!found.iter().any(|g| g.key == key),
"{name:?} shares a key with a reported group but was left out"
);
}
}
#[test]
fn lang_reaches_the_reducers_that_take_one() {
let names = &["Müller", "Mueller"];
let de = find_key_collisions(names, "search_key", Some("de")).unwrap();
assert_eq!(de.len(), 1);
assert!(find_key_collisions(names, "search_key", None)
.unwrap()
.is_empty());
}
#[test]
fn one_key_for_the_whole_batch_stays_linear() {
const WORD: &str = "reservationkeys";
const COUNT: u32 = 20_000;
assert!(1u32 << WORD.len() >= COUNT, "WORD too short for COUNT");
let owned: Vec<String> = (0..COUNT)
.map(|mask| {
WORD.chars()
.enumerate()
.map(|(i, c)| {
if mask >> i & 1 == 1 {
c.to_ascii_uppercase()
} else {
c
}
})
.collect()
})
.collect();
let values: Vec<&str> = owned.iter().map(String::as_str).collect();
let found = find_key_collisions(&values, FOLD, None).unwrap();
assert_eq!(found.len(), 1, "one key, so one group");
assert_eq!(found[0].key, WORD);
assert_eq!(
found[0].values.len(),
COUNT as usize,
"every spelling is distinct"
);
assert_eq!(found[0].indices.len(), COUNT as usize);
}
}