use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use icu::collator::Collator;
use icu::collator::options::CollatorOptions;
use icu::datetime::DateTimeFormatter;
use icu::datetime::fieldsets::YMD;
use icu::datetime::input::{Date, DateTime, Time};
use icu::decimal::DecimalFormatter;
use icu::decimal::input::Decimal;
use icu::decimal::options::DecimalFormatterOptions;
use icu::locale::Locale;
use icu::plurals::{PluralCategory, PluralRules, PluralRulesOptions};
use writeable::Writeable;
#[derive(Debug)]
pub enum I18nError {
InvalidLocale { input: String, reason: String },
InvalidNumber { input: String, reason: String },
DateError(String),
FormatError(String),
}
impl fmt::Display for I18nError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
I18nError::InvalidLocale { input, reason } => {
write!(f, "invalid locale '{input}': {reason}")
}
I18nError::InvalidNumber { input, reason } => {
write!(f, "invalid number '{input}': {reason}")
}
I18nError::DateError(reason) => write!(f, "date error: {reason}"),
I18nError::FormatError(reason) => write!(f, "formatting error: {reason}"),
}
}
}
impl std::error::Error for I18nError {}
#[derive(Debug)]
pub struct I18nFormatter {
locale: Locale,
decimal_formatter: DecimalFormatter,
plural_rules: PluralRules,
collator: icu::collator::CollatorBorrowed<'static>,
}
fn plural_category_name(category: PluralCategory) -> &'static str {
match category {
PluralCategory::Zero => "Zero",
PluralCategory::One => "One",
PluralCategory::Two => "Two",
PluralCategory::Few => "Few",
PluralCategory::Many => "Many",
PluralCategory::Other => "Other",
}
}
impl I18nFormatter {
pub fn new(locale: &str) -> Result<Self, I18nError> {
let parsed = Locale::from_str(locale).map_err(|e| I18nError::InvalidLocale {
input: locale.to_string(),
reason: e.to_string(),
})?;
let decimal_formatter =
DecimalFormatter::try_new(parsed.clone().into(), DecimalFormatterOptions::default())
.map_err(|e| I18nError::FormatError(e.to_string()))?;
let plural_rules =
PluralRules::try_new(parsed.clone().into(), PluralRulesOptions::default())
.map_err(|e| I18nError::FormatError(e.to_string()))?;
let collator = Collator::try_new(parsed.clone().into(), CollatorOptions::default())
.map_err(|e| I18nError::FormatError(e.to_string()))?;
Ok(Self {
locale: parsed,
decimal_formatter,
plural_rules,
collator,
})
}
pub fn for_current_locale() -> Result<Self, I18nError> {
Self::new(&super::current_locale())
}
pub fn format_number(&self, value: f64) -> Result<String, I18nError> {
if !value.is_finite() {
return Err(I18nError::InvalidNumber {
input: value.to_string(),
reason: "value is not finite (NaN or Infinity)".into(),
});
}
let repr = format!("{value}");
let decimal = Decimal::from_str(&repr).map_err(|e| I18nError::InvalidNumber {
input: repr,
reason: e.to_string(),
})?;
let formatted = self.decimal_formatter.format(&decimal);
Ok(formatted.write_to_string().into_owned())
}
pub fn format_count(&self, count: u64) -> Result<String, I18nError> {
self.format_number(count as f64)
}
pub fn format_date(&self, year: i32, month: u8, day: u8) -> Result<String, I18nError> {
let date =
Date::try_new_iso(year, month, day).map_err(|e| I18nError::DateError(e.to_string()))?;
let time = Time::try_new(0, 0, 0, 0).map_err(|e| I18nError::DateError(e.to_string()))?;
let datetime = DateTime { date, time };
let dtf = DateTimeFormatter::try_new(self.locale.clone().into(), YMD::medium())
.map_err(|e| I18nError::FormatError(e.to_string()))?;
let formatted = dtf.format(&datetime);
Ok(formatted.write_to_string().into_owned())
}
pub fn plural_category(&self, count: u64) -> Result<String, I18nError> {
Ok(plural_category_name(self.plural_rules.category_for(count)).to_string())
}
pub fn compare(&self, a: &str, b: &str) -> Result<Ordering, I18nError> {
Ok(self.collator.compare(a, b))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_valid_locales() {
assert!(I18nFormatter::new("en").is_ok());
assert!(I18nFormatter::new("zh").is_ok());
assert!(I18nFormatter::new("zh-CN").is_ok());
assert!(I18nFormatter::new("en-US").is_ok());
}
#[test]
fn test_new_invalid_locale() {
let err = I18nFormatter::new("not-a-valid-locale!!!").unwrap_err();
assert!(matches!(err, I18nError::InvalidLocale { .. }));
assert!(err.to_string().contains("invalid locale"));
}
#[test]
fn test_format_number_en_grouping() {
let fmt = I18nFormatter::new("en").unwrap();
assert_eq!(fmt.format_number(1234567.0).unwrap(), "1,234,567");
assert_eq!(fmt.format_number(42.5).unwrap(), "42.5");
}
#[test]
fn test_format_number_zh() {
let fmt = I18nFormatter::new("zh").unwrap();
let result = fmt.format_number(1234567.0).unwrap();
assert!(!result.is_empty(), "zh grouping: {result}");
assert!(
result.contains('1'),
"zh grouping contains digits: {result}"
);
}
#[test]
fn test_format_number_non_finite_is_error() {
let fmt = I18nFormatter::new("en").unwrap();
assert!(fmt.format_number(f64::NAN).is_err());
assert!(fmt.format_number(f64::INFINITY).is_err());
}
#[test]
fn test_format_count() {
let fmt = I18nFormatter::new("en").unwrap();
assert_eq!(fmt.format_count(1_234_567).unwrap(), "1,234,567");
}
#[test]
fn test_format_date_en() {
let fmt = I18nFormatter::new("en").unwrap();
let result = fmt.format_date(2026, 9, 19).unwrap();
assert!(result.contains("2026"), "en medium date: {result}");
assert!(result.contains("19"), "en medium date: {result}");
}
#[test]
fn test_format_date_zh() {
let fmt = I18nFormatter::new("zh").unwrap();
let result = fmt.format_date(2026, 9, 19).unwrap();
assert!(
result.contains("2026") && result.contains("9") && result.contains("19"),
"zh medium date: {result}"
);
}
#[test]
fn test_format_date_invalid_components() {
let fmt = I18nFormatter::new("en").unwrap();
assert!(fmt.format_date(2026, 13, 1).is_err(), "month 13 invalid");
assert!(fmt.format_date(2026, 1, 32).is_err(), "day 32 invalid");
}
#[test]
fn test_plural_category_en() {
let fmt = I18nFormatter::new("en").unwrap();
assert_eq!(fmt.plural_category(1).unwrap(), "One");
assert_eq!(fmt.plural_category(2).unwrap(), "Other");
assert_eq!(fmt.plural_category(0).unwrap(), "Other");
}
#[test]
fn test_plural_category_zh_is_other() {
let fmt = I18nFormatter::new("zh").unwrap();
assert_eq!(fmt.plural_category(1).unwrap(), "Other");
assert_eq!(fmt.plural_category(100).unwrap(), "Other");
}
#[test]
fn test_compare_collation() {
let fmt = I18nFormatter::new("en").unwrap();
assert_eq!(fmt.compare("apple", "banana").unwrap(), Ordering::Less);
assert_eq!(fmt.compare("b", "a").unwrap(), Ordering::Greater);
assert_eq!(fmt.compare("same", "same").unwrap(), Ordering::Equal);
}
}