Skip to main content

ftui_i18n/
catalog.rs

1//! String catalog with locale fallback and interpolation.
2//!
3//! # Invariants
4//!
5//! 1. **Fallback chain terminates**: every lookup walks the chain exactly
6//!    once, returning `None` if no locale provides the key.
7//!
8//! 2. **Interpolation is idempotent**: `format()` replaces `{name}` tokens
9//!    using a single pass; nested or recursive substitution does not occur.
10//!
11//! 3. **Thread safety**: `StringCatalog` is `Send + Sync` (all data is
12//!    immutable after construction).
13//!
14//! # Failure Modes
15//!
16//! | Failure | Cause | Behavior |
17//! |---------|-------|----------|
18//! | Missing key | Key not in any locale | Returns `None` |
19//! | Missing locale | Locale not loaded | Falls through chain |
20//! | Bad interpolation arg | `{name}` but no `name` arg | Token left as-is |
21//! | Empty catalog | No locales loaded | All lookups return `None` |
22
23use std::collections::HashMap;
24
25use crate::plural::{PluralCategory, PluralForms, PluralRule};
26
27/// Locale identifier (e.g., `"en"`, `"en-US"`, `"ru"`).
28pub type Locale = String;
29
30/// Errors from i18n operations.
31#[derive(Debug, Clone)]
32pub enum I18nError {
33    /// A locale string was malformed.
34    InvalidLocale(String),
35    /// A catalog file could not be parsed.
36    ParseError(String),
37    /// Duplicate key in the same locale.
38    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/// A single string entry: either a simple string or plural forms.
56#[derive(Debug, Clone)]
57pub enum StringEntry {
58    /// A simple, non-pluralized string.
59    Simple(String),
60    /// Plural forms keyed by CLDR category.
61    Plural(PluralForms),
62}
63
64/// Strings for a single locale.
65#[derive(Debug, Clone, Default)]
66pub struct LocaleStrings {
67    strings: HashMap<String, StringEntry>,
68}
69
70impl LocaleStrings {
71    /// Create an empty locale string set.
72    #[must_use]
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Insert a simple string.
78    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    /// Insert plural forms.
84    pub fn insert_plural(&mut self, key: impl Into<String>, forms: PluralForms) {
85        self.strings.insert(key.into(), StringEntry::Plural(forms));
86    }
87
88    /// Look up a string entry by key.
89    #[must_use]
90    pub fn get(&self, key: &str) -> Option<&StringEntry> {
91        self.strings.get(key)
92    }
93
94    /// Number of entries.
95    #[must_use]
96    pub fn len(&self) -> usize {
97        self.strings.len()
98    }
99
100    /// Whether the locale has no strings.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.strings.is_empty()
104    }
105
106    /// Iterate over all keys in this locale.
107    pub fn keys(&self) -> impl Iterator<Item = &str> {
108        self.strings.keys().map(String::as_str)
109    }
110}
111
112/// Central string catalog with locale fallback and pluralization.
113///
114/// # Example
115///
116/// ```
117/// use ftui_i18n::catalog::{StringCatalog, LocaleStrings};
118/// use ftui_i18n::plural::PluralForms;
119///
120/// let mut catalog = StringCatalog::new();
121///
122/// let mut en = LocaleStrings::new();
123/// en.insert("greeting", "Hello");
124/// en.insert("welcome", "Welcome, {name}!");
125/// en.insert_plural("items", PluralForms {
126///     one: "{count} item".into(),
127///     other: "{count} items".into(),
128///     ..Default::default()
129/// });
130/// catalog.add_locale("en", en);
131/// catalog.set_fallback_chain(vec!["en".into()]);
132///
133/// assert_eq!(catalog.get("en", "greeting"), Some("Hello"));
134/// assert_eq!(
135///     catalog.format("en", "welcome", &[("name", "Alice")]),
136///     Some("Welcome, Alice!".into())
137/// );
138/// assert_eq!(
139///     catalog.get_plural("en", "items", 1),
140///     Some("{count} item")
141/// );
142/// assert_eq!(
143///     catalog.get_plural("en", "items", 5),
144///     Some("{count} items")
145/// );
146/// ```
147#[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    /// Create an empty catalog.
162    #[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    /// Add strings for a locale.
172    ///
173    /// Automatically detects the plural rule based on the locale tag.
174    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    /// Set the fallback chain (tried in order when a key is missing).
182    ///
183    /// Example: `["es-MX", "es", "en"]` — try Mexican Spanish, then
184    /// generic Spanish, then English.
185    pub fn set_fallback_chain(&mut self, chain: Vec<Locale>) {
186        self.fallback_chain = chain;
187    }
188
189    /// Override the plural rule for a locale.
190    pub fn set_plural_rule(&mut self, locale: impl Into<String>, rule: PluralRule) {
191        self.plural_rules.insert(locale.into(), rule);
192    }
193
194    /// Look up a simple string by key.
195    ///
196    /// Tries the specified locale first, then walks the fallback chain.
197    /// Returns `None` if no locale provides the key.
198    #[must_use]
199    pub fn get(&self, locale: &str, key: &str) -> Option<&str> {
200        // Try the specified locale
201        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        // Walk fallback chain
209        for fallback in &self.fallback_chain {
210            if fallback == locale {
211                continue; // Already tried
212            }
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    /// Look up a pluralized string by key and count.
229    ///
230    /// Uses the locale's plural rule to select the appropriate form.
231    #[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        // Try specified locale
241        if let Some(result) = self.get_plural_from(locale, key, category) {
242            return Some(result);
243        }
244
245        // Walk fallback chain
246        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    /// Look up a string and perform `{key}` interpolation.
275    ///
276    /// Each `(name, value)` pair in `args` replaces `{name}` in the
277    /// template string. Tokens without matching args are left as-is.
278    #[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    /// Look up a pluralized string and perform interpolation.
285    ///
286    /// Automatically adds a `{count}` argument.
287    #[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    /// All registered locale tags.
304    #[must_use]
305    pub fn locales(&self) -> Vec<&str> {
306        self.locales.keys().map(String::as_str).collect()
307    }
308
309    // -----------------------------------------------------------------
310    // Extraction & Coverage
311    // -----------------------------------------------------------------
312
313    /// Collect all unique keys across every registered locale.
314    ///
315    /// The result is sorted for deterministic output.
316    #[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    /// Find keys from `reference_keys` that are missing in `locale`
329    /// (including fallback chain resolution).
330    ///
331    /// Returns the missing keys sorted alphabetically.
332    #[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    /// Generate a full coverage report across all locales.
345    ///
346    /// Uses `all_keys()` as the reference set and checks each locale
347    /// (with fallback) for presence.
348    #[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/// Coverage report for a string catalog.
384///
385/// Shows how many keys each locale covers relative to the full key set
386/// and lists the specific missing keys.
387#[derive(Debug, Clone)]
388pub struct CoverageReport {
389    /// Total number of unique keys across all locales.
390    pub total_keys: usize,
391    /// Per-locale coverage data.
392    pub locales: Vec<LocaleCoverage>,
393}
394
395/// Per-locale coverage statistics.
396#[derive(Debug, Clone)]
397pub struct LocaleCoverage {
398    /// Locale tag (e.g., `"en"`, `"ru"`).
399    pub locale: String,
400    /// Number of reference keys present (including via fallback).
401    pub present: usize,
402    /// Keys from the reference set that are missing (even after fallback).
403    pub missing: Vec<String>,
404    /// Coverage as a percentage (0.0–100.0).
405    pub coverage_percent: f32,
406}
407
408/// Single-pass `{name}` interpolation. Unmatched tokens left as-is.
409fn 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            // Try to read a token name until '}'
416            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                // Look up the token in args
428                if let Some(&(_, value)) = args.iter().find(|&&(name, _)| name == placeholder) {
429                    result.push_str(value);
430                } else {
431                    // No match: leave token as-is
432                    result.push('{');
433                    result.push_str(&placeholder);
434                    result.push('}');
435                }
436            } else {
437                // Unclosed brace: emit as-is
438                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        // "fr" not in catalog, falls back to "en"
489        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        // "color" not in es
503
504        let mut es_mx = LocaleStrings::new();
505        es_mx.insert("greeting", "Qué onda");
506        // "color" not in es_mx
507
508        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        // Direct hit
514        assert_eq!(catalog.get("es-MX", "greeting"), Some("Qué onda"));
515        // Falls through es-MX (no color) -> es (no color) -> en
516        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        // Unclosed brace
601        assert_eq!(interpolate("Hello {world", &[]), "Hello {world");
602        // Empty braces
603        assert_eq!(interpolate("Hello {}", &[]), "Hello {}");
604        // No braces
605        assert_eq!(interpolate("Hello World", &[]), "Hello World");
606        // Multiple occurrences
607        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); // greeting, welcome, farewell, items
631        assert!(!en.is_empty());
632    }
633
634    #[test]
635    fn simple_entry_from_plural_lookup() {
636        // Looking up a Simple entry via get_plural should still work
637        let catalog = english_catalog();
638        assert_eq!(catalog.get_plural("en", "greeting", 1), Some("Hello"));
639    }
640
641    // -----------------------------------------------------------------
642    // Extraction & Coverage tests
643    // -----------------------------------------------------------------
644
645    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        // "submit" missing in es
658        catalog.add_locale("es", es);
659
660        let mut fr = LocaleStrings::new();
661        fr.insert("greeting", "Bonjour");
662        // "farewell" and "submit" missing in fr
663        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}