use super::*;
use std::cmp::Ordering;
use std::str::FromStr;
fn localize_window(lang: &str, window: &str) -> String {
let lower = window.to_lowercase();
match (lang, lower.as_str()) {
("zh", "second") => "秒".to_string(),
("zh", "minute") => "分钟".to_string(),
("zh", "hour") => "小时".to_string(),
("zh", "day") => "天".to_string(),
("ja", "second") => "秒".to_string(),
("ja", "minute") => "分".to_string(),
("ja", "hour") => "時間".to_string(),
("ja", "day") => "日".to_string(),
("ko", "second") => "초".to_string(),
("ko", "minute") => "분".to_string(),
("ko", "hour") => "시간".to_string(),
("ko", "day") => "일".to_string(),
("de", "second") => "Sekunde".to_string(),
("de", "minute") => "Minute".to_string(),
("de", "hour") => "Stunde".to_string(),
("de", "day") => "Tag".to_string(),
("fr", "hour") => "heure".to_string(),
("fr", "day") => "jour".to_string(),
("es", "second") => "segundo".to_string(),
("es", "minute") => "minuto".to_string(),
("es", "hour") => "hora".to_string(),
("es", "day") => "día".to_string(),
_ => window.to_string(),
}
}
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;
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 LimiterI18nFormatter {
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 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> {
Ok(plural_category_name(self.plural_rules.category_for(count)).to_string())
}
pub fn format_rate_limit_message(
&self,
count: u64,
limit: u64,
window: &str,
) -> Result<String, I18nError> {
let count_str = self.format_number(count as f64)?;
let limit_str = self.format_number(limit as f64)?;
let lang = self.locale.id.language.as_str();
let window = localize_window(lang, window);
Ok(match lang {
"zh" => format!("已超出限流:每个{window} {count_str}/{limit_str} 个请求"),
"ja" => {
format!("レート制限を超過しました:{window}あたり{count_str}/{limit_str}リクエスト")
}
"ko" => format!("요청 한도 초과: {window}당 {count_str}/{limit_str}개 요청"),
"de" => {
format!("Ratenlimit überschritten: {count_str}/{limit_str} Anfragen pro {window}")
}
"fr" => format!(
"Limit de requêtes dépassée : {count_str}/{limit_str} requêtes par {window}"
),
"es" => format!(
"Límite de solicitudes excedido: {count_str}/{limit_str} solicitudes por {window}"
),
_ => format!("Rate limit exceeded: {count_str}/{limit_str} requests per {window}"),
})
}
pub fn format_window(&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_rules(&self, a: &str, b: &str) -> Result<Ordering, I18nError> {
Ok(self.collator.compare(a, b))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_locale_parsing_en() {
let fmt = LimiterI18nFormatter::new("en-US");
assert!(fmt.is_ok(), "en-US should parse successfully");
}
#[test]
fn test_locale_parsing_zh() {
let fmt = LimiterI18nFormatter::new("zh-CN");
assert!(fmt.is_ok(), "zh-CN should parse successfully");
}
#[test]
fn test_invalid_locale() {
let result = LimiterI18nFormatter::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_format_count() {
let fmt = LimiterI18nFormatter::new("en").expect("en locale");
assert_eq!(
fmt.format_count(1).expect("plural 1"),
"One",
"en: count=1 should be One"
);
assert_eq!(
fmt.format_count(2).expect("plural 2"),
"Other",
"en: count=2 should be Other"
);
}
#[test]
fn test_format_number_en() {
let fmt = LimiterI18nFormatter::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 = LimiterI18nFormatter::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_rate_limit_message() {
let fmt = LimiterI18nFormatter::new("en-US").expect("en-US locale");
let msg = fmt
.format_rate_limit_message(5, 100, "minute")
.expect("rate limit message");
assert!(
msg.contains("5"),
"message should contain count: got '{msg}'"
);
assert!(
msg.contains("100"),
"message should contain limit: got '{msg}'"
);
assert!(
msg.contains("minute"),
"message should contain window: got '{msg}'"
);
assert!(
msg.contains("Rate limit exceeded"),
"message should contain prefix: got '{msg}'"
);
}
#[test]
fn test_compare_rules() {
let fmt = LimiterI18nFormatter::new("en").expect("en locale");
assert_eq!(
fmt.compare_rules("apple", "banana").expect("compare"),
Ordering::Less,
"apple < banana"
);
assert_eq!(
fmt.compare_rules("banana", "apple").expect("compare"),
Ordering::Greater,
"banana > apple"
);
assert_eq!(
fmt.compare_rules("apple", "apple").expect("compare"),
Ordering::Equal,
"apple == apple"
);
}
#[test]
fn test_format_window() {
let fmt = LimiterI18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_window(2026, 7, 11).expect("format window");
assert!(
result.contains("2026"),
"window should contain year: got '{result}'"
);
assert!(
!result.is_empty(),
"window should be non-empty: got '{result}'"
);
}
}