normalizer-tr 0.4.0

Bounded Turkish text-to-speech normalization with original-source diagnostics
Documentation
use crate::{
    domain::{
        electronic::Electronic,
        identifiers::{Iban, Telephone},
        numeric::{Numeric, NumericRange, Quantity},
    },
    morphology::Inflection,
    numerals::Number,
};

#[derive(Clone, Copy, Debug)]
pub(crate) struct Date {
    day: u8,
    month: u8,
    year: u16,
    dotted: bool,
}

impl Date {
    pub(crate) fn parse(text: &str) -> Option<Self> {
        let dotted = text.contains('.');
        let parts: Vec<_> = text.split(if dotted { '.' } else { '-' }).collect();
        let [first, second, third] = parts.as_slice() else {
            return None;
        };
        let (day, month, year) = if dotted {
            (*first, *second, *third)
        } else {
            (*third, *second, *first)
        };
        if year.len() != 4
            || !(1..=2).contains(&day.len())
            || !(1..=2).contains(&month.len())
            || (!dotted && (day.len() != 2 || month.len() != 2))
            || ![day, month, year]
                .iter()
                .all(|s| s.bytes().all(|b| b.is_ascii_digit()))
        {
            return None;
        }
        let day: u8 = day.parse().ok()?;
        let month: u8 = month.parse().ok()?;
        let year: u16 = year.parse().ok()?;
        let leap =
            year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
        let days = match month {
            2 if leap => 29,
            2 => 28,
            4 | 6 | 9 | 11 => 30,
            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
            _ => return None,
        };
        if year == 0 || day == 0 || day > days {
            return None;
        }
        Some(Self {
            day,
            month,
            year,
            dotted,
        })
    }
    pub(crate) fn day(self) -> u8 {
        self.day
    }
    pub(crate) fn month(self) -> u8 {
        self.month
    }
    pub(crate) fn year(self) -> u16 {
        self.year
    }
    pub(crate) fn dotted(self) -> bool {
        self.dotted
    }
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct Clock {
    hour: u8,
    minute: u8,
}

impl Clock {
    pub(crate) fn parse(text: &str) -> Option<Self> {
        let (hour, minute) = text.split_once([':', '.'])?;
        if !(1..=2).contains(&hour.len())
            || minute.len() != 2
            || ![hour, minute]
                .iter()
                .all(|s| s.bytes().all(|b| b.is_ascii_digit()))
        {
            return None;
        }
        let hour = hour.parse().ok()?;
        let minute = minute.parse().ok()?;
        if hour > 23 || minute > 59 {
            return None;
        }
        Some(Self { hour, minute })
    }
    pub(crate) fn hour(self) -> u8 {
        self.hour
    }
    pub(crate) fn minute(self) -> u8 {
        self.minute
    }
}

#[derive(Clone, Debug)]
pub(crate) enum Value {
    Numeric(Numeric),
    Digits(String),
    Percent(Number, Option<Inflection>),
    Date(Date, bool),
    Time(Clock, bool),
    Quantity(Quantity),
    Lexical(crate::domain::lexicon::Lexeme, Option<Inflection>),
    Range(NumericRange),
    Telephone(Telephone),
    Iban(Iban),
    Roman(Numeric),
    Electronic(Electronic),
    Symbol(String),
}

pub(crate) enum TemporalPreference {
    Date(Date, bool),
    Time(Clock, bool),
}

pub(crate) struct TemporalFailure {
    pub(crate) category: crate::IssueCategory,
    pub(crate) preference: Option<TemporalPreference>,
}

impl From<crate::IssueCategory> for TemporalFailure {
    fn from(category: crate::IssueCategory) -> Self {
        Self {
            category,
            preference: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::numerals::Amount;

    #[test]
    fn gregorian_boundaries_and_iso_shape_are_validated_before_rendering() {
        for year in 1..=9999_u16 {
            let expected_leap =
                year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
            assert_eq!(
                Date::parse(&format!("29.02.{year:04}")).is_some(),
                expected_leap
            );
        }
        for input in [
            "31.04.2026",
            "00.01.2026",
            "01.00.2026",
            "2026-3-04",
            "2026-03-4",
        ] {
            assert!(Date::parse(input).is_none());
        }
    }

    #[test]
    fn clock_and_amount_private_types_cannot_hold_invalid_values() {
        assert!(Clock::parse("23:59").is_some());
        assert!(Clock::parse("00:00").is_some());
        for input in ["24:00", "12:60", "12:5", "1:2:3"] {
            assert!(Clock::parse(input).is_none());
        }
        assert_eq!(Amount::parse("-0,05").unwrap().minor(), 5);
        assert!(Amount::parse("1,005").is_none());
    }
}