1use std::collections::HashMap;
24
25use crate::plural::{PluralCategory, PluralForms, PluralRule};
26
27pub type Locale = String;
29
30#[derive(Debug, Clone)]
32pub enum I18nError {
33 InvalidLocale(String),
35 ParseError(String),
37 DuplicateKey { locale: String, key: String },
39}
40
41impl std::fmt::Display for I18nError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::InvalidLocale(l) => write!(f, "invalid locale: {l}"),
45 Self::ParseError(msg) => write!(f, "parse error: {msg}"),
46 Self::DuplicateKey { locale, key } => {
47 write!(f, "duplicate key '{key}' in locale '{locale}'")
48 }
49 }
50 }
51}
52
53impl std::error::Error for I18nError {}
54
55#[derive(Debug, Clone)]
57pub enum StringEntry {
58 Simple(String),
60 Plural(PluralForms),
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct LocaleStrings {
67 strings: HashMap<String, StringEntry>,
68}
69
70impl LocaleStrings {
71 #[must_use]
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
79 self.strings
80 .insert(key.into(), StringEntry::Simple(value.into()));
81 }
82
83 pub fn insert_plural(&mut self, key: impl Into<String>, forms: PluralForms) {
85 self.strings.insert(key.into(), StringEntry::Plural(forms));
86 }
87
88 #[must_use]
90 pub fn get(&self, key: &str) -> Option<&StringEntry> {
91 self.strings.get(key)
92 }
93
94 #[must_use]
96 pub fn len(&self) -> usize {
97 self.strings.len()
98 }
99
100 #[must_use]
102 pub fn is_empty(&self) -> bool {
103 self.strings.is_empty()
104 }
105
106 pub fn keys(&self) -> impl Iterator<Item = &str> {
108 self.strings.keys().map(String::as_str)
109 }
110}
111
112#[derive(Debug, Clone)]
148pub struct StringCatalog {
149 locales: HashMap<Locale, LocaleStrings>,
150 fallback_chain: Vec<Locale>,
151 plural_rules: HashMap<Locale, PluralRule>,
152}
153
154impl Default for StringCatalog {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160impl StringCatalog {
161 #[must_use]
163 pub fn new() -> Self {
164 Self {
165 locales: HashMap::new(),
166 fallback_chain: Vec::new(),
167 plural_rules: HashMap::new(),
168 }
169 }
170
171 pub fn add_locale(&mut self, locale: impl Into<String>, strings: LocaleStrings) {
175 let locale = locale.into();
176 let rule = PluralRule::for_locale(&locale);
177 self.plural_rules.insert(locale.clone(), rule);
178 self.locales.insert(locale, strings);
179 }
180
181 pub fn set_fallback_chain(&mut self, chain: Vec<Locale>) {
186 self.fallback_chain = chain;
187 }
188
189 pub fn set_plural_rule(&mut self, locale: impl Into<String>, rule: PluralRule) {
191 self.plural_rules.insert(locale.into(), rule);
192 }
193
194 #[must_use]
199 pub fn get(&self, locale: &str, key: &str) -> Option<&str> {
200 if let Some(entry) = self.locales.get(locale).and_then(|ls| ls.get(key)) {
202 return match entry {
203 StringEntry::Simple(s) => Some(s.as_str()),
204 StringEntry::Plural(p) => Some(&p.other),
205 };
206 }
207
208 for fallback in &self.fallback_chain {
210 if fallback == locale {
211 continue; }
213 if let Some(entry) = self
214 .locales
215 .get(fallback.as_str())
216 .and_then(|ls| ls.get(key))
217 {
218 return match entry {
219 StringEntry::Simple(s) => Some(s.as_str()),
220 StringEntry::Plural(p) => Some(&p.other),
221 };
222 }
223 }
224
225 None
226 }
227
228 #[must_use]
232 pub fn get_plural(&self, locale: &str, key: &str, count: i64) -> Option<&str> {
233 let rule = self
234 .plural_rules
235 .get(locale)
236 .cloned()
237 .unwrap_or(PluralRule::English);
238 let category = rule.categorize(count);
239
240 if let Some(result) = self.get_plural_from(locale, key, category) {
242 return Some(result);
243 }
244
245 for fallback in &self.fallback_chain {
247 if fallback == locale {
248 continue;
249 }
250 let fb_rule = self
251 .plural_rules
252 .get(fallback.as_str())
253 .cloned()
254 .unwrap_or(PluralRule::English);
255 let fb_category = fb_rule.categorize(count);
256 if let Some(result) = self.get_plural_from(fallback, key, fb_category) {
257 return Some(result);
258 }
259 }
260
261 None
262 }
263
264 fn get_plural_from(&self, locale: &str, key: &str, category: PluralCategory) -> Option<&str> {
265 self.locales
266 .get(locale)
267 .and_then(|ls| ls.get(key))
268 .map(|entry| match entry {
269 StringEntry::Plural(forms) => forms.select(category),
270 StringEntry::Simple(s) => s.as_str(),
271 })
272 }
273
274 #[must_use]
279 pub fn format(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> Option<String> {
280 self.get(locale, key)
281 .map(|template| interpolate(template, args))
282 }
283
284 #[must_use]
288 pub fn format_plural(
289 &self,
290 locale: &str,
291 key: &str,
292 count: i64,
293 extra_args: &[(&str, &str)],
294 ) -> Option<String> {
295 self.get_plural(locale, key, count).map(|template| {
296 let count_str = count.to_string();
297 let mut all_args: Vec<(&str, &str)> = vec![("count", &count_str)];
298 all_args.extend_from_slice(extra_args);
299 interpolate(template, &all_args)
300 })
301 }
302
303 #[must_use]
305 pub fn locales(&self) -> Vec<&str> {
306 self.locales.keys().map(String::as_str).collect()
307 }
308
309 #[must_use]
317 pub fn all_keys(&self) -> Vec<String> {
318 let mut keys: Vec<String> = self
319 .locales
320 .values()
321 .flat_map(|ls| ls.keys().map(String::from))
322 .collect();
323 keys.sort_unstable();
324 keys.dedup();
325 keys
326 }
327
328 #[must_use]
333 pub fn missing_keys(&self, locale: &str, reference_keys: &[&str]) -> Vec<String> {
334 let mut missing = Vec::new();
335 for &key in reference_keys {
336 if self.get(locale, key).is_none() {
337 missing.push(key.to_string());
338 }
339 }
340 missing.sort_unstable();
341 missing
342 }
343
344 #[must_use]
349 pub fn coverage_report(&self) -> CoverageReport {
350 let all = self.all_keys();
351 let ref_keys: Vec<&str> = all.iter().map(String::as_str).collect();
352 let total = ref_keys.len();
353
354 let mut locale_tags: Vec<String> = self.locales.keys().cloned().collect();
355 locale_tags.sort_unstable();
356
357 let locales = locale_tags
358 .into_iter()
359 .map(|tag| {
360 let missing = self.missing_keys(&tag, &ref_keys);
361 let present = total.saturating_sub(missing.len());
362 let coverage_percent = if total == 0 {
363 100.0
364 } else {
365 (present as f32 / total as f32) * 100.0
366 };
367 LocaleCoverage {
368 locale: tag,
369 present,
370 missing,
371 coverage_percent,
372 }
373 })
374 .collect();
375
376 CoverageReport {
377 total_keys: total,
378 locales,
379 }
380 }
381}
382
383#[derive(Debug, Clone)]
388pub struct CoverageReport {
389 pub total_keys: usize,
391 pub locales: Vec<LocaleCoverage>,
393}
394
395#[derive(Debug, Clone)]
397pub struct LocaleCoverage {
398 pub locale: String,
400 pub present: usize,
402 pub missing: Vec<String>,
404 pub coverage_percent: f32,
406}
407
408fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
410 let mut result = String::with_capacity(template.len());
411 let mut chars = template.chars().peekable();
412
413 while let Some(ch) = chars.next() {
414 if ch == '{' {
415 let mut placeholder = String::new();
417 let mut found_close = false;
418 for c in chars.by_ref() {
419 if c == '}' {
420 found_close = true;
421 break;
422 }
423 placeholder.push(c);
424 }
425
426 if found_close {
427 if let Some(&(_, value)) = args.iter().find(|&&(name, _)| name == placeholder) {
429 result.push_str(value);
430 } else {
431 result.push('{');
433 result.push_str(&placeholder);
434 result.push('}');
435 }
436 } else {
437 result.push('{');
439 result.push_str(&placeholder);
440 }
441 } else {
442 result.push(ch);
443 }
444 }
445
446 result
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::plural::PluralForms;
453
454 fn english_catalog() -> StringCatalog {
455 let mut catalog = StringCatalog::new();
456 let mut en = LocaleStrings::new();
457 en.insert("greeting", "Hello");
458 en.insert("welcome", "Welcome, {name}!");
459 en.insert("farewell", "Goodbye, {name}. See you {when}.");
460 en.insert_plural(
461 "items",
462 PluralForms {
463 one: "{count} item".into(),
464 other: "{count} items".into(),
465 ..Default::default()
466 },
467 );
468 catalog.add_locale("en", en);
469 catalog.set_fallback_chain(vec!["en".into()]);
470 catalog
471 }
472
473 #[test]
474 fn simple_lookup() {
475 let catalog = english_catalog();
476 assert_eq!(catalog.get("en", "greeting"), Some("Hello"));
477 }
478
479 #[test]
480 fn missing_key_returns_none() {
481 let catalog = english_catalog();
482 assert_eq!(catalog.get("en", "nonexistent"), None);
483 }
484
485 #[test]
486 fn missing_locale_falls_back() {
487 let catalog = english_catalog();
488 assert_eq!(catalog.get("fr", "greeting"), Some("Hello"));
490 }
491
492 #[test]
493 fn fallback_chain_order() {
494 let mut catalog = StringCatalog::new();
495
496 let mut en = LocaleStrings::new();
497 en.insert("greeting", "Hello");
498 en.insert("color", "Color");
499
500 let mut es = LocaleStrings::new();
501 es.insert("greeting", "Hola");
502 let mut es_mx = LocaleStrings::new();
505 es_mx.insert("greeting", "Qué onda");
506 catalog.add_locale("en", en);
509 catalog.add_locale("es", es);
510 catalog.add_locale("es-MX", es_mx);
511 catalog.set_fallback_chain(vec!["es-MX".into(), "es".into(), "en".into()]);
512
513 assert_eq!(catalog.get("es-MX", "greeting"), Some("Qué onda"));
515 assert_eq!(catalog.get("es-MX", "color"), Some("Color"));
517 }
518
519 #[test]
520 fn plural_english_singular() {
521 let catalog = english_catalog();
522 assert_eq!(catalog.get_plural("en", "items", 1), Some("{count} item"));
523 }
524
525 #[test]
526 fn plural_english_plural() {
527 let catalog = english_catalog();
528 assert_eq!(catalog.get_plural("en", "items", 0), Some("{count} items"));
529 assert_eq!(catalog.get_plural("en", "items", 2), Some("{count} items"));
530 assert_eq!(
531 catalog.get_plural("en", "items", 100),
532 Some("{count} items")
533 );
534 }
535
536 #[test]
537 fn plural_russian() {
538 let mut catalog = StringCatalog::new();
539 let mut ru = LocaleStrings::new();
540 ru.insert_plural(
541 "files",
542 PluralForms {
543 one: "{count} файл".into(),
544 few: Some("{count} файла".into()),
545 many: Some("{count} файлов".into()),
546 other: "{count} файлов".into(),
547 ..Default::default()
548 },
549 );
550 catalog.add_locale("ru", ru);
551
552 assert_eq!(catalog.get_plural("ru", "files", 1), Some("{count} файл"));
553 assert_eq!(catalog.get_plural("ru", "files", 3), Some("{count} файла"));
554 assert_eq!(catalog.get_plural("ru", "files", 5), Some("{count} файлов"));
555 assert_eq!(catalog.get_plural("ru", "files", 21), Some("{count} файл"));
556 }
557
558 #[test]
559 fn interpolation_single_arg() {
560 let catalog = english_catalog();
561 assert_eq!(
562 catalog.format("en", "welcome", &[("name", "Alice")]),
563 Some("Welcome, Alice!".into())
564 );
565 }
566
567 #[test]
568 fn interpolation_multiple_args() {
569 let catalog = english_catalog();
570 assert_eq!(
571 catalog.format("en", "farewell", &[("name", "Bob"), ("when", "tomorrow")]),
572 Some("Goodbye, Bob. See you tomorrow.".into())
573 );
574 }
575
576 #[test]
577 fn interpolation_missing_arg_left_as_is() {
578 let catalog = english_catalog();
579 assert_eq!(
580 catalog.format("en", "welcome", &[]),
581 Some("Welcome, {name}!".into())
582 );
583 }
584
585 #[test]
586 fn format_plural_auto_count() {
587 let catalog = english_catalog();
588 assert_eq!(
589 catalog.format_plural("en", "items", 1, &[]),
590 Some("1 item".into())
591 );
592 assert_eq!(
593 catalog.format_plural("en", "items", 42, &[]),
594 Some("42 items".into())
595 );
596 }
597
598 #[test]
599 fn interpolation_edge_cases() {
600 assert_eq!(interpolate("Hello {world", &[]), "Hello {world");
602 assert_eq!(interpolate("Hello {}", &[]), "Hello {}");
604 assert_eq!(interpolate("Hello World", &[]), "Hello World");
606 assert_eq!(interpolate("{x} and {x}", &[("x", "A")]), "A and A");
608 }
609
610 #[test]
611 fn empty_catalog() {
612 let catalog = StringCatalog::new();
613 assert_eq!(catalog.get("en", "anything"), None);
614 assert_eq!(catalog.get_plural("en", "anything", 1), None);
615 assert!(catalog.locales().is_empty());
616 }
617
618 #[test]
619 fn locale_listing() {
620 let catalog = english_catalog();
621 let locales = catalog.locales();
622 assert_eq!(locales.len(), 1);
623 assert!(locales.contains(&"en"));
624 }
625
626 #[test]
627 fn locale_strings_len() {
628 let catalog = english_catalog();
629 let en = catalog.locales.get("en").unwrap();
630 assert_eq!(en.len(), 4); assert!(!en.is_empty());
632 }
633
634 #[test]
635 fn simple_entry_from_plural_lookup() {
636 let catalog = english_catalog();
638 assert_eq!(catalog.get_plural("en", "greeting", 1), Some("Hello"));
639 }
640
641 fn multi_locale_catalog() -> StringCatalog {
646 let mut catalog = StringCatalog::new();
647
648 let mut en = LocaleStrings::new();
649 en.insert("greeting", "Hello");
650 en.insert("farewell", "Goodbye");
651 en.insert("submit", "Submit");
652 catalog.add_locale("en", en);
653
654 let mut es = LocaleStrings::new();
655 es.insert("greeting", "Hola");
656 es.insert("farewell", "Adiós");
657 catalog.add_locale("es", es);
659
660 let mut fr = LocaleStrings::new();
661 fr.insert("greeting", "Bonjour");
662 catalog.add_locale("fr", fr);
664
665 catalog.set_fallback_chain(vec!["en".into()]);
666 catalog
667 }
668
669 #[test]
670 fn locale_strings_keys() {
671 let mut ls = LocaleStrings::new();
672 ls.insert("alpha", "A");
673 ls.insert("beta", "B");
674
675 let mut keys: Vec<&str> = ls.keys().collect();
676 keys.sort_unstable();
677 assert_eq!(keys, vec!["alpha", "beta"]);
678 }
679
680 #[test]
681 fn all_keys_is_sorted_and_deduped() {
682 let catalog = multi_locale_catalog();
683 let keys = catalog.all_keys();
684 assert_eq!(keys, vec!["farewell", "greeting", "submit"]);
685 }
686
687 #[test]
688 fn all_keys_empty_catalog() {
689 let catalog = StringCatalog::new();
690 assert!(catalog.all_keys().is_empty());
691 }
692
693 #[test]
694 fn missing_keys_none_missing() {
695 let catalog = multi_locale_catalog();
696 let missing = catalog.missing_keys("en", &["greeting", "farewell", "submit"]);
697 assert!(missing.is_empty());
698 }
699
700 #[test]
701 fn missing_keys_with_fallback() {
702 let catalog = multi_locale_catalog();
703 let missing = catalog.missing_keys("es", &["greeting", "farewell", "submit"]);
704 assert!(missing.is_empty(), "fallback should resolve submit");
705 }
706
707 #[test]
708 fn missing_keys_no_fallback() {
709 let mut catalog = StringCatalog::new();
710 let mut es = LocaleStrings::new();
711 es.insert("greeting", "Hola");
712 catalog.add_locale("es", es);
713 let missing = catalog.missing_keys("es", &["greeting", "farewell"]);
714 assert_eq!(missing, vec!["farewell"]);
715 }
716
717 #[test]
718 fn missing_keys_unknown_locale() {
719 let catalog = multi_locale_catalog();
720 let missing = catalog.missing_keys("de", &["greeting", "farewell", "submit"]);
721 assert!(missing.is_empty(), "fallback to en should cover all");
722 }
723
724 #[test]
725 fn coverage_report_structure() {
726 let catalog = multi_locale_catalog();
727 let report = catalog.coverage_report();
728
729 assert_eq!(report.total_keys, 3);
730 assert_eq!(report.locales.len(), 3);
731
732 let tags: Vec<&str> = report.locales.iter().map(|l| l.locale.as_str()).collect();
733 let mut sorted_tags = tags.clone();
734 sorted_tags.sort_unstable();
735 assert_eq!(tags, sorted_tags);
736 }
737
738 #[test]
739 fn coverage_report_with_fallback() {
740 let catalog = multi_locale_catalog();
741 let report = catalog.coverage_report();
742
743 for lc in &report.locales {
744 assert_eq!(
745 lc.present, 3,
746 "{} should have all 3 keys via fallback",
747 lc.locale
748 );
749 assert!(
750 lc.missing.is_empty(),
751 "{} should have no missing keys via fallback",
752 lc.locale
753 );
754 assert!(
755 (lc.coverage_percent - 100.0).abs() < f32::EPSILON,
756 "{} should be 100% coverage",
757 lc.locale
758 );
759 }
760 }
761
762 #[test]
763 fn coverage_report_without_fallback() {
764 let mut catalog = StringCatalog::new();
765
766 let mut en = LocaleStrings::new();
767 en.insert("a", "A");
768 en.insert("b", "B");
769 en.insert("c", "C");
770 catalog.add_locale("en", en);
771
772 let mut fr = LocaleStrings::new();
773 fr.insert("a", "A-fr");
774 catalog.add_locale("fr", fr);
775
776 let report = catalog.coverage_report();
777 assert_eq!(report.total_keys, 3);
778
779 let en_cov = report.locales.iter().find(|l| l.locale == "en").unwrap();
780 assert_eq!(en_cov.present, 3);
781 assert!(en_cov.missing.is_empty());
782
783 let fr_cov = report.locales.iter().find(|l| l.locale == "fr").unwrap();
784 assert_eq!(fr_cov.present, 1);
785 assert_eq!(fr_cov.missing, vec!["b", "c"]);
786 assert!((fr_cov.coverage_percent - 33.333_332).abs() < 0.01);
787 }
788
789 #[test]
790 fn coverage_report_empty_catalog() {
791 let catalog = StringCatalog::new();
792 let report = catalog.coverage_report();
793 assert_eq!(report.total_keys, 0);
794 assert!(report.locales.is_empty());
795 }
796}