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