Skip to main content

modo/i18n/
store.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules};
6use unic_langid::LanguageIdentifier;
7
8#[derive(Debug, Clone)]
9pub(super) enum Entry {
10    Plain(String),
11    Plural {
12        zero: Option<String>,
13        one: Option<String>,
14        two: Option<String>,
15        few: Option<String>,
16        many: Option<String>,
17        other: String,
18    },
19}
20
21struct Inner {
22    translations: HashMap<String, HashMap<String, Entry>>,
23    default_locale: String,
24    plural_rules: HashMap<String, PluralRules>,
25    /// English cardinal plural rules used as a fallback when the requested
26    /// locale has no loaded entry in `plural_rules`. Built once during
27    /// construction so `translate_plural` for unknown locales does not
28    /// allocate a new `PluralRules` on every call.
29    fallback_plural_rules: PluralRules,
30}
31
32/// In-memory store of translation entries loaded from YAML files on disk.
33///
34/// Cheaply cloneable — wraps an `Arc` internally. Created by [`TranslationStore::load`].
35/// Used by the [`Translator`](super::Translator) extractor and the template
36/// engine's `t()` function (registered by
37/// [`make_t_function`](super::make_t_function)).
38#[derive(Clone)]
39pub struct TranslationStore {
40    inner: Arc<Inner>,
41}
42
43impl std::fmt::Debug for TranslationStore {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("TranslationStore")
46            .field("translations", &self.inner.translations)
47            .field("default_locale", &self.inner.default_locale)
48            .field(
49                "plural_rules",
50                &self.inner.plural_rules.keys().collect::<Vec<_>>(),
51            )
52            .finish()
53    }
54}
55
56impl TranslationStore {
57    /// Creates an empty store with no translations loaded.
58    ///
59    /// Translations fall back to the key itself when nothing is loaded. Plural
60    /// rules are populated lazily as translations are loaded, so an empty
61    /// store holds no plural-rule entries.
62    pub(super) fn empty(default_locale: &str) -> Self {
63        let en: LanguageIdentifier = "en".parse().expect("en is a valid language tag");
64        let fallback_plural_rules = PluralRules::create(en, PluralRuleType::CARDINAL)
65            .expect("en plural rules creation cannot fail");
66        Self {
67            inner: Arc::new(Inner {
68                translations: HashMap::new(),
69                default_locale: default_locale.to_string(),
70                plural_rules: HashMap::new(),
71                fallback_plural_rules,
72            }),
73        }
74    }
75
76    /// Loads translations from the given directory.
77    ///
78    /// Each subdirectory of `path` is treated as a locale. YAML/YML files inside
79    /// become namespaces whose keys are flattened with `.` separators.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error`](crate::Error) if the directory is unreadable or a YAML
84    /// file cannot be parsed.
85    pub fn load(path: &Path, default_locale: &str) -> crate::Result<Self> {
86        let mut translations: HashMap<String, HashMap<String, Entry>> = HashMap::new();
87
88        let entries = std::fs::read_dir(path).map_err(|e| {
89            crate::Error::internal(format!(
90                "Failed to read locales directory {}: {e}",
91                path.display()
92            ))
93        })?;
94
95        for entry in entries {
96            let entry = entry.map_err(|e| {
97                crate::Error::internal(format!("Failed to read directory entry: {e}"))
98            })?;
99            let locale_path = entry.path();
100            if !locale_path.is_dir() {
101                continue;
102            }
103
104            let Some(locale) = locale_path.file_name().and_then(|n| n.to_str()) else {
105                tracing::warn!(
106                    path = %locale_path.display(),
107                    "skipping non-UTF-8 locale directory name"
108                );
109                continue;
110            };
111
112            let locale_entries = load_locale_dir(&locale_path)?;
113            translations.insert(locale.to_string(), locale_entries);
114        }
115
116        let en: LanguageIdentifier = "en".parse().expect("en is a valid language tag");
117        let en_rules = PluralRules::create(en, PluralRuleType::CARDINAL)
118            .expect("en plural rules creation cannot fail");
119        let mut plural_rules = HashMap::new();
120        for locale_str in translations.keys() {
121            let Ok(lang_id) = locale_str.parse::<LanguageIdentifier>() else {
122                tracing::warn!(
123                    locale = %locale_str,
124                    "failed to parse locale as language identifier — plural rules will use English fallback"
125                );
126                continue;
127            };
128            let Ok(rules) = PluralRules::create(lang_id, PluralRuleType::CARDINAL) else {
129                tracing::warn!(
130                    locale = %locale_str,
131                    "failed to create plural rules for locale — plural rules will use English fallback"
132                );
133                continue;
134            };
135            plural_rules.insert(locale_str.clone(), rules);
136        }
137
138        Ok(Self {
139            inner: Arc::new(Inner {
140                translations,
141                default_locale: default_locale.to_string(),
142                plural_rules,
143                fallback_plural_rules: en_rules,
144            }),
145        })
146    }
147
148    /// Translates `key` for the given `locale`, interpolating any `{placeholder}`
149    /// values found in `kwargs`.
150    ///
151    /// Falls back to the default locale and finally to the key itself if no entry
152    /// is found.
153    pub fn translate(
154        &self,
155        locale: &str,
156        key: &str,
157        kwargs: &[(&str, &str)],
158    ) -> crate::Result<String> {
159        // Try requested locale first
160        if let Some(entry) = self.lookup(locale, key) {
161            return Ok(interpolate(entry_to_string(entry), kwargs));
162        }
163
164        // Fall back to default locale
165        if locale != self.inner.default_locale
166            && let Some(entry) = self.lookup(&self.inner.default_locale, key)
167        {
168            return Ok(interpolate(entry_to_string(entry), kwargs));
169        }
170
171        // Return key itself as fallback
172        Ok(key.to_string())
173    }
174
175    /// Translates `key` with plural-rule selection based on `count`.
176    ///
177    /// `count` is also injected into `kwargs` under the name `count`.
178    ///
179    /// # Cross-locale fallback
180    ///
181    /// When an entry is missing in the requested locale, the default locale's
182    /// entry is used. Plural rule selection still uses the **requesting**
183    /// locale's rules (e.g., Ukrainian `FEW` / `MANY` categories applied
184    /// against English `one` / `other` forms map to `other`). This keeps
185    /// grammatical selection consistent with the user's language even though
186    /// the fallback copy is authored for a different one.
187    pub fn translate_plural(
188        &self,
189        locale: &str,
190        key: &str,
191        count: i64,
192        kwargs: &[(&str, &str)],
193    ) -> crate::Result<String> {
194        let entry = self.lookup(locale, key).or_else(|| {
195            if locale != self.inner.default_locale {
196                self.lookup(&self.inner.default_locale, key)
197            } else {
198                None
199            }
200        });
201
202        let Some(entry) = entry else {
203            return Ok(key.to_string());
204        };
205
206        let template = match entry {
207            Entry::Plural {
208                zero,
209                one,
210                two,
211                few,
212                many,
213                other,
214            } => {
215                let category = self.plural_category(locale, count);
216                match category {
217                    PluralCategory::ZERO => zero.as_deref().unwrap_or(other),
218                    PluralCategory::ONE => one.as_deref().unwrap_or(other),
219                    PluralCategory::TWO => two.as_deref().unwrap_or(other),
220                    PluralCategory::FEW => few.as_deref().unwrap_or(other),
221                    PluralCategory::MANY => many.as_deref().unwrap_or(other),
222                    PluralCategory::OTHER => other,
223                }
224            }
225            Entry::Plain(s) => s,
226        };
227
228        // Add count to kwargs
229        let count_str = count.to_string();
230        let mut all_kwargs: Vec<(&str, &str)> = kwargs.to_vec();
231        all_kwargs.push(("count", &count_str));
232
233        Ok(interpolate(template, &all_kwargs))
234    }
235
236    /// Returns the list of locales discovered on disk (unordered).
237    pub fn available_locales(&self) -> Vec<String> {
238        self.inner.translations.keys().cloned().collect()
239    }
240
241    /// Returns the configured default locale.
242    pub fn default_locale(&self) -> &str {
243        &self.inner.default_locale
244    }
245
246    fn lookup(&self, locale: &str, key: &str) -> Option<&Entry> {
247        self.inner.translations.get(locale)?.get(key)
248    }
249
250    fn plural_category(&self, locale: &str, count: i64) -> PluralCategory {
251        let abs_count = count.unsigned_abs() as usize;
252        if let Some(rules) = self.inner.plural_rules.get(locale) {
253            rules.select(abs_count).unwrap_or(PluralCategory::OTHER)
254        } else {
255            // Fallback to cached English rules for unknown locales.
256            self.inner
257                .fallback_plural_rules
258                .select(abs_count)
259                .unwrap_or(PluralCategory::OTHER)
260        }
261    }
262}
263
264pub(super) fn entry_to_string(entry: &Entry) -> &str {
265    match entry {
266        Entry::Plain(s) => s,
267        Entry::Plural { other, .. } => other,
268    }
269}
270
271pub(super) fn interpolate(template: &str, kwargs: &[(&str, &str)]) -> String {
272    let mut result = String::with_capacity(template.len());
273    let mut chars = template.chars().peekable();
274
275    while let Some(ch) = chars.next() {
276        if ch == '{' {
277            // Try to read a key
278            let mut key = String::new();
279            let mut found_close = false;
280            for next_ch in chars.by_ref() {
281                if next_ch == '}' {
282                    found_close = true;
283                    break;
284                }
285                key.push(next_ch);
286            }
287
288            if found_close && !key.is_empty() {
289                // Look up the key in kwargs
290                if let Some((_, val)) = kwargs.iter().find(|(k, _)| *k == key) {
291                    result.push_str(val);
292                } else {
293                    // Leave unmatched placeholders as-is
294                    result.push('{');
295                    result.push_str(&key);
296                    result.push('}');
297                }
298            } else {
299                result.push('{');
300                result.push_str(&key);
301            }
302        } else {
303            result.push(ch);
304        }
305    }
306
307    result
308}
309
310pub(super) fn load_locale_dir(locale_path: &Path) -> crate::Result<HashMap<String, Entry>> {
311    let mut entries = HashMap::new();
312
313    let dir_entries = std::fs::read_dir(locale_path).map_err(|e| {
314        crate::Error::internal(format!(
315            "Failed to read locale directory {}: {e}",
316            locale_path.display()
317        ))
318    })?;
319
320    for entry in dir_entries {
321        let entry = entry
322            .map_err(|e| crate::Error::internal(format!("Failed to read directory entry: {e}")))?;
323        let path = entry.path();
324
325        let ext = path.extension().and_then(|e| e.to_str());
326        if ext != Some("yaml") && ext != Some("yml") {
327            continue;
328        }
329
330        let Some(namespace) = path.file_stem().and_then(|n| n.to_str()) else {
331            tracing::warn!(
332                path = %path.display(),
333                "skipping non-UTF-8 translation file name"
334            );
335            continue;
336        };
337        let namespace = namespace.to_string();
338
339        let content = std::fs::read_to_string(&path).map_err(|e| {
340            crate::Error::internal(format!("Failed to read {}: {e}", path.display()))
341        })?;
342
343        let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).map_err(|e| {
344            crate::Error::internal(format!("Failed to parse {}: {e}", path.display()))
345        })?;
346
347        flatten_yaml(&namespace, &value, &mut entries);
348    }
349
350    Ok(entries)
351}
352
353pub(super) fn flatten_yaml(
354    prefix: &str,
355    value: &serde_yaml_ng::Value,
356    entries: &mut HashMap<String, Entry>,
357) {
358    match value {
359        serde_yaml_ng::Value::Mapping(map) => {
360            // Check if this is a plural entry (has "other" key)
361            if is_plural_entry(map) {
362                let other = map
363                    .get(serde_yaml_ng::Value::String("other".into()))
364                    .and_then(|v| v.as_str())
365                    .unwrap_or("")
366                    .to_string();
367
368                let entry = Entry::Plural {
369                    zero: get_str(map, "zero"),
370                    one: get_str(map, "one"),
371                    two: get_str(map, "two"),
372                    few: get_str(map, "few"),
373                    many: get_str(map, "many"),
374                    other,
375                };
376
377                entries.insert(prefix.to_string(), entry);
378                return;
379            }
380
381            // Regular nested map — recurse
382            for (k, v) in map {
383                if let Some(key_str) = k.as_str() {
384                    let full_key = format!("{prefix}.{key_str}");
385                    flatten_yaml(&full_key, v, entries);
386                }
387            }
388        }
389        serde_yaml_ng::Value::String(s) => {
390            entries.insert(prefix.to_string(), Entry::Plain(s.clone()));
391        }
392        other => {
393            tracing::warn!(
394                key = %prefix,
395                ?other,
396                "translation value is not a string or mapping — ignored"
397            );
398        }
399    }
400}
401
402fn is_plural_entry(map: &serde_yaml_ng::Mapping) -> bool {
403    let has_other = map.contains_key(serde_yaml_ng::Value::String("other".into()));
404    if !has_other {
405        return false;
406    }
407
408    // All keys must be plural category names
409    let plural_keys = ["zero", "one", "two", "few", "many", "other"];
410    map.keys()
411        .all(|k| k.as_str().is_some_and(|s| plural_keys.contains(&s)))
412}
413
414fn get_str(map: &serde_yaml_ng::Mapping, key: &str) -> Option<String> {
415    map.get(serde_yaml_ng::Value::String(key.into()))
416        .and_then(|v| v.as_str())
417        .map(|s| s.to_string())
418}
419
420/// Creates a MiniJinja-compatible `t()` function that reads the `locale` variable
421/// from the template context and delegates to the `TranslationStore`.
422pub fn make_t_function(
423    store: TranslationStore,
424) -> impl Fn(
425    &minijinja::State,
426    &[minijinja::Value],
427    minijinja::value::Kwargs,
428) -> Result<String, minijinja::Error>
429+ Send
430+ Sync
431+ 'static {
432    move |state: &minijinja::State, args: &[minijinja::Value], kwargs: minijinja::value::Kwargs| {
433        let key = args.first().ok_or_else(|| {
434            minijinja::Error::new(
435                minijinja::ErrorKind::MissingArgument,
436                "t() requires a translation key",
437            )
438        })?;
439        let key = key.to_string();
440
441        // Read locale from template context
442        let locale = state
443            .lookup("locale")
444            .and_then(|v| {
445                let s = v.to_string();
446                if s.is_empty() { None } else { Some(s) }
447            })
448            .unwrap_or_else(|| store.default_locale().to_string());
449
450        // Check for count kwarg (plural)
451        let count: Option<i64> = kwargs.get("count").ok();
452
453        // Collect all kwargs for interpolation
454        let mut kw_pairs: Vec<(String, String)> = Vec::new();
455        for k in kwargs.args() {
456            if let Ok(v) = kwargs.get::<minijinja::Value>(k) {
457                kw_pairs.push((k.to_string(), v.to_string()));
458            }
459        }
460
461        let kw_refs: Vec<(&str, &str)> = kw_pairs
462            .iter()
463            .map(|(k, v)| (k.as_str(), v.as_str()))
464            .collect();
465
466        let result = if let Some(count) = count {
467            store
468                .translate_plural(&locale, &key, count, &kw_refs)
469                .map_err(|e| {
470                    minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
471                })?
472        } else {
473            store.translate(&locale, &key, &kw_refs).map_err(|e| {
474                minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
475            })?
476        };
477
478        // Consume all kwargs to avoid "unexpected keyword argument" errors.
479        // Surface unused kwargs via tracing so typos are visible during
480        // development without breaking template rendering.
481        if let Err(e) = kwargs.assert_all_used() {
482            tracing::warn!(key = %key, error = %e, "unused template kwargs in t() call");
483        }
484
485        Ok(result)
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use std::path::Path;
493
494    fn write_locale_file(dir: &Path, locale: &str, filename: &str, content: &str) {
495        let locale_dir = dir.join(locale);
496        std::fs::create_dir_all(&locale_dir).unwrap();
497        std::fs::write(locale_dir.join(filename), content).unwrap();
498    }
499
500    fn test_store(dir: &Path) -> TranslationStore {
501        TranslationStore::load(dir, "en").unwrap()
502    }
503
504    #[test]
505    fn load_plain_translations() {
506        let dir = tempfile::tempdir().unwrap();
507        write_locale_file(
508            dir.path(),
509            "en",
510            "common.yaml",
511            "greeting: Hello\nbye: Goodbye",
512        );
513        let store = test_store(dir.path());
514        assert_eq!(
515            store.translate("en", "common.greeting", &[]).unwrap(),
516            "Hello"
517        );
518        assert_eq!(store.translate("en", "common.bye", &[]).unwrap(), "Goodbye");
519    }
520
521    #[test]
522    fn load_nested_keys() {
523        let dir = tempfile::tempdir().unwrap();
524        write_locale_file(
525            dir.path(),
526            "en",
527            "auth.yaml",
528            "login:\n  title: \"Log In\"\n  submit: Submit",
529        );
530        let store = test_store(dir.path());
531        assert_eq!(
532            store.translate("en", "auth.login.title", &[]).unwrap(),
533            "Log In"
534        );
535        assert_eq!(
536            store.translate("en", "auth.login.submit", &[]).unwrap(),
537            "Submit"
538        );
539    }
540
541    #[test]
542    fn interpolation_replaces_placeholders() {
543        let dir = tempfile::tempdir().unwrap();
544        write_locale_file(
545            dir.path(),
546            "en",
547            "greet.yaml",
548            "welcome: \"Hello, {name}! Age: {age}\"",
549        );
550        let store = test_store(dir.path());
551        let result = store
552            .translate("en", "greet.welcome", &[("name", "Dmytro"), ("age", "30")])
553            .unwrap();
554        assert_eq!(result, "Hello, Dmytro! Age: 30");
555    }
556
557    #[test]
558    fn interpolation_leaves_unmatched_placeholders() {
559        let dir = tempfile::tempdir().unwrap();
560        write_locale_file(
561            dir.path(),
562            "en",
563            "test.yaml",
564            "msg: \"Hello {name}, {missing}\"",
565        );
566        let store = test_store(dir.path());
567        let result = store
568            .translate("en", "test.msg", &[("name", "Dmytro")])
569            .unwrap();
570        assert_eq!(result, "Hello Dmytro, {missing}");
571    }
572
573    #[test]
574    fn plural_english_one_other() {
575        let dir = tempfile::tempdir().unwrap();
576        write_locale_file(
577            dir.path(),
578            "en",
579            "items.yaml",
580            "count:\n  one: \"{count} item\"\n  other: \"{count} items\"",
581        );
582        let store = test_store(dir.path());
583        assert_eq!(
584            store.translate_plural("en", "items.count", 1, &[]).unwrap(),
585            "1 item"
586        );
587        assert_eq!(
588            store.translate_plural("en", "items.count", 0, &[]).unwrap(),
589            "0 items"
590        );
591        assert_eq!(
592            store.translate_plural("en", "items.count", 5, &[]).unwrap(),
593            "5 items"
594        );
595    }
596
597    #[test]
598    fn plural_falls_back_to_other() {
599        let dir = tempfile::tempdir().unwrap();
600        write_locale_file(
601            dir.path(),
602            "en",
603            "items.yaml",
604            "count:\n  other: \"{count} things\"",
605        );
606        let store = test_store(dir.path());
607        assert_eq!(
608            store.translate_plural("en", "items.count", 1, &[]).unwrap(),
609            "1 things"
610        );
611    }
612
613    #[test]
614    fn falls_back_to_default_locale() {
615        let dir = tempfile::tempdir().unwrap();
616        write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
617        write_locale_file(dir.path(), "uk", "common.yaml", "bye: Бувай");
618        let store = test_store(dir.path());
619        // "uk" doesn't have "common.greeting", falls back to "en"
620        assert_eq!(
621            store.translate("uk", "common.greeting", &[]).unwrap(),
622            "Hello"
623        );
624    }
625
626    #[test]
627    fn missing_key_returns_key_itself() {
628        let dir = tempfile::tempdir().unwrap();
629        write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
630        let store = test_store(dir.path());
631        assert_eq!(
632            store.translate("en", "nonexistent.key", &[]).unwrap(),
633            "nonexistent.key"
634        );
635    }
636
637    #[test]
638    fn missing_locale_falls_back_to_default() {
639        let dir = tempfile::tempdir().unwrap();
640        write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
641        let store = test_store(dir.path());
642        assert_eq!(
643            store.translate("fr", "common.greeting", &[]).unwrap(),
644            "Hello"
645        );
646    }
647
648    #[test]
649    fn load_returns_error_on_missing_directory() {
650        let result = TranslationStore::load(Path::new("/nonexistent/path"), "en");
651        assert!(result.is_err());
652    }
653
654    #[test]
655    fn plural_slavic_rules_ukrainian() {
656        let dir = tempfile::tempdir().unwrap();
657        let uk_dir = dir.path().join("uk");
658        std::fs::create_dir_all(&uk_dir).unwrap();
659        std::fs::write(
660            uk_dir.join("items.yaml"),
661            "count:\n  one: \"{count} елемент\"\n  few: \"{count} елементи\"\n  many: \"{count} елементів\"\n  other: \"{count} елементів\"",
662        )
663        .unwrap();
664        let en_dir = dir.path().join("en");
665        std::fs::create_dir_all(&en_dir).unwrap();
666        std::fs::write(
667            en_dir.join("items.yaml"),
668            "count:\n  one: \"{count} item\"\n  other: \"{count} items\"",
669        )
670        .unwrap();
671
672        let store = TranslationStore::load(dir.path(), "en").unwrap();
673        assert_eq!(
674            store.translate_plural("uk", "items.count", 1, &[]).unwrap(),
675            "1 елемент"
676        );
677        assert_eq!(
678            store.translate_plural("uk", "items.count", 3, &[]).unwrap(),
679            "3 елементи"
680        );
681        assert_eq!(
682            store.translate_plural("uk", "items.count", 5, &[]).unwrap(),
683            "5 елементів"
684        );
685        assert_eq!(
686            store
687                .translate_plural("uk", "items.count", 21, &[])
688                .unwrap(),
689            "21 елемент"
690        );
691        assert_eq!(
692            store
693                .translate_plural("uk", "items.count", 22, &[])
694                .unwrap(),
695            "22 елементи"
696        );
697    }
698
699    #[test]
700    fn translate_plural_negative_count() {
701        let dir = tempfile::tempdir().unwrap();
702        write_locale_file(
703            dir.path(),
704            "en",
705            "items.yaml",
706            "count:\n  one: \"{count} item\"\n  other: \"{count} items\"",
707        );
708        let store = TranslationStore::load(dir.path(), "en").unwrap();
709        // Negative counts use absolute value for plural category selection,
710        // but {count} interpolates the original signed value.
711        assert_eq!(
712            store
713                .translate_plural("en", "items.count", -1, &[])
714                .unwrap(),
715            "-1 item"
716        );
717        assert_eq!(
718            store
719                .translate_plural("en", "items.count", -5, &[])
720                .unwrap(),
721            "-5 items"
722        );
723    }
724
725    #[test]
726    fn yml_extension_support() {
727        let dir = tempfile::tempdir().unwrap();
728        let en_dir = dir.path().join("en");
729        std::fs::create_dir_all(&en_dir).unwrap();
730        std::fs::write(en_dir.join("messages.yml"), "hello: Hi there").unwrap();
731        let store = TranslationStore::load(dir.path(), "en").unwrap();
732        assert_eq!(
733            store.translate("en", "messages.hello", &[]).unwrap(),
734            "Hi there"
735        );
736    }
737
738    #[test]
739    fn t_function_with_count_kwarg() {
740        let dir = tempfile::tempdir().unwrap();
741        write_locale_file(
742            dir.path(),
743            "en",
744            "items.yaml",
745            "count:\n  one: \"{count} item\"\n  other: \"{count} items\"",
746        );
747        let store = TranslationStore::load(dir.path(), "en").unwrap();
748
749        let mut env = minijinja::Environment::new();
750        let t_fn = make_t_function(store);
751        env.add_function("t", t_fn);
752        env.add_template("test", "{{ t('items.count', count=5) }}")
753            .unwrap();
754
755        let tmpl = env.get_template("test").unwrap();
756        let result = tmpl.render(minijinja::context! { locale => "en" }).unwrap();
757        assert_eq!(result, "5 items");
758    }
759}