use std::borrow::Cow;
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
#[must_use]
pub fn normalize_text(value: &str) -> String {
if value.is_ascii() {
return normalize_ascii(value);
}
let stripped: String = value.nfkd().filter(|c| !is_combining_mark(*c)).collect();
collapse_whitespace(&stripped.to_lowercase())
}
#[must_use]
pub fn normalize_text_cow(value: &str) -> Cow<'_, str> {
if value.is_ascii() && is_ascii_normalized(value) {
return Cow::Borrowed(value);
}
Cow::Owned(normalize_text(value))
}
fn is_ascii_normalized(s: &str) -> bool {
let mut prev_space = true; for &b in s.as_bytes() {
if b.is_ascii_uppercase() {
return false;
}
if is_ascii_ws(b) {
if b != b' ' || prev_space {
return false; }
prev_space = true;
} else {
prev_space = false;
}
}
!prev_space }
fn is_ascii_ws(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
}
fn normalize_ascii(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut pending_space = false;
for &b in value.as_bytes() {
if is_ascii_ws(b) {
pending_space = !out.is_empty();
continue;
}
if pending_space {
out.push(' ');
pending_space = false;
}
out.push(b.to_ascii_lowercase() as char);
}
out
}
fn collapse_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for word in s.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_lowercases_and_collapses() {
assert_eq!(normalize_text("Hello World"), "hello world");
assert_eq!(normalize_text(" Foo Bar "), "foo bar");
assert_eq!(normalize_text("UPPER"), "upper");
assert_eq!(normalize_text(""), "");
assert_eq!(normalize_text(" "), "");
}
#[test]
fn strips_accents_on_latin_text() {
assert_eq!(normalize_text("Café"), "cafe");
assert_eq!(normalize_text("JOSÉ"), "jose");
assert_eq!(normalize_text("naïve"), "naive");
assert_eq!(normalize_text("Crème Brûlée"), "creme brulee");
}
#[test]
fn idempotent() {
let once = normalize_text("Héllo WÖRLD");
assert_eq!(normalize_text(&once), once);
}
#[test]
fn cow_always_equals_owned_and_borrows_when_already_normalized() {
use std::borrow::Cow;
let cases = [
"user42@test.com",
"hello world",
"abc",
"already_normalized-slug.v2",
"Hello",
" lead",
"trail ",
"a b",
"a\tb",
"Café",
"MiXeD Case",
"",
"x",
"a b c",
"UPPER",
"with\nnewline",
];
for s in cases {
assert_eq!(
normalize_text_cow(s).as_ref(),
normalize_text(s),
"value={s:?}"
);
}
assert!(matches!(
normalize_text_cow("user42@test.com"),
Cow::Borrowed(_)
));
assert!(matches!(
normalize_text_cow("hello world"),
Cow::Borrowed(_)
));
assert!(matches!(normalize_text_cow("Hello"), Cow::Owned(_)));
assert!(matches!(normalize_text_cow("a b"), Cow::Owned(_)));
assert!(matches!(normalize_text_cow(" x"), Cow::Owned(_)));
assert!(matches!(normalize_text_cow("x "), Cow::Owned(_)));
assert!(matches!(normalize_text_cow("a\tb"), Cow::Owned(_)));
assert!(matches!(normalize_text_cow("Café"), Cow::Owned(_)));
}
#[test]
fn ascii_single_pass_matches_python_split_semantics() {
assert_eq!(normalize_text("a\u{0b}b\u{0c}c"), "a b c");
assert_eq!(normalize_text("\tTAB\tand spaces\n"), "tab and spaces");
assert_eq!(normalize_text("Multi Word Test"), "multi word test");
}
}