use crate::charset_table::EPC_CONVERSION_TABLE;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Transliteration {
#[default]
German,
Epc,
}
#[inline]
#[must_use]
pub const fn is_sepa_char(c: char) -> bool {
matches!(c,
'a'..='z' | 'A'..='Z' | '0'..='9'
| '/' | '-' | '?' | ':' | '(' | ')' | '.' | ',' | '\'' | '+' | ' '
)
}
#[inline]
#[must_use]
pub fn is_sepa_text(s: &str) -> bool {
s.chars().all(is_sepa_char)
}
#[inline]
#[must_use]
pub fn first_invalid_char(s: &str) -> Option<char> {
s.chars().find(|c| !is_sepa_char(*c))
}
#[must_use]
pub fn transliterate(s: &str, style: Transliteration) -> std::borrow::Cow<'_, str> {
if is_sepa_text(s) {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if is_sepa_char(c) {
out.push(c);
} else {
out.push_str(map_char(c, style));
}
}
std::borrow::Cow::Owned(out)
}
fn map_char(c: char, style: Transliteration) -> &'static str {
if style == Transliteration::German {
match c {
'ä' => return "ae",
'ö' => return "oe",
'ü' => return "ue",
'Ä' => return "Ae",
'Ö' => return "Oe",
'Ü' => return "Ue",
'ß' | 'ẞ' => return "ss",
_ => {}
}
}
match c {
'&' => return "+",
'_' | '~' => return "-",
'"' => return "'",
'<' => return "(",
'>' => return ")",
_ => {}
}
if let Ok(i) = EPC_CONVERSION_TABLE.binary_search_by_key(&(c as u32), |(cp, _)| *cp) {
if let Some((_, replacement)) = EPC_CONVERSION_TABLE.get(i) {
return replacement;
}
}
if c.is_whitespace() { " " } else { "." }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permitted_set_is_exactly_73_characters() {
let count = (0u32..=0x10_FFFF)
.filter_map(char::from_u32)
.filter(|c| is_sepa_char(*c))
.count();
assert_eq!(count, 73, "SEPA Basic Latin set must hold exactly 73 chars");
}
#[test]
fn punctuation_membership() {
for c in "/-?:().,'+ ".chars() {
assert!(is_sepa_char(c), "{c:?} must be permitted");
}
for c in "&@#$%*!\"<>[]{}=;_~\\|".chars() {
assert!(!is_sepa_char(c), "{c:?} must be rejected");
}
}
#[test]
fn valid_text_is_borrowed_not_copied() {
let s = "Rechnung 2026-07 (Teil 1)";
assert!(is_sepa_text(s));
assert!(matches!(
transliterate(s, Transliteration::German),
std::borrow::Cow::Borrowed(_)
));
}
#[test]
fn german_style_preserves_pronunciation() {
assert_eq!(
transliterate("Müller & Söhne GmbH", Transliteration::German),
"Mueller + Soehne GmbH"
);
assert_eq!(
transliterate("Ärzte Öl Übung Straße", Transliteration::German),
"Aerzte Oel Uebung Strasse"
);
}
#[test]
fn epc_maps_ligatures_one_to_one() {
for (input, expected) in [
('Æ', "A"),
('æ', "a"),
('Œ', "O"),
('œ', "o"),
('IJ', "I"),
('ij', "i"),
] {
assert_eq!(
transliterate(&input.to_string(), Transliteration::Epc),
expected,
"EPC217-08 maps {input:?} one-to-one"
);
}
}
#[test]
fn greek_and_cyrillic_use_the_published_romanisation() {
for (input, expected) in [
('Θ', "TH"),
('Χ', "CH"),
('Ψ', "PS"),
('θ', "th"),
('Ж', "ZH"),
('Ц', "TS"),
('Щ', "SHT"),
('Я', "YA"),
('ж', "zh"),
('я', "ya"),
] {
for style in [Transliteration::German, Transliteration::Epc] {
assert_eq!(
transliterate(&input.to_string(), style),
expected,
"{input:?} must romanise per EPC217-08"
);
}
}
assert_eq!(transliterate("Ψυχή", Transliteration::Epc), "PSychi");
}
#[test]
fn table_is_sorted_for_binary_search() {
let table = crate::charset_table::EPC_CONVERSION_TABLE;
assert!(
table.windows(2).all(|w| w[0].0 < w[1].0),
"EPC table must be strictly sorted by code point"
);
assert!(table.len() > 1000, "table looks truncated");
}
#[test]
fn epc_style_is_one_to_one() {
assert_eq!(
transliterate("Müller & Söhne GmbH", Transliteration::Epc),
"Muller + Sohne GmbH"
);
let input = "Ärzte Öl Übung Straße";
let out = transliterate(input, Transliteration::Epc);
assert_eq!(out, "Arzte Ol Ubung Strase");
assert_eq!(out.chars().count(), input.chars().count());
}
#[test]
fn non_umlaut_accents_fold_identically_in_both_styles() {
for style in [Transliteration::German, Transliteration::Epc] {
assert_eq!(transliterate("Café", style), "Cafe");
assert_eq!(transliterate("Señor Niño", style), "Senor Nino");
assert_eq!(transliterate("Łódź", style), "Lodz");
assert_eq!(transliterate("Français", style), "Francais");
}
}
#[test]
fn styles_differ_only_on_umlauts_and_eszett() {
assert_eq!(
transliterate("Ångström", Transliteration::German),
"Angstroem"
);
assert_eq!(transliterate("Ångström", Transliteration::Epc), "Angstrom");
}
#[test]
fn symbols_map_to_legal_equivalents() {
assert_eq!(
transliterate("A & B @ 100€", Transliteration::Epc),
"A + B . 100E"
);
assert_eq!(transliterate("a_b~c", Transliteration::Epc), "a-b-c");
assert_eq!(
transliterate("\"quoted\"", Transliteration::Epc),
"'quoted'"
);
assert_eq!(transliterate("x[1]{2}", Transliteration::Epc), "x(1)(2)");
}
#[test]
fn unmapped_characters_become_a_dot() {
assert_eq!(transliterate("日本語", Transliteration::Epc), "...");
assert_eq!(transliterate("emoji 🎉", Transliteration::Epc), "emoji .");
}
#[test]
fn control_and_exotic_whitespace_becomes_a_space() {
assert_eq!(transliterate("a\tb\nc", Transliteration::Epc), "a b c");
assert_eq!(transliterate("a\u{00A0}b", Transliteration::Epc), "a b");
}
#[test]
fn transliteration_output_is_always_valid() {
let nasty = "Müller\tÖl 🎉 €100 «señor» 日本 \\|<>{}[]!@#$%^&*_~;=`";
for style in [Transliteration::German, Transliteration::Epc] {
let out = transliterate(nasty, style);
assert!(
is_sepa_text(&out),
"{style:?} produced non-SEPA output: {out:?}"
);
}
}
#[test]
fn first_invalid_char_reports_the_offender() {
assert_eq!(first_invalid_char("Mueller"), None);
assert_eq!(first_invalid_char("Müller"), Some('ü'));
assert_eq!(first_invalid_char("A&B"), Some('&'));
}
#[test]
fn empty_input() {
assert!(is_sepa_text(""));
assert_eq!(transliterate("", Transliteration::German), "");
}
}