const TURKISH: [(char, char); 12] = [
('ı', 'i'),
('İ', 'i'),
('ğ', 'g'),
('Ğ', 'g'),
('ş', 's'),
('Ş', 's'),
('ö', 'o'),
('Ö', 'o'),
('ü', 'u'),
('Ü', 'u'),
('ç', 'c'),
('Ç', 'c'),
];
fn fold(c: char) -> Option<char> {
if let Some((_, to)) = TURKISH.iter().find(|(from, _)| *from == c) {
return Some(*to);
}
Some(match c {
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' | 'ă' | 'ą' => 'a',
'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' | 'Ā' | 'Ă' | 'Ą' => 'a',
'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ė' | 'ę' | 'ě' => 'e',
'É' | 'È' | 'Ê' | 'Ë' | 'Ē' | 'Ė' | 'Ę' | 'Ě' => 'e',
'í' | 'ì' | 'î' | 'ï' | 'ī' | 'į' => 'i',
'Í' | 'Ì' | 'Î' | 'Ï' | 'Ī' | 'Į' => 'i',
'ó' | 'ò' | 'ô' | 'õ' | 'ø' | 'ō' => 'o',
'Ó' | 'Ò' | 'Ô' | 'Õ' | 'Ø' | 'Ō' => 'o',
'ú' | 'ù' | 'û' | 'ū' | 'ů' => 'u',
'Ú' | 'Ù' | 'Û' | 'Ū' | 'Ů' => 'u',
'ñ' | 'ń' | 'ň' => 'n',
'Ñ' | 'Ń' | 'Ň' => 'n',
'ý' | 'ÿ' => 'y',
'Ý' | 'Ÿ' => 'y',
'ć' | 'č' => 'c',
'Ć' | 'Č' => 'c',
'ś' | 'š' => 's',
'Ś' | 'Š' => 's',
'ź' | 'ż' | 'ž' => 'z',
'Ź' | 'Ż' | 'Ž' => 'z',
'ł' => 'l',
'Ł' => 'l',
'đ' | 'ð' => 'd',
'Đ' => 'd',
'ß' => 's',
_ => return None,
})
}
pub fn normalize(raw: &str) -> Option<String> {
let mut out = String::with_capacity(raw.len());
let mut last_was_sep = true;
for ch in raw.trim().chars() {
let ch = fold(ch).unwrap_or(ch);
if ch.is_whitespace() || ch == '_' {
if !last_was_sep {
out.push('_');
last_was_sep = true;
}
continue;
}
for lower in ch.to_lowercase() {
if lower.is_ascii_alphanumeric() {
out.push(lower);
last_was_sep = false;
}
}
}
while out.ends_with('_') {
out.pop();
}
(!out.is_empty()).then_some(out)
}
pub fn display_name(raw: &str) -> Option<String> {
let filtered: String = raw
.chars()
.filter(|c| !c.is_control() && !is_disallowed_format_char(*c))
.collect();
let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
return None;
}
Some(collapsed.chars().take(60).collect())
}
fn is_disallowed_format_char(c: char) -> bool {
matches!(
c,
'\u{200B}'
| '\u{200E}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2069}'
| '\u{FEFF}'
)
}
pub fn resolve_identities(
conn: &rusqlite::Connection,
typed_name: &str,
) -> rusqlite::Result<Vec<String>> {
let normalized = normalize(typed_name).unwrap_or_else(|| typed_name.to_string());
let typed = typed_name.trim().to_lowercase();
let mut identities = vec![normalized.clone()];
if crate::db::table_exists(conn, "people").unwrap_or(false) {
let mut stmt = conn.prepare("SELECT name, full_name FROM people")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
for row in rows {
let (identity, full) = row?;
let matches_display = full.trim().to_lowercase() == typed
|| normalize(&full).as_deref() == Some(normalized.as_str());
if matches_display && !identities.contains(&identity) {
identities.push(identity);
}
}
}
Ok(identities)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_normalize_the_way_the_spec_says() {
let cases = [
(" Erhan ", "erhan", "Erhan"),
("erhan", "erhan", "erhan"),
("ERHAN", "erhan", "ERHAN"),
("Ayşegül", "aysegul", "Ayşegül"),
("Şefik", "sefik", "Şefik"),
("Çağdaş", "cagdas", "Çağdaş"),
("Ömercan", "omercan", "Ömercan"),
("Sertuğ", "sertug", "Sertuğ"),
("Serdar Başaran", "serdar_basaran", "Serdar Başaran"),
("Ahmet Arı", "ahmet_ari", "Ahmet Arı"),
("Anne-Marie", "annemarie", "Anne-Marie"),
];
for (input, name, display) in cases {
assert_eq!(
normalize(input).as_deref(),
Some(name),
"normalize({input:?})"
);
assert_eq!(
display_name(input).as_deref(),
Some(display),
"display_name({input:?})"
);
}
}
#[test]
fn the_dotted_and_dotless_i_are_pinned() {
for (input, want) in [
("İrfan", "irfan"),
("Irmak", "irmak"),
("ICE", "ice"),
("Işıl", "isil"),
("Işıl Özyeğin", "isil_ozyegin"),
("ışık", "isik"),
] {
assert_eq!(
normalize(input).as_deref(),
Some(want),
"normalize({input:?})"
);
}
}
#[test]
fn the_whole_turkish_alphabet_folds_to_ascii() {
assert_eq!(
normalize("öÖüÜıIiİşŞçÇğĞ").as_deref(),
Some("oouuiiiissccgg")
);
for (input, want) in [
("öÖ", "oo"),
("üÜ", "uu"),
("ıI", "ii"),
("iİ", "ii"),
("şŞ", "ss"),
("çÇ", "cc"),
("ğĞ", "gg"),
] {
assert_eq!(normalize(input).as_deref(), Some(want), "pair {input:?}");
}
}
#[test]
fn case_differences_collapse_to_one_identity() {
let forms = ["Erhan", "erhan", "ERHAN", " eRhAn "];
let ids: Vec<_> = forms.iter().filter_map(|f| normalize(f)).collect();
assert_eq!(ids.len(), forms.len());
assert!(
ids.windows(2).all(|w| w[0] == w[1]),
"these must be one person, got {ids:?}"
);
}
#[test]
fn punctuation_is_dropped_and_diacritics_are_folded() {
assert_eq!(normalize("Erhan!!!").as_deref(), Some("erhan"));
assert_eq!(normalize("a#$%^&?~|{}[]=b").as_deref(), Some("ab"));
assert_eq!(normalize("Şşğüöç").as_deref(), Some("ssguoc"));
}
#[test]
fn nothing_usable_is_none_rather_than_empty() {
for input in ["", " ", "!!!", "---", "\u{200B}"] {
assert_eq!(normalize(input), None, "normalize({input:?})");
}
assert_eq!(display_name(" "), None);
}
#[test]
fn separators_never_double_or_dangle() {
assert_eq!(normalize("a b").as_deref(), Some("a_b"));
assert_eq!(normalize(" a b ").as_deref(), Some("a_b"));
assert_eq!(normalize("a - b").as_deref(), Some("a_b"));
assert_eq!(normalize("Erhan ").as_deref(), Some("erhan"));
}
#[test]
fn normalizing_twice_changes_nothing() {
for input in ["Işıl Özyeğin", "Serdar Başaran", "Anne-Marie", "ERHAN"] {
let once = normalize(input).unwrap();
let twice = normalize(&once).unwrap();
assert_eq!(once, twice, "normalize is not idempotent for {input:?}");
}
}
#[test]
fn display_name_preserves_what_was_typed() {
assert_eq!(
display_name("Işıl Özyeğin").as_deref(),
Some("Işıl Özyeğin")
);
assert_eq!(display_name("Anne-Marie").as_deref(), Some("Anne-Marie"));
assert_eq!(display_name(&"x".repeat(70)).unwrap().chars().count(), 60);
}
fn people_db() -> rusqlite::Connection {
let c = rusqlite::Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
INSERT INTO people VALUES ('erhan_gundogan','Erhan Gündoğan'),
('ozgur_demirtas','Özgür'),
('ozgur_tamer','Özgür');",
)
.unwrap();
c
}
#[test]
fn resolve_matches_the_identity_and_the_display_name() {
let c = people_db();
assert_eq!(resolve_identities(&c, "erhan_gundogan").unwrap().len(), 1);
assert!(resolve_identities(&c, "Erhan Gündoğan")
.unwrap()
.contains(&"erhan_gundogan".to_string()));
}
#[test]
fn resolve_is_case_insensitive_for_non_ascii() {
let c = people_db();
for typed in ["Özgür", "özgür", "ÖZGÜR"] {
let ids = resolve_identities(&c, typed).unwrap();
assert!(ids.contains(&"ozgur_demirtas".to_string()), "{typed}");
assert!(ids.contains(&"ozgur_tamer".to_string()), "{typed}");
}
}
#[test]
fn resolve_returns_both_people_sharing_a_display_name() {
let c = people_db();
let ids = resolve_identities(&c, "Özgür").unwrap();
assert!(ids.contains(&"ozgur_demirtas".to_string()));
assert!(ids.contains(&"ozgur_tamer".to_string()));
assert!(ids.contains(&"ozgur".to_string()), "the normalized query");
}
#[test]
fn resolve_without_a_people_table_falls_back_to_the_normalized_name() {
let c = rusqlite::Connection::open_in_memory().unwrap();
assert_eq!(resolve_identities(&c, "Erhan").unwrap(), vec!["erhan"]);
}
#[test]
fn resolve_never_returns_duplicates() {
let c = people_db();
let ids = resolve_identities(&c, "Erhan Gündoğan").unwrap();
let mut sorted = ids.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "{ids:?}");
}
#[test]
fn display_name_strips_bidi_and_control_characters() {
assert_eq!(display_name("A\u{202E}lice").as_deref(), Some("Alice"));
assert_eq!(display_name("A\u{0007}li\tce").as_deref(), Some("Alice"));
assert_eq!(display_name("\u{200B}").as_deref(), None);
}
#[test]
fn display_name_keeps_joiners_that_real_scripts_need() {
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
assert_eq!(display_name(family).as_deref(), Some(family));
assert_eq!(display_name("\u{200C}a").as_deref(), Some("\u{200C}a"));
}
}