use derive_more::{Display, Error};
use std::borrow::Cow;
const MAC_PREFIXES: [&str; 7] = ["Mc", "MC", "mc", "M'", "M’", "m'", "m’"];
#[derive(Debug, Display, Error)]
#[non_exhaustive]
pub enum NormalizationError {
#[display("{_0:?} is not alphabetic ASCII after trying to convert")]
NonAsciiAlpha(#[error(ignore)] String),
#[display("empty string after trimming or converting to ASCII")]
EmptyString,
#[display("the bound cutter table contains no entries")]
EmptyTable,
}
use NormalizationError::{EmptyString, NonAsciiAlpha};
fn fold_diacritic(c: char) -> char {
match c {
'á' | 'à' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ą' | 'ă' => 'a',
'Á' | 'À' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'Ā' | 'Ą' | 'Ă' => 'A',
'ç' | 'ć' | 'č' | 'ĉ' | 'ċ' => 'c',
'Ç' | 'Ć' | 'Č' | 'Ĉ' | 'Ċ' => 'C',
'ð' | 'ď' | 'đ' => 'd',
'Ð' | 'Ď' | 'Đ' => 'D',
'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ę' | 'ě' | 'ĕ' | 'ė' => 'e',
'É' | 'È' | 'Ê' | 'Ë' | 'Ē' | 'Ę' | 'Ě' | 'Ĕ' | 'Ė' => 'E',
'ğ' | 'ġ' | 'ģ' => 'g',
'Ğ' | 'Ġ' | 'Ģ' => 'G',
'í' | 'ì' | 'î' | 'ï' | 'ī' | 'į' | 'ı' => 'i',
'Í' | 'Ì' | 'Î' | 'Ï' | 'Ī' | 'Į' | 'İ' => 'I',
'ł' | 'ľ' | 'ĺ' | 'ļ' => 'l',
'Ł' | 'Ľ' | 'Ĺ' | 'Ļ' => 'L',
'ñ' | 'ń' | 'ň' | 'ņ' => 'n',
'Ñ' | 'Ń' | 'Ň' | 'Ņ' => 'N',
'ó' | 'ò' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => 'o',
'Ó' | 'Ò' | 'Ô' | 'Õ' | 'Ö' | 'Ø' | 'Ō' | 'Ő' => 'O',
'ř' | 'ŕ' | 'ŗ' => 'r',
'Ř' | 'Ŕ' | 'Ŗ' => 'R',
'ś' | 'š' | 'ş' | 'ș' => 's',
'Ś' | 'Š' | 'Ş' | 'Ș' => 'S',
'ť' | 'ţ' | 'ț' => 't',
'Ť' | 'Ţ' | 'Ț' => 'T',
'ú' | 'ù' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' | 'ų' => 'u',
'Ú' | 'Ù' | 'Û' | 'Ü' | 'Ū' | 'Ů' | 'Ű' | 'Ų' => 'U',
'ý' | 'ÿ' => 'y',
'Ý' | 'Ÿ' => 'Y',
'ź' | 'ż' | 'ž' => 'z',
'Ź' | 'Ż' | 'Ž' => 'Z',
_ => c,
}
}
fn push_folded(out: &mut String, c: char) {
match c {
'ß' => out.push_str("ss"),
'ẞ' => out.push_str("SS"),
'æ' => out.push_str("ae"),
'Æ' => out.push_str("AE"),
'œ' => out.push_str("oe"),
'Œ' => out.push_str("OE"),
'þ' => out.push_str("th"),
'Þ' => out.push_str("TH"),
_ if c.is_alphabetic() => out.push(fold_diacritic(c)),
_ => {}
}
}
pub fn to_ascii_alphabetic(input: Cow<'_, str>) -> Result<Cow<'_, str>, NormalizationError> {
let trimmed_input = input.trim();
if trimmed_input.is_empty() {
return Err(EmptyString);
}
for (i, c) in trimmed_input.char_indices() {
if c.is_ascii_alphabetic() {
continue;
}
let mut s = String::with_capacity(trimmed_input.len());
s.push_str(trimmed_input.get(..i).unwrap_or_default());
push_folded(&mut s, c);
for tail in trimmed_input.get(i..).unwrap_or_default().chars().skip(1) {
push_folded(&mut s, tail);
}
if s.is_empty() {
return Err(EmptyString);
}
if !s.is_ascii() {
return Err(NonAsciiAlpha(s));
}
return Ok(Cow::Owned(s));
}
if trimmed_input.len() == input.len() {
Ok(input)
} else {
Ok(Cow::Owned(trimmed_input.to_owned()))
}
}
#[must_use]
pub fn capitalize_ascii(input: Cow<'_, str>) -> Cow<'_, str> {
fn recase_in_place(s: &mut str) {
if let Some((first, rest)) = s.split_at_mut_checked(1) {
first.make_ascii_uppercase();
rest.make_ascii_lowercase();
}
}
match input {
Cow::Owned(mut s) => {
recase_in_place(&mut s);
Cow::Owned(s)
}
Cow::Borrowed(s) => {
let needs_alloc = match s.as_bytes().split_first() {
Some((first_byte, rest)) => {
first_byte.is_ascii_lowercase() || rest.iter().any(|&b| b.is_ascii_uppercase())
}
None => false,
};
if !needs_alloc {
return input;
}
let mut new_s = s.to_owned();
recase_in_place(&mut new_s);
Cow::Owned(new_s)
}
}
}
#[must_use]
pub fn normalize_mac(input: Cow<'_, str>) -> Cow<'_, str> {
let candidate = input.trim_start();
if let Some(w) = MAC_PREFIXES.iter().find_map(|p| candidate.strip_prefix(p)) {
return Cow::Owned(format!("Mac{}", capitalize_first_ascii(w)));
}
input
}
fn capitalize_first_ascii(s: &str) -> Cow<'_, str> {
match s.as_bytes().first() {
Some(&b) if b.is_ascii_lowercase() => {
let mut owned = String::with_capacity(s.len());
owned.push(char::from(b.to_ascii_uppercase()));
owned.push_str(s.get(1..).unwrap_or_default());
Cow::Owned(owned)
}
_ => Cow::Borrowed(s),
}
}
#[cfg(test)]
mod tests {
use super::*;
mod to_ascii_alphabetic {
use super::*;
#[test]
fn basic_ascii() {
let input = "Hello";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "Hello");
}
#[test]
fn removes_whitespace_and_symbols() {
let input = " Hello, World! 123 ";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "HelloWorld");
}
#[test]
fn empty_error() {
for input in ["", " ", "12345!@#"] {
let err = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap_err();
assert!(matches!(err, EmptyString), "input: {input:?}");
}
}
#[test]
fn unmapped_unicode_error() {
match to_ascii_alphabetic(Cow::Borrowed("Пушкин")) {
Err(NonAsciiAlpha(s)) => assert_eq!(s, "Пушкин"),
other => panic!("expected NonAsciiAlpha error, got {other:?}"),
}
}
#[test]
fn mixed_valid_and_numbers() {
let input = "User123Name";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "UserName");
}
mod accents {
use super::*;
#[test]
fn western_european_accents() {
let input = "Áéîöù Çça";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "AeiouCca");
}
#[test]
fn eastern_european_accents() {
let input = "Łąka Ńuans Život";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "LakaNuansZivot");
}
#[test]
fn nordic_accents() {
let input = "København Århus";
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, "KobenhavnArhus");
}
#[test]
fn ligatures_and_digraphs() {
let cases = [
("Straße", "Strasse"),
("Ærø", "AEro"),
("Œuvre", "OEuvre"),
("Þórður", "THordur"),
];
for (input, expected) in cases {
let result = to_ascii_alphabetic(Cow::Borrowed(input)).unwrap();
assert_eq!(result, expected, "input: {input}");
}
}
}
}
mod capitalize_ascii {
use super::*;
use std::borrow::Cow;
fn c(s: &str) -> Cow<'_, str> {
Cow::from(s)
}
#[test]
fn single_word_allocates() {
let input = c("rust");
let result = capitalize_ascii(input);
assert_eq!(result, "Rust");
assert!(
matches!(result, Cow::Owned(_)),
"input should trigger allocation"
);
}
#[test]
fn single_word_happy_path() {
let input = c("Rust");
let result = capitalize_ascii(input);
assert_eq!(result, "Rust");
assert!(
matches!(result, Cow::Borrowed(_)),
"input should not allocate"
);
}
#[test]
fn mixed_case_input() {
let input = c("tHiS");
assert_eq!(capitalize_ascii(input), "This");
}
#[test]
fn multi_word_behavior() {
assert_eq!(capitalize_ascii(c("hello world")), "Hello world");
}
#[test]
fn empty() {
let result = capitalize_ascii(c(""));
assert_eq!(result, "");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn multibyte_head_is_returned_unchanged() {
let odd = capitalize_ascii(c("éX"));
assert_eq!(odd, "éX");
let odd_owned = capitalize_ascii(Cow::Owned(String::from("Éx")));
assert_eq!(odd_owned, "Éx");
}
#[test]
fn pipeline_integration() {
let raw = " jOhN ";
let norm = to_ascii_alphabetic(Cow::Borrowed(raw)).unwrap();
let titled = capitalize_ascii(norm);
assert_eq!(titled, "John");
}
}
mod normalize_mac {
use super::*;
#[test]
fn expands_all_prefix_spellings() {
let cases = [
("McDonald", "MacDonald"),
("mcdonald", "MacDonald"),
("MCDONALD", "MacDONALD"),
("M'Clintock", "MacClintock"),
("m’clintock", "MacClintock"),
];
for (input, expected) in cases {
assert_eq!(
normalize_mac(Cow::Borrowed(input)),
expected,
"input: {input}"
);
}
}
#[test]
fn leading_whitespace_does_not_defeat_expansion() {
let expanded = normalize_mac(Cow::Borrowed(" McDonald"));
assert_eq!(expanded, "MacDonald");
}
#[test]
fn full_pipeline_matches_table_casing() {
let piped = capitalize_ascii(
to_ascii_alphabetic(normalize_mac(Cow::Borrowed(" MCDONALD "))).unwrap(),
);
assert_eq!(piped, "Macdonald");
}
#[test]
fn non_mac_names_untouched() {
let untouched = normalize_mac(Cow::Borrowed("Marshall"));
assert_eq!(untouched, "Marshall");
assert!(matches!(untouched, Cow::Borrowed(_)));
assert_eq!(normalize_mac(Cow::Borrowed("Macbeth")), "Macbeth");
}
}
}