1mod i18n_impl;
30mod messages;
31
32use std::collections::HashMap;
33use std::fmt;
34use std::sync::OnceLock;
35
36use icu::collator::CollatorBorrowed;
37use icu::decimal::DecimalFormatter;
38use icu::locale::Locale;
39use icu::plurals::PluralRules;
40
41#[derive(Debug, Clone)]
45pub enum I18nError {
46 InvalidLocale {
48 input: String,
50 reason: String,
52 },
53 InvalidNumber {
55 input: String,
57 reason: String,
59 },
60 DateError(String),
62 FormatError(String),
64}
65
66impl fmt::Display for I18nError {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
72 Self::InvalidLocale { input, reason } => {
73 write!(f, "invalid locale '{input}': {reason}")
74 }
75 Self::InvalidNumber { input, reason } => {
76 write!(f, "invalid number '{input}': {reason}")
77 }
78 Self::DateError(detail) => write!(f, "date error: {detail}"),
79 Self::FormatError(detail) => write!(f, "format error: {detail}"),
80 }
81 }
82}
83
84impl std::error::Error for I18nError {}
85
86#[derive(Debug)]
93pub struct I18nFormatter {
94 pub(crate) locale: Locale,
96 pub(crate) decimal_formatter: DecimalFormatter,
98 pub(crate) plural_rules: PluralRules,
100 pub(crate) collator: CollatorBorrowed<'static>,
102}
103
104#[derive(Debug)]
111struct MessageCatalog {
112 messages: HashMap<String, String>,
113}
114
115impl MessageCatalog {
116 fn parse(ftl: &str) -> Self {
123 let mut messages = HashMap::new();
124 for line in ftl.lines() {
125 let line = line.trim();
126 if line.is_empty() || line.starts_with('#') {
127 continue;
128 }
129 if let Some((key, value)) = line.split_once('=') {
130 messages.insert(key.trim().to_string(), value.trim().to_string());
131 }
132 }
133 Self { messages }
134 }
135
136 fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
141 let Some(template) = self.messages.get(message_id) else {
142 return message_id.to_string();
143 };
144 let mut result = template.clone();
145 for &(key, value) in args {
146 let pattern = format!("{{ ${key} }}");
148 result = result.replace(&pattern, value);
149 }
150 result
151 }
152}
153
154static GLOBAL_I18N: OnceLock<I18nManager> = OnceLock::new();
158
159#[derive(Debug)]
164pub struct I18nManager {
165 catalog: MessageCatalog,
166 locale_tag: String,
167}
168
169impl I18nManager {
170 pub fn init() -> &'static Self {
175 GLOBAL_I18N.get_or_init(|| {
176 let locale_str = detect_system_locale();
177 Self::build(&locale_str)
178 })
179 }
180
181 pub fn init_with_locale(locale: &str) -> Result<&'static Self, I18nError> {
191 let manager = Self::build(locale);
192 GLOBAL_I18N
193 .set(manager)
194 .map_err(|_| I18nError::InvalidLocale {
195 input: locale.to_string(),
196 reason: "global I18nManager already initialized".into(),
197 })?;
198 Ok(GLOBAL_I18N.get().unwrap())
199 }
200
201 #[must_use]
206 pub fn global() -> Option<&'static I18nManager> {
207 GLOBAL_I18N.get()
208 }
209
210 #[must_use]
214 pub fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
215 self.catalog.translate(message_id, args)
216 }
217
218 #[must_use]
220 pub fn locale_tag(&self) -> &str {
221 &self.locale_tag
222 }
223
224 fn build(locale: &str) -> Self {
226 let ftl_content = if locale.starts_with("zh") {
227 messages::ZH_FTL
228 } else {
229 messages::EN_FTL
230 };
231 Self {
232 catalog: MessageCatalog::parse(ftl_content),
233 locale_tag: locale.to_string(),
234 }
235 }
236}
237
238#[must_use]
251pub fn tr(message_id: &str, args: &[(&str, &str)]) -> String {
252 let mgr = I18nManager::init();
253 mgr.translate(message_id, args)
254}
255
256fn detect_system_locale() -> String {
260 sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string())
261}
262
263#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::cmp::Ordering;
269
270 use icu::plurals::PluralCategory;
271
272 #[test]
275 fn catalog_parse_simple_ftl() {
276 let catalog = MessageCatalog::parse("hello = Hello, world!\nbye = Goodbye!");
277 assert_eq!(catalog.translate("hello", &[]), "Hello, world!");
278 assert_eq!(catalog.translate("bye", &[]), "Goodbye!");
279 }
280
281 #[test]
282 fn catalog_parse_skips_comments_and_blanks() {
283 let ftl = "# comment\n\nkey = value\n# another comment\n";
284 let catalog = MessageCatalog::parse(ftl);
285 assert_eq!(catalog.translate("key", &[]), "value");
286 }
287
288 #[test]
289 fn catalog_translate_with_variables() {
290 let catalog = MessageCatalog::parse("greet = Hello, { $name }!");
291 let result = catalog.translate("greet", &[("name", "World")]);
292 assert_eq!(result, "Hello, World!");
293 }
294
295 #[test]
296 fn catalog_translate_unknown_key_returns_key() {
297 let catalog = MessageCatalog::parse("key = value");
298 assert_eq!(catalog.translate("unknown", &[]), "unknown");
299 }
300
301 #[test]
304 fn manager_init_returns_valid_instance() {
305 let mgr = I18nManager::init();
306 assert!(
307 !mgr.locale_tag().is_empty(),
308 "locale tag should be non-empty"
309 );
310 }
311
312 #[test]
313 fn manager_translate_message() {
314 let mgr = I18nManager::init();
315 let msg = mgr.translate(
316 "trait-kit-error-already-registered",
317 &[("module", "test-mod")],
318 );
319 assert!(
320 msg.contains("test-mod"),
321 "translated message should contain module name: got '{msg}'"
322 );
323 }
324
325 #[test]
326 fn manager_translate_unknown_key_returns_key() {
327 let mgr = I18nManager::init();
328 let msg = mgr.translate("nonexistent-key", &[]);
329 assert_eq!(msg, "nonexistent-key");
330 }
331
332 #[test]
333 fn tr_convenience_function_works() {
334 let msg = tr("trait-kit-error-missing-capability", &[("key", "my-cap")]);
335 assert!(
336 msg.contains("my-cap"),
337 "tr() output should contain key: got '{msg}'"
338 );
339 }
340
341 #[test]
344 fn test_locale_parsing_en() {
345 let fmt = I18nFormatter::new("en-US");
346 assert!(fmt.is_ok(), "en-US should parse successfully");
347 let fmt = fmt.unwrap();
348 assert_eq!(fmt.locale.to_string(), "en-US");
349 }
350
351 #[test]
352 fn test_locale_parsing_zh() {
353 let fmt = I18nFormatter::new("zh-CN");
354 assert!(fmt.is_ok(), "zh-CN should parse successfully");
355 let fmt = fmt.unwrap();
356 assert_eq!(fmt.locale.to_string(), "zh-CN");
357 }
358
359 #[test]
360 fn test_invalid_locale() {
361 let result = I18nFormatter::new("not-a-valid-locale!!!");
362 assert!(result.is_err(), "invalid locale should return error");
363 match result.err().unwrap() {
364 I18nError::InvalidLocale { input, .. } => assert_eq!(input, "not-a-valid-locale!!!"),
365 other => panic!("expected InvalidLocale, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn test_format_number_en() {
371 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
372 let result = fmt.format_number(1_234_567.89_f64).expect("format number");
373 assert!(
374 result.contains(','),
375 "en-US number should contain thousands separator: got '{result}'"
376 );
377 assert!(
378 result.contains('.'),
379 "en-US number should contain decimal point: got '{result}'"
380 );
381 }
382
383 #[test]
384 fn test_format_number_zh() {
385 let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
386 let result = fmt.format_number(1_234_567.89_f64).expect("format number");
387 assert!(
388 !result.is_empty(),
389 "zh-CN number should be non-empty: got '{result}'"
390 );
391 }
392
393 #[test]
394 fn test_format_number_not_finite() {
395 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
396 assert!(fmt.format_number(f64::NAN).is_err());
397 assert!(fmt.format_number(f64::INFINITY).is_err());
398 }
399
400 #[test]
401 fn test_plural_rules_en() {
402 let fmt = I18nFormatter::new("en").expect("en locale");
403 assert_eq!(
404 fmt.plural_category(1).expect("plural 1"),
405 PluralCategory::One,
406 "en: count=1 should be One"
407 );
408 assert_eq!(
409 fmt.plural_category(2).expect("plural 2"),
410 PluralCategory::Other,
411 "en: count=2 should be Other"
412 );
413 assert_eq!(
414 fmt.plural_category(0).expect("plural 0"),
415 PluralCategory::Other,
416 "en: count=0 should be Other"
417 );
418 }
419
420 #[test]
421 fn test_collator_basic() {
422 let fmt = I18nFormatter::new("en").expect("en locale");
423 assert_eq!(
424 fmt.compare("apple", "banana").expect("compare"),
425 Ordering::Less,
426 "apple < banana"
427 );
428 assert_eq!(
429 fmt.compare("banana", "apple").expect("compare"),
430 Ordering::Greater,
431 "banana > apple"
432 );
433 assert_eq!(
434 fmt.compare("apple", "apple").expect("compare"),
435 Ordering::Equal,
436 "apple == apple"
437 );
438 }
439
440 #[test]
441 fn test_format_date_en() {
442 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
443 let result = fmt.format_date(2026, 7, 11).expect("format date");
444 assert!(
445 result.contains("2026"),
446 "date should contain year: got '{result}'"
447 );
448 assert!(
449 !result.is_empty(),
450 "date should be non-empty: got '{result}'"
451 );
452 }
453
454 #[test]
455 fn test_format_date_invalid_month() {
456 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
457 let result = fmt.format_date(2026, 13, 1);
458 assert!(result.is_err(), "month 13 should be invalid");
459 assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
460 }
461
462 #[test]
463 fn test_format_date_invalid_day() {
464 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
465 let result = fmt.format_date(2026, 2, 30);
466 assert!(result.is_err(), "Feb 30 should be invalid");
467 assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
468 }
469
470 #[test]
471 fn test_format_number_integer() {
472 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
473 let result = fmt.format_number(42.0).expect("format integer-like float");
474 assert!(
475 result.contains('4'),
476 "should contain digit 4: got '{result}'"
477 );
478 }
479
480 #[test]
481 fn test_plural_category_zero() {
482 let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
483 let cat = fmt.plural_category(0).expect("plural 0");
484 assert_eq!(
485 cat,
486 PluralCategory::Other,
487 "Chinese uses Other for all counts"
488 );
489 }
490
491 #[test]
492 fn test_compare_equal_strings() {
493 let fmt = I18nFormatter::new("de-DE").expect("de-DE locale");
494 let result = fmt.compare("abc", "abc").expect("compare");
495 assert_eq!(result, Ordering::Equal);
496 }
497
498 #[test]
501 fn error_display_invalid_locale() {
502 let err = I18nError::InvalidLocale {
503 input: "bad".into(),
504 reason: "parse failed".into(),
505 };
506 let msg = err.to_string();
507 assert!(
508 msg.contains("bad"),
509 "error display should contain input: got '{msg}'"
510 );
511 }
512
513 #[test]
514 fn error_display_date_error() {
515 let err = I18nError::DateError("month out of range".into());
516 let msg = err.to_string();
517 assert!(
518 msg.contains("month out of range"),
519 "error display should contain detail: got '{msg}'"
520 );
521 }
522
523 #[test]
524 fn error_display_invalid_number() {
525 let err = I18nError::InvalidNumber {
526 input: "NaN".into(),
527 reason: "not finite".into(),
528 };
529 let msg = err.to_string();
530 assert!(msg.contains("NaN"), "should contain input: got '{msg}'");
531 }
532
533 #[test]
534 fn error_display_format_error() {
535 let err = I18nError::FormatError("formatting failed".into());
536 let msg = err.to_string();
537 assert!(msg.contains("formatting failed"), "got '{msg}'");
538 }
539
540 #[test]
541 fn i18n_manager_init_with_locale() {
542 let _ = I18nManager::init_with_locale("en-US");
545 }
546
547 #[test]
548 fn i18n_manager_global_returns_some_after_init() {
549 let _ = I18nManager::init_with_locale("en-US");
550 assert!(I18nManager::global().is_some());
551 }
552
553 #[test]
554 fn i18n_manager_translate_and_locale_tag() {
555 let manager = I18nManager::build("en-US");
556 let tag = manager.locale_tag();
557 assert_eq!(tag, "en-US");
558 let msg = manager.translate("nonexistent-key", &[]);
559 assert_eq!(msg, "nonexistent-key");
560 }
561
562 #[test]
563 fn i18n_manager_build_zh_cn() {
564 let manager = I18nManager::build("zh-CN");
565 assert_eq!(manager.locale_tag(), "zh-CN");
566 }
567}