use std::cmp::Ordering;
use std::str::FromStr;
use icu::collator::options::CollatorOptions;
use icu::collator::{Collator, CollatorBorrowed};
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 thiserror::Error;
use writeable::Writeable;
const DEFAULT_Q_VALUE: f64 = 1.0;
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",
}
}
#[derive(Debug, Error)]
pub enum I18nError {
#[error("invalid locale '{input}': {reason}")]
InvalidLocale {
input: String,
reason: String,
},
#[error("invalid number '{input}': {reason}")]
InvalidNumber {
input: String,
reason: String,
},
#[error("date error: {0}")]
DateError(String),
#[error("formatting error: {0}")]
FormatError(String),
#[error("no valid locale found in Accept-Language header '{header}'")]
NoValidLocale {
header: String,
},
}
pub struct HttpI18nFormatter {
locale: Locale,
decimal_formatter: DecimalFormatter,
plural_rules: PluralRules,
collator: CollatorBorrowed<'static>,
}
impl HttpI18nFormatter {
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 from_accept_language(header: &str) -> Result<Self, I18nError> {
let locales = parse_accept_language(header);
for locale in &locales {
if let Ok(fmt) = Self::new(locale) {
return Ok(fmt);
}
}
Err(I18nError::NoValidLocale {
header: header.to_string(),
})
}
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_error_message(&self, code: u16, count: u64) -> Result<String, I18nError> {
let count_str = self.format_number(count as f64)?;
let category = self.plural_rules.category_for(count);
let plural_name = plural_category_name(category);
let noun = match category {
PluralCategory::One => "error",
_ => "errors",
};
Ok(format!("HTTP {code}: {count_str} {noun} ({plural_name})"))
}
pub fn format_timestamp(&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 compare_headers(&self, a: &str, b: &str) -> Result<Ordering, I18nError> {
Ok(self.collator.compare(a, b))
}
}
pub fn parse_accept_language(header: &str) -> Vec<String> {
let mut entries: Vec<(String, f64)> = Vec::new();
for part in header.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let mut segments = part.split(';');
let locale = segments.next().unwrap_or("").trim().to_string();
if locale.is_empty() {
continue;
}
let mut q = DEFAULT_Q_VALUE;
for seg in segments {
let seg = seg.trim();
if let Some(q_str) = seg.strip_prefix("q=")
&& let Ok(parsed) = q_str.trim().parse::<f64>()
{
q = parsed;
}
}
if q > 0.0 {
entries.push((locale, q));
}
}
entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
entries.into_iter().map(|(locale, _)| locale).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_locale_parsing_en() {
let fmt = HttpI18nFormatter::new("en-US");
assert!(fmt.is_ok(), "en-US should parse successfully");
}
#[test]
fn test_locale_parsing_zh() {
let fmt = HttpI18nFormatter::new("zh-CN");
assert!(fmt.is_ok(), "zh-CN should parse successfully");
}
#[test]
fn test_invalid_locale() {
let result = HttpI18nFormatter::new("not-a-valid-locale!!!");
assert!(result.is_err(), "invalid locale should return error");
match result.err().unwrap() {
I18nError::InvalidLocale { input, .. } => assert_eq!(input, "not-a-valid-locale!!!"),
other => panic!("expected InvalidLocale, got {other:?}"),
}
}
#[test]
fn test_parse_accept_language() {
let locales = parse_accept_language("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7");
assert_eq!(
locales,
vec!["en-US", "en", "zh-CN", "zh"],
"locales should be sorted by q-value descending: got {locales:?}"
);
}
#[test]
fn test_parse_accept_language_default_q() {
let locales = parse_accept_language("fr,en;q=0.9");
assert_eq!(
locales,
vec!["fr", "en"],
"entry without q= should get default 1.0: got {locales:?}"
);
}
#[test]
fn test_parse_accept_language_q_zero_excluded() {
let locales = parse_accept_language("en;q=0,fr");
assert_eq!(
locales,
vec!["fr"],
"q=0 entries should be excluded: got {locales:?}"
);
}
#[test]
fn test_parse_accept_language_empty() {
let locales = parse_accept_language("");
assert!(locales.is_empty(), "empty header should return empty vec");
}
#[test]
fn test_from_accept_language() {
let fmt = HttpI18nFormatter::from_accept_language("en-US,en;q=0.9,zh-CN;q=0.8");
assert!(fmt.is_ok(), "should create formatter from valid header");
}
#[test]
fn test_from_accept_language_fallback() {
let fmt = HttpI18nFormatter::from_accept_language("not-a-locale!!!,en-US");
assert!(fmt.is_ok(), "should fall back to valid locale");
}
#[test]
fn test_from_accept_language_all_invalid() {
let result = HttpI18nFormatter::from_accept_language("not-a-locale!!!");
assert!(result.is_err(), "all-invalid header should error");
match result.err().unwrap() {
I18nError::NoValidLocale { header, .. } => {
assert_eq!(header, "not-a-locale!!!");
}
other => panic!("expected NoValidLocale, got {other:?}"),
}
}
#[test]
fn test_format_error_message_singular() {
let fmt = HttpI18nFormatter::new("en").expect("en locale");
let msg = fmt.format_error_message(404, 1).expect("error message");
assert!(
msg.contains("One"),
"count=1 should contain plural category One: got '{msg}'"
);
assert!(
msg.contains("error"),
"singular form should use 'error': got '{msg}'"
);
assert!(
msg.contains("404"),
"message should contain status code: got '{msg}'"
);
}
#[test]
fn test_format_error_message_plural() {
let fmt = HttpI18nFormatter::new("en").expect("en locale");
let msg = fmt.format_error_message(404, 2).expect("error message");
assert!(
msg.contains("Other"),
"count=2 should contain plural category Other: got '{msg}'"
);
assert!(
msg.contains("errors"),
"plural form should use 'errors': got '{msg}'"
);
}
#[test]
fn test_format_number_en() {
let fmt = HttpI18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_number(1_234_567.89_f64).expect("format number");
assert!(
result.contains(','),
"en-US number should contain thousands separator: got '{result}'"
);
assert!(
result.contains('.'),
"en-US number should contain decimal point: got '{result}'"
);
}
#[test]
fn test_format_number_not_finite() {
let fmt = HttpI18nFormatter::new("en-US").expect("en-US locale");
assert!(fmt.format_number(f64::NAN).is_err());
assert!(fmt.format_number(f64::INFINITY).is_err());
}
#[test]
fn test_format_timestamp() {
let fmt = HttpI18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_timestamp(2026, 7, 11).expect("format timestamp");
assert!(
result.contains("2026"),
"timestamp should contain year: got '{result}'"
);
assert!(
!result.is_empty(),
"timestamp should be non-empty: got '{result}'"
);
}
#[test]
fn test_compare_headers() {
let fmt = HttpI18nFormatter::new("en").expect("en locale");
assert_eq!(
fmt.compare_headers("apple", "banana").expect("compare"),
Ordering::Less,
"apple < banana"
);
assert_eq!(
fmt.compare_headers("banana", "apple").expect("compare"),
Ordering::Greater,
"banana > apple"
);
assert_eq!(
fmt.compare_headers("apple", "apple").expect("compare"),
Ordering::Equal,
"apple == apple"
);
}
#[test]
fn test_format_timestamp_invalid_date() {
let fmt = HttpI18nFormatter::new("en-US").expect("en-US locale");
assert!(
fmt.format_timestamp(2026, 13, 1).is_err(),
"month=13 should return date error"
);
assert!(
fmt.format_timestamp(2026, 2, 30).is_err(),
"Feb 30 should return date error"
);
}
#[test]
fn test_parse_accept_language_whitespace_entries() {
let locales = parse_accept_language(" , en ; q=0.9 , ");
assert_eq!(
locales,
vec!["en"],
"should handle whitespace-only and trimmed entries: got {locales:?}"
);
}
#[test]
fn test_parse_accept_language_malformed_q() {
let locales = parse_accept_language("fr;q=abc,en;q=0.5");
assert_eq!(
locales,
vec!["fr", "en"],
"malformed q= should fall back to default 1.0: got {locales:?}"
);
}
#[test]
fn test_parse_accept_language_q_zero_mixed() {
let locales = parse_accept_language("en;q=0,fr;q=0.5,de");
assert_eq!(
locales,
vec!["de", "fr"],
"q=0 excluded, others sorted by q: got {locales:?}"
);
}
}