1#[cfg(feature = "i18n")]
30mod i18n_impl;
31mod messages;
32
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::OnceLock;
36
37#[cfg(feature = "i18n")]
38use icu::collator::CollatorBorrowed;
39#[cfg(feature = "i18n")]
40use icu::decimal::DecimalFormatter;
41#[cfg(feature = "i18n")]
42use icu::locale::Locale;
43#[cfg(feature = "i18n")]
44use icu::plurals::PluralRules;
45
46#[derive(Debug, Clone)]
50pub enum I18nError {
51 InvalidLocale {
53 input: String,
55 reason: String,
57 },
58 InvalidNumber {
60 input: String,
62 reason: String,
64 },
65 DateError(String),
67 FormatError(String),
69}
70
71impl fmt::Display for I18nError {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
76 Self::InvalidLocale { input, reason } => {
77 write!(
78 f,
79 "{}",
80 tr(
81 "i18n-error-invalid-locale",
82 &[("input", input), ("reason", reason)]
83 ),
84 )
85 }
86 Self::InvalidNumber { input, reason } => {
87 write!(
88 f,
89 "{}",
90 tr(
91 "i18n-error-invalid-number",
92 &[("input", input), ("reason", reason)]
93 ),
94 )
95 }
96 Self::DateError(detail) => {
97 write!(f, "{}", tr("i18n-error-date", &[("detail", detail)]))
98 }
99 Self::FormatError(detail) => {
100 write!(f, "{}", tr("i18n-error-format", &[("detail", detail)]))
101 }
102 }
103 }
104}
105
106impl std::error::Error for I18nError {}
107
108#[cfg(feature = "i18n")]
115#[derive(Debug)]
116pub struct I18nFormatter {
117 pub(crate) locale: Locale,
119 pub(crate) decimal_formatter: DecimalFormatter,
121 pub(crate) plural_rules: PluralRules,
123 pub(crate) collator: CollatorBorrowed<'static>,
125}
126
127#[derive(Debug)]
134struct MessageCatalog {
135 messages: HashMap<String, String>,
136}
137
138impl MessageCatalog {
139 fn parse(ftl: &str) -> Self {
146 let mut messages = HashMap::new();
147 for line in ftl.lines() {
148 let line = line.trim();
149 if line.is_empty() || line.starts_with('#') {
150 continue;
151 }
152 if let Some((key, value)) = line.split_once('=') {
153 messages.insert(key.trim().to_string(), value.trim().to_string());
154 }
155 }
156 Self { messages }
157 }
158
159 fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
164 let Some(template) = self.messages.get(message_id) else {
165 return message_id.to_string();
166 };
167 let mut result = template.clone();
168 for &(key, value) in args {
169 let pattern = format!("{{ ${key} }}");
171 result = result.replace(&pattern, value);
172 }
173 result
174 }
175}
176
177static GLOBAL_I18N: OnceLock<I18nManager> = OnceLock::new();
181
182#[derive(Debug)]
187pub struct I18nManager {
188 catalog: MessageCatalog,
189 locale_tag: String,
190}
191
192impl I18nManager {
193 pub fn init() -> &'static Self {
198 GLOBAL_I18N.get_or_init(|| {
199 #[cfg(feature = "i18n")]
200 let locale_str = detect_system_locale();
201 #[cfg(not(feature = "i18n"))]
202 let locale_str = String::from("en-US");
203 Self::build(&locale_str)
204 })
205 }
206
207 pub fn init_with_locale(locale: &str) -> Result<&'static Self, I18nError> {
217 let manager = Self::build(locale);
218 GLOBAL_I18N
219 .set(manager)
220 .map_err(|_| I18nError::InvalidLocale {
221 input: locale.to_string(),
222 reason: "global I18nManager already initialized".into(),
223 })?;
224 Ok(GLOBAL_I18N.get().unwrap())
225 }
226
227 #[must_use]
232 pub fn global() -> Option<&'static I18nManager> {
233 GLOBAL_I18N.get()
234 }
235
236 #[must_use]
240 pub fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
241 self.catalog.translate(message_id, args)
242 }
243
244 #[must_use]
246 pub fn locale_tag(&self) -> &str {
247 &self.locale_tag
248 }
249
250 fn build(locale: &str) -> Self {
252 let ftl_content = if locale.to_lowercase().starts_with("zh") {
253 messages::ZH_FTL
254 } else {
255 messages::EN_FTL
256 };
257 Self {
258 catalog: MessageCatalog::parse(ftl_content),
259 locale_tag: locale.to_string(),
260 }
261 }
262}
263
264#[must_use]
277pub fn tr(message_id: &str, args: &[(&str, &str)]) -> String {
278 let mgr = I18nManager::init();
279 mgr.translate(message_id, args)
280}
281
282#[cfg(feature = "i18n")]
286fn detect_system_locale() -> String {
287 sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string())
288}
289
290#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::cmp::Ordering;
296
297 #[cfg(feature = "i18n")]
298 use icu::plurals::PluralCategory;
299
300 #[test]
303 fn catalog_parse_simple_ftl() {
304 let catalog = MessageCatalog::parse("hello = Hello, world!\nbye = Goodbye!");
305 assert_eq!(catalog.translate("hello", &[]), "Hello, world!");
306 assert_eq!(catalog.translate("bye", &[]), "Goodbye!");
307 }
308
309 #[test]
310 fn catalog_parse_skips_comments_and_blanks() {
311 let ftl = "# comment\n\nkey = value\n# another comment\n";
312 let catalog = MessageCatalog::parse(ftl);
313 assert_eq!(catalog.translate("key", &[]), "value");
314 }
315
316 #[test]
317 fn catalog_translate_with_variables() {
318 let catalog = MessageCatalog::parse("greet = Hello, { $name }!");
319 let result = catalog.translate("greet", &[("name", "World")]);
320 assert_eq!(result, "Hello, World!");
321 }
322
323 #[test]
324 fn catalog_translate_unknown_key_returns_key() {
325 let catalog = MessageCatalog::parse("key = value");
326 assert_eq!(catalog.translate("unknown", &[]), "unknown");
327 }
328
329 #[test]
332 fn manager_init_returns_valid_instance() {
333 let mgr = I18nManager::init();
334 assert!(
335 !mgr.locale_tag().is_empty(),
336 "locale tag should be non-empty"
337 );
338 }
339
340 #[test]
341 fn manager_translate_message() {
342 let mgr = I18nManager::init();
343 let msg = mgr.translate(
344 "trait-kit-error-already-registered",
345 &[("module", "test-mod")],
346 );
347 assert!(
348 msg.contains("test-mod"),
349 "translated message should contain module name: got '{msg}'"
350 );
351 }
352
353 #[test]
354 fn manager_translate_unknown_key_returns_key() {
355 let mgr = I18nManager::init();
356 let msg = mgr.translate("nonexistent-key", &[]);
357 assert_eq!(msg, "nonexistent-key");
358 }
359
360 #[test]
361 fn tr_convenience_function_works() {
362 let msg = tr("trait-kit-error-missing-capability", &[("key", "my-cap")]);
363 assert!(
364 msg.contains("my-cap"),
365 "tr() output should contain key: got '{msg}'"
366 );
367 }
368
369 #[cfg(feature = "i18n")]
372 #[test]
373 fn test_locale_parsing_en() {
374 let fmt = I18nFormatter::new("en-US");
375 assert!(fmt.is_ok(), "en-US should parse successfully");
376 let fmt = fmt.unwrap();
377 assert_eq!(fmt.locale.to_string(), "en-US");
378 }
379
380 #[cfg(feature = "i18n")]
381 #[test]
382 fn test_locale_parsing_zh() {
383 let fmt = I18nFormatter::new("zh-CN");
384 assert!(fmt.is_ok(), "zh-CN should parse successfully");
385 let fmt = fmt.unwrap();
386 assert_eq!(fmt.locale.to_string(), "zh-CN");
387 }
388
389 #[cfg(feature = "i18n")]
390 #[test]
391 fn test_invalid_locale() {
392 let result = I18nFormatter::new("not-a-valid-locale!!!");
393 assert!(result.is_err(), "invalid locale should return error");
394 match result.err().unwrap() {
395 I18nError::InvalidLocale { input, .. } => assert_eq!(input, "not-a-valid-locale!!!"),
396 other => panic!("expected InvalidLocale, got {other:?}"),
397 }
398 }
399
400 #[cfg(feature = "i18n")]
401 #[test]
402 fn test_format_number_en() {
403 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
404 let result = fmt.format_number(1_234_567.89_f64).expect("format number");
405 assert!(
406 result.contains(','),
407 "en-US number should contain thousands separator: got '{result}'"
408 );
409 assert!(
410 result.contains('.'),
411 "en-US number should contain decimal point: got '{result}'"
412 );
413 }
414
415 #[cfg(feature = "i18n")]
416 #[test]
417 fn test_format_number_zh() {
418 let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
419 let result = fmt.format_number(1_234_567.89_f64).expect("format number");
420 assert!(
421 !result.is_empty(),
422 "zh-CN number should be non-empty: got '{result}'"
423 );
424 }
425
426 #[cfg(feature = "i18n")]
427 #[test]
428 fn test_format_number_not_finite() {
429 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
430 assert!(fmt.format_number(f64::NAN).is_err());
431 assert!(fmt.format_number(f64::INFINITY).is_err());
432 }
433
434 #[cfg(feature = "i18n")]
435 #[test]
436 fn test_plural_rules_en() {
437 let fmt = I18nFormatter::new("en").expect("en locale");
438 assert_eq!(
439 fmt.plural_category(1).expect("plural 1"),
440 PluralCategory::One,
441 "en: count=1 should be One"
442 );
443 assert_eq!(
444 fmt.plural_category(2).expect("plural 2"),
445 PluralCategory::Other,
446 "en: count=2 should be Other"
447 );
448 assert_eq!(
449 fmt.plural_category(0).expect("plural 0"),
450 PluralCategory::Other,
451 "en: count=0 should be Other"
452 );
453 }
454
455 #[cfg(feature = "i18n")]
456 #[test]
457 fn test_collator_basic() {
458 let fmt = I18nFormatter::new("en").expect("en locale");
459 assert_eq!(
460 fmt.compare("apple", "banana").expect("compare"),
461 Ordering::Less,
462 "apple < banana"
463 );
464 assert_eq!(
465 fmt.compare("banana", "apple").expect("compare"),
466 Ordering::Greater,
467 "banana > apple"
468 );
469 assert_eq!(
470 fmt.compare("apple", "apple").expect("compare"),
471 Ordering::Equal,
472 "apple == apple"
473 );
474 }
475
476 #[cfg(feature = "i18n")]
477 #[test]
478 fn test_format_date_en() {
479 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
480 let result = fmt.format_date(2026, 7, 11).expect("format date");
481 assert!(
482 result.contains("2026"),
483 "date should contain year: got '{result}'"
484 );
485 assert!(
486 !result.is_empty(),
487 "date should be non-empty: got '{result}'"
488 );
489 }
490
491 #[cfg(feature = "i18n")]
492 #[test]
493 fn test_format_date_invalid_month() {
494 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
495 let result = fmt.format_date(2026, 13, 1);
496 assert!(result.is_err(), "month 13 should be invalid");
497 assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
498 }
499
500 #[cfg(feature = "i18n")]
501 #[test]
502 fn test_format_date_invalid_day() {
503 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
504 let result = fmt.format_date(2026, 2, 30);
505 assert!(result.is_err(), "Feb 30 should be invalid");
506 assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
507 }
508
509 #[cfg(feature = "i18n")]
510 #[test]
511 fn test_format_number_integer() {
512 let fmt = I18nFormatter::new("en-US").expect("en-US locale");
513 let result = fmt.format_number(42.0).expect("format integer-like float");
514 assert!(
515 result.contains('4'),
516 "should contain digit 4: got '{result}'"
517 );
518 }
519
520 #[cfg(feature = "i18n")]
521 #[test]
522 fn test_plural_category_zero() {
523 let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
524 let cat = fmt.plural_category(0).expect("plural 0");
525 assert_eq!(
526 cat,
527 PluralCategory::Other,
528 "Chinese uses Other for all counts"
529 );
530 }
531
532 #[cfg(feature = "i18n")]
533 #[test]
534 fn test_compare_equal_strings() {
535 let fmt = I18nFormatter::new("de-DE").expect("de-DE locale");
536 let result = fmt.compare("abc", "abc").expect("compare");
537 assert_eq!(result, Ordering::Equal);
538 }
539
540 #[test]
543 fn error_display_invalid_locale() {
544 let err = I18nError::InvalidLocale {
545 input: "bad".into(),
546 reason: "parse failed".into(),
547 };
548 let msg = err.to_string();
549 assert!(
550 msg.contains("bad"),
551 "error display should contain input: got '{msg}'"
552 );
553 }
554
555 #[test]
556 fn error_display_date_error() {
557 let err = I18nError::DateError("month out of range".into());
558 let msg = err.to_string();
559 assert!(
560 msg.contains("month out of range"),
561 "error display should contain detail: got '{msg}'"
562 );
563 }
564
565 #[test]
566 fn error_display_invalid_number() {
567 let err = I18nError::InvalidNumber {
568 input: "NaN".into(),
569 reason: "not finite".into(),
570 };
571 let msg = err.to_string();
572 assert!(msg.contains("NaN"), "should contain input: got '{msg}'");
573 }
574
575 #[test]
576 fn error_display_format_error() {
577 let err = I18nError::FormatError("formatting failed".into());
578 let msg = err.to_string();
579 assert!(msg.contains("formatting failed"), "got '{msg}'");
580 }
581
582 #[test]
583 fn i18n_manager_init_with_locale() {
584 let _ = I18nManager::init_with_locale("en-US");
587 }
588
589 #[test]
590 fn i18n_manager_global_returns_some_after_init() {
591 let _ = I18nManager::init_with_locale("en-US");
592 assert!(I18nManager::global().is_some());
593 }
594
595 #[test]
596 fn i18n_manager_translate_and_locale_tag() {
597 let manager = I18nManager::build("en-US");
598 let tag = manager.locale_tag();
599 assert_eq!(tag, "en-US");
600 let msg = manager.translate("nonexistent-key", &[]);
601 assert_eq!(msg, "nonexistent-key");
602 }
603
604 #[test]
605 fn i18n_manager_build_zh_cn() {
606 let manager = I18nManager::build("zh-CN");
607 assert_eq!(manager.locale_tag(), "zh-CN");
608 }
609}