#[cfg(feature = "i18n")]
mod i18n_impl;
mod messages;
use std::collections::HashMap;
use std::fmt;
use std::sync::OnceLock;
#[cfg(feature = "i18n")]
use icu::collator::CollatorBorrowed;
#[cfg(feature = "i18n")]
use icu::decimal::DecimalFormatter;
#[cfg(feature = "i18n")]
use icu::locale::Locale;
#[cfg(feature = "i18n")]
use icu::plurals::PluralRules;
#[derive(Debug, Clone)]
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 {
Self::InvalidLocale { input, reason } => {
write!(
f,
"{}",
tr(
"i18n-error-invalid-locale",
&[("input", input), ("reason", reason)]
),
)
}
Self::InvalidNumber { input, reason } => {
write!(
f,
"{}",
tr(
"i18n-error-invalid-number",
&[("input", input), ("reason", reason)]
),
)
}
Self::DateError(detail) => {
write!(f, "{}", tr("i18n-error-date", &[("detail", detail)]))
}
Self::FormatError(detail) => {
write!(f, "{}", tr("i18n-error-format", &[("detail", detail)]))
}
}
}
}
impl std::error::Error for I18nError {}
#[cfg(feature = "i18n")]
#[derive(Debug)]
pub struct I18nFormatter {
pub(crate) locale: Locale,
pub(crate) decimal_formatter: DecimalFormatter,
pub(crate) plural_rules: PluralRules,
pub(crate) collator: CollatorBorrowed<'static>,
}
#[derive(Debug)]
struct MessageCatalog {
messages: HashMap<String, String>,
}
impl MessageCatalog {
fn parse(ftl: &str) -> Self {
let mut messages = HashMap::new();
for line in ftl.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
messages.insert(key.trim().to_string(), value.trim().to_string());
}
}
Self { messages }
}
fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
let Some(template) = self.messages.get(message_id) else {
return message_id.to_string();
};
let mut result = template.clone();
for &(key, value) in args {
let pattern = format!("{{ ${key} }}");
result = result.replace(&pattern, value);
}
result
}
}
static GLOBAL_I18N: OnceLock<I18nManager> = OnceLock::new();
#[derive(Debug)]
pub struct I18nManager {
catalog: MessageCatalog,
locale_tag: String,
}
impl I18nManager {
pub fn init() -> &'static Self {
GLOBAL_I18N.get_or_init(|| {
#[cfg(feature = "i18n")]
let locale_str = detect_system_locale();
#[cfg(not(feature = "i18n"))]
let locale_str = String::from("en-US");
Self::build(&locale_str)
})
}
pub fn init_with_locale(locale: &str) -> Result<&'static Self, I18nError> {
let manager = Self::build(locale);
GLOBAL_I18N
.set(manager)
.map_err(|_| I18nError::InvalidLocale {
input: locale.to_string(),
reason: "global I18nManager already initialized".into(),
})?;
Ok(GLOBAL_I18N.get().unwrap())
}
#[must_use]
pub fn global() -> Option<&'static I18nManager> {
GLOBAL_I18N.get()
}
#[must_use]
pub fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
self.catalog.translate(message_id, args)
}
#[must_use]
pub fn locale_tag(&self) -> &str {
&self.locale_tag
}
fn build(locale: &str) -> Self {
let ftl_content = if locale.to_lowercase().starts_with("zh") {
messages::ZH_FTL
} else {
messages::EN_FTL
};
Self {
catalog: MessageCatalog::parse(ftl_content),
locale_tag: locale.to_string(),
}
}
}
#[must_use]
pub fn tr(message_id: &str, args: &[(&str, &str)]) -> String {
let mgr = I18nManager::init();
mgr.translate(message_id, args)
}
#[cfg(feature = "i18n")]
fn detect_system_locale() -> String {
sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering;
#[cfg(feature = "i18n")]
use icu::plurals::PluralCategory;
#[test]
fn catalog_parse_simple_ftl() {
let catalog = MessageCatalog::parse("hello = Hello, world!\nbye = Goodbye!");
assert_eq!(catalog.translate("hello", &[]), "Hello, world!");
assert_eq!(catalog.translate("bye", &[]), "Goodbye!");
}
#[test]
fn catalog_parse_skips_comments_and_blanks() {
let ftl = "# comment\n\nkey = value\n# another comment\n";
let catalog = MessageCatalog::parse(ftl);
assert_eq!(catalog.translate("key", &[]), "value");
}
#[test]
fn catalog_translate_with_variables() {
let catalog = MessageCatalog::parse("greet = Hello, { $name }!");
let result = catalog.translate("greet", &[("name", "World")]);
assert_eq!(result, "Hello, World!");
}
#[test]
fn catalog_translate_unknown_key_returns_key() {
let catalog = MessageCatalog::parse("key = value");
assert_eq!(catalog.translate("unknown", &[]), "unknown");
}
#[test]
fn manager_init_returns_valid_instance() {
let mgr = I18nManager::init();
assert!(
!mgr.locale_tag().is_empty(),
"locale tag should be non-empty"
);
}
#[test]
fn manager_translate_message() {
let mgr = I18nManager::init();
let msg = mgr.translate(
"trait-kit-error-already-registered",
&[("module", "test-mod")],
);
assert!(
msg.contains("test-mod"),
"translated message should contain module name: got '{msg}'"
);
}
#[test]
fn manager_translate_unknown_key_returns_key() {
let mgr = I18nManager::init();
let msg = mgr.translate("nonexistent-key", &[]);
assert_eq!(msg, "nonexistent-key");
}
#[test]
fn tr_convenience_function_works() {
let msg = tr("trait-kit-error-missing-capability", &[("key", "my-cap")]);
assert!(
msg.contains("my-cap"),
"tr() output should contain key: got '{msg}'"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_locale_parsing_en() {
let fmt = I18nFormatter::new("en-US");
assert!(fmt.is_ok(), "en-US should parse successfully");
let fmt = fmt.unwrap();
assert_eq!(fmt.locale.to_string(), "en-US");
}
#[cfg(feature = "i18n")]
#[test]
fn test_locale_parsing_zh() {
let fmt = I18nFormatter::new("zh-CN");
assert!(fmt.is_ok(), "zh-CN should parse successfully");
let fmt = fmt.unwrap();
assert_eq!(fmt.locale.to_string(), "zh-CN");
}
#[cfg(feature = "i18n")]
#[test]
fn test_invalid_locale() {
let result = I18nFormatter::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:?}"),
}
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_number_en() {
let fmt = I18nFormatter::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}'"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_number_zh() {
let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
let result = fmt.format_number(1_234_567.89_f64).expect("format number");
assert!(
!result.is_empty(),
"zh-CN number should be non-empty: got '{result}'"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_number_not_finite() {
let fmt = I18nFormatter::new("en-US").expect("en-US locale");
assert!(fmt.format_number(f64::NAN).is_err());
assert!(fmt.format_number(f64::INFINITY).is_err());
}
#[cfg(feature = "i18n")]
#[test]
fn test_plural_rules_en() {
let fmt = I18nFormatter::new("en").expect("en locale");
assert_eq!(
fmt.plural_category(1).expect("plural 1"),
PluralCategory::One,
"en: count=1 should be One"
);
assert_eq!(
fmt.plural_category(2).expect("plural 2"),
PluralCategory::Other,
"en: count=2 should be Other"
);
assert_eq!(
fmt.plural_category(0).expect("plural 0"),
PluralCategory::Other,
"en: count=0 should be Other"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_collator_basic() {
let fmt = I18nFormatter::new("en").expect("en locale");
assert_eq!(
fmt.compare("apple", "banana").expect("compare"),
Ordering::Less,
"apple < banana"
);
assert_eq!(
fmt.compare("banana", "apple").expect("compare"),
Ordering::Greater,
"banana > apple"
);
assert_eq!(
fmt.compare("apple", "apple").expect("compare"),
Ordering::Equal,
"apple == apple"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_date_en() {
let fmt = I18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_date(2026, 7, 11).expect("format date");
assert!(
result.contains("2026"),
"date should contain year: got '{result}'"
);
assert!(
!result.is_empty(),
"date should be non-empty: got '{result}'"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_date_invalid_month() {
let fmt = I18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_date(2026, 13, 1);
assert!(result.is_err(), "month 13 should be invalid");
assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_date_invalid_day() {
let fmt = I18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_date(2026, 2, 30);
assert!(result.is_err(), "Feb 30 should be invalid");
assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
}
#[cfg(feature = "i18n")]
#[test]
fn test_format_number_integer() {
let fmt = I18nFormatter::new("en-US").expect("en-US locale");
let result = fmt.format_number(42.0).expect("format integer-like float");
assert!(
result.contains('4'),
"should contain digit 4: got '{result}'"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_plural_category_zero() {
let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
let cat = fmt.plural_category(0).expect("plural 0");
assert_eq!(
cat,
PluralCategory::Other,
"Chinese uses Other for all counts"
);
}
#[cfg(feature = "i18n")]
#[test]
fn test_compare_equal_strings() {
let fmt = I18nFormatter::new("de-DE").expect("de-DE locale");
let result = fmt.compare("abc", "abc").expect("compare");
assert_eq!(result, Ordering::Equal);
}
#[test]
fn error_display_invalid_locale() {
let err = I18nError::InvalidLocale {
input: "bad".into(),
reason: "parse failed".into(),
};
let msg = err.to_string();
assert!(
msg.contains("bad"),
"error display should contain input: got '{msg}'"
);
}
#[test]
fn error_display_date_error() {
let err = I18nError::DateError("month out of range".into());
let msg = err.to_string();
assert!(
msg.contains("month out of range"),
"error display should contain detail: got '{msg}'"
);
}
#[test]
fn error_display_invalid_number() {
let err = I18nError::InvalidNumber {
input: "NaN".into(),
reason: "not finite".into(),
};
let msg = err.to_string();
assert!(msg.contains("NaN"), "should contain input: got '{msg}'");
}
#[test]
fn error_display_format_error() {
let err = I18nError::FormatError("formatting failed".into());
let msg = err.to_string();
assert!(msg.contains("formatting failed"), "got '{msg}'");
}
#[test]
fn i18n_manager_init_with_locale() {
let _ = I18nManager::init_with_locale("en-US");
}
#[test]
fn i18n_manager_global_returns_some_after_init() {
let _ = I18nManager::init_with_locale("en-US");
assert!(I18nManager::global().is_some());
}
#[test]
fn i18n_manager_translate_and_locale_tag() {
let manager = I18nManager::build("en-US");
let tag = manager.locale_tag();
assert_eq!(tag, "en-US");
let msg = manager.translate("nonexistent-key", &[]);
assert_eq!(msg, "nonexistent-key");
}
#[test]
fn i18n_manager_build_zh_cn() {
let manager = I18nManager::build("zh-CN");
assert_eq!(manager.locale_tag(), "zh-CN");
}
}