Skip to main content

ferro_lang/
translator.rs

1use std::collections::HashMap;
2
3use crate::error::LangError;
4use crate::interpolation::interpolate;
5use crate::loader::{load_translations, normalize_locale};
6use crate::pluralization::select_plural_form;
7
8/// Core translation engine.
9///
10/// Loads JSON translation files from a directory structure, pre-merges
11/// fallback translations, and provides lookup with interpolation and
12/// pluralization.
13pub struct Translator {
14    translations: HashMap<String, HashMap<String, String>>,
15    fallback: String,
16}
17
18impl Translator {
19    /// Load translations from `{path}/{locale}/*.json` with the given fallback locale.
20    ///
21    /// The fallback locale's keys are pre-merged into every other locale so
22    /// runtime lookup is a single `HashMap::get`.
23    pub fn load(path: impl AsRef<str>, fallback: impl Into<String>) -> Result<Self, LangError> {
24        let fallback = fallback.into();
25        let translations = load_translations(path.as_ref(), &fallback)?;
26        Ok(Self {
27            translations,
28            fallback: normalize_locale(&fallback),
29        })
30    }
31
32    /// Look up a translation key with parameter interpolation.
33    ///
34    /// Returns the translated string with `:param` placeholders replaced.
35    /// If the key is not found, returns the key itself (no panic, no Option).
36    ///
37    /// Resolution order: exact locale → base language (region stripped) →
38    /// configured fallback. The base-language step lets a request for
39    /// `it-IT` resolve against translations loaded under `lang/it/`.
40    pub fn get(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
41        let locale = normalize_locale(locale);
42        let value = self
43            .translations
44            .get(&locale)
45            .and_then(|m| m.get(key))
46            .or_else(|| {
47                base_language(&locale)
48                    .and_then(|base| self.translations.get(base).and_then(|m| m.get(key)))
49            })
50            .or_else(|| {
51                self.translations
52                    .get(&self.fallback)
53                    .and_then(|m| m.get(key))
54            });
55
56        match value {
57            Some(template) => interpolate(template, params),
58            None => {
59                tracing::warn!(locale = %locale, key, "translation key not found");
60                key.to_string()
61            }
62        }
63    }
64
65    /// Look up a pluralized translation key.
66    ///
67    /// Selects the correct plural form from pipe-separated values, then
68    /// applies parameter interpolation. A `:count` parameter is automatically
69    /// added with the string representation of `count`.
70    ///
71    /// Uses the same resolution order as [`Self::get`]: exact → base language →
72    /// configured fallback.
73    pub fn choice(&self, locale: &str, key: &str, count: i64, params: &[(&str, &str)]) -> String {
74        let locale = normalize_locale(locale);
75        let value = self
76            .translations
77            .get(&locale)
78            .and_then(|m| m.get(key))
79            .or_else(|| {
80                base_language(&locale)
81                    .and_then(|base| self.translations.get(base).and_then(|m| m.get(key)))
82            })
83            .or_else(|| {
84                self.translations
85                    .get(&self.fallback)
86                    .and_then(|m| m.get(key))
87            });
88
89        match value {
90            Some(template) => {
91                let form = select_plural_form(template, count);
92                let count_str = count.to_string();
93                let mut all_params: Vec<(&str, &str)> = params.to_vec();
94                all_params.push(("count", &count_str));
95                interpolate(&form, &all_params)
96            }
97            None => {
98                tracing::warn!(locale = %locale, key, "translation key not found");
99                key.to_string()
100            }
101        }
102    }
103
104    /// Check if a translation key exists for the given locale.
105    pub fn has(&self, locale: &str, key: &str) -> bool {
106        let locale = normalize_locale(locale);
107        self.translations
108            .get(&locale)
109            .is_some_and(|m| m.contains_key(key))
110    }
111
112    /// Return all available locale identifiers.
113    pub fn locales(&self) -> Vec<&str> {
114        self.translations.keys().map(|s| s.as_str()).collect()
115    }
116}
117
118/// Strip the region subtag from a normalized locale.
119///
120/// Returns `Some("it")` for `"it-it"`, `Some("zh")` for `"zh-hans-cn"`,
121/// and `None` for bare language tags like `"it"` that have no region.
122fn base_language(locale: &str) -> Option<&str> {
123    locale.split_once('-').map(|(base, _)| base)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::fs;
130    use std::path::{Path, PathBuf};
131    use std::sync::atomic::{AtomicU64, Ordering};
132
133    static COUNTER: AtomicU64 = AtomicU64::new(0);
134
135    /// Create a uniquely-named temp directory per test invocation.
136    fn unique_dir(label: &str) -> PathBuf {
137        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
138        let dir =
139            std::env::temp_dir().join(format!("ferro_lang_{}_{}_{}", label, std::process::id(), n));
140        let _ = fs::remove_dir_all(&dir);
141        dir
142    }
143
144    /// Write the standard en + es fixture into the given directory.
145    fn write_fixtures(dir: &Path) {
146        let en_dir = dir.join("en");
147        fs::create_dir_all(&en_dir).unwrap();
148        fs::write(
149            en_dir.join("messages.json"),
150            serde_json::json!({
151                "welcome": "Welcome, :name!",
152                "items.count": "One item|:count items",
153                "cart.summary": "{0} Your cart is empty|{1} :count item in your cart|[2,*] :count items in your cart",
154                "only_en": "English only"
155            })
156            .to_string(),
157        )
158        .unwrap();
159
160        let es_dir = dir.join("es");
161        fs::create_dir_all(&es_dir).unwrap();
162        fs::write(
163            es_dir.join("messages.json"),
164            serde_json::json!({
165                "welcome": "Bienvenido, :name!",
166                "items.count": "Un elemento|:count elementos"
167            })
168            .to_string(),
169        )
170        .unwrap();
171    }
172
173    fn cleanup(dir: &PathBuf) {
174        let _ = fs::remove_dir_all(dir);
175    }
176
177    #[test]
178    fn load_succeeds() {
179        let dir = unique_dir("load");
180        write_fixtures(&dir);
181        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
182        assert!(t.locales().contains(&"en"));
183        assert!(t.locales().contains(&"es"));
184        cleanup(&dir);
185    }
186
187    #[test]
188    fn get_with_interpolation() {
189        let dir = unique_dir("get_interp");
190        write_fixtures(&dir);
191        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
192        assert_eq!(
193            t.get("en", "welcome", &[("name", "Alice")]),
194            "Welcome, Alice!"
195        );
196        cleanup(&dir);
197    }
198
199    #[test]
200    fn get_returns_key_when_missing() {
201        let dir = unique_dir("get_missing");
202        write_fixtures(&dir);
203        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
204        assert_eq!(t.get("en", "nonexistent.key", &[]), "nonexistent.key");
205        cleanup(&dir);
206    }
207
208    #[test]
209    fn choice_returns_plural_form() {
210        let dir = unique_dir("choice_plural");
211        write_fixtures(&dir);
212        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
213        assert_eq!(t.choice("en", "items.count", 1, &[]), "One item");
214        assert_eq!(t.choice("en", "items.count", 5, &[]), "5 items");
215        cleanup(&dir);
216    }
217
218    #[test]
219    fn choice_auto_adds_count_param() {
220        let dir = unique_dir("choice_count");
221        write_fixtures(&dir);
222        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
223        assert_eq!(t.choice("en", "items.count", 42, &[]), "42 items");
224        cleanup(&dir);
225    }
226
227    #[test]
228    fn choice_explicit_ranges() {
229        let dir = unique_dir("choice_ranges");
230        write_fixtures(&dir);
231        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
232        assert_eq!(t.choice("en", "cart.summary", 0, &[]), "Your cart is empty");
233        assert_eq!(
234            t.choice("en", "cart.summary", 1, &[]),
235            "1 item in your cart"
236        );
237        assert_eq!(
238            t.choice("en", "cart.summary", 3, &[]),
239            "3 items in your cart"
240        );
241        cleanup(&dir);
242    }
243
244    #[test]
245    fn has_returns_correct() {
246        let dir = unique_dir("has");
247        write_fixtures(&dir);
248        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
249        assert!(t.has("en", "welcome"));
250        assert!(!t.has("en", "nonexistent"));
251        cleanup(&dir);
252    }
253
254    #[test]
255    fn locales_returns_all() {
256        let dir = unique_dir("locales");
257        write_fixtures(&dir);
258        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
259        let mut locales = t.locales();
260        locales.sort();
261        assert_eq!(locales, vec!["en", "es"]);
262        cleanup(&dir);
263    }
264
265    #[test]
266    fn fallback_locale_works() {
267        let dir = unique_dir("fallback");
268        write_fixtures(&dir);
269        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
270        // "only_en" exists in en but not es — should be pre-merged into es
271        assert_eq!(t.get("es", "only_en", &[]), "English only");
272        cleanup(&dir);
273    }
274
275    #[test]
276    fn locale_normalization() {
277        let dir = unique_dir("normalization");
278        write_fixtures(&dir);
279
280        let en_us_dir = dir.join("en_US");
281        fs::create_dir_all(&en_us_dir).unwrap();
282        fs::write(
283            en_us_dir.join("messages.json"),
284            serde_json::json!({
285                "greeting": "Hey!"
286            })
287            .to_string(),
288        )
289        .unwrap();
290
291        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
292        assert!(t.has("en-us", "greeting"));
293        assert!(t.has("en_US", "greeting"));
294        assert!(t.has("EN_US", "greeting"));
295        cleanup(&dir);
296    }
297
298    #[test]
299    fn nested_json_flattened() {
300        let dir = unique_dir("nested");
301        write_fixtures(&dir);
302
303        let fr_dir = dir.join("fr");
304        fs::create_dir_all(&fr_dir).unwrap();
305        fs::write(
306            fr_dir.join("auth.json"),
307            serde_json::json!({
308                "auth": {
309                    "login": "Connexion",
310                    "register": "Inscription"
311                }
312            })
313            .to_string(),
314        )
315        .unwrap();
316
317        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
318        assert_eq!(t.get("fr", "auth.login", &[]), "Connexion");
319        assert_eq!(t.get("fr", "auth.register", &[]), "Inscription");
320        cleanup(&dir);
321    }
322
323    #[test]
324    fn empty_dir_errors() {
325        let dir = unique_dir("empty");
326        fs::create_dir_all(&dir).unwrap();
327        let result = Translator::load(dir.to_str().unwrap(), "en");
328        assert!(result.is_err());
329        cleanup(&dir);
330    }
331
332    // ── regional locale → base language fallback ──────────────────────
333    //
334    // Mobile browsers virtually always send region-tagged Accept-Language
335    // (e.g. `it-IT`, `en-US`), but translation files are typically loaded
336    // under base-language directories (`lang/it/`, `lang/en/`). The
337    // translator must try the base language before falling through to
338    // the configured fallback locale, otherwise every mobile user sees
339    // the fallback locale's strings regardless of device language.
340
341    #[test]
342    fn get_regional_locale_falls_back_to_base_language() {
343        let dir = unique_dir("regional_base");
344        write_fixtures(&dir);
345        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
346        // Request "es-MX" — no es-MX dir exists, but "es" does.
347        // Must return Spanish, not English (the configured fallback).
348        assert_eq!(
349            t.get("es-MX", "welcome", &[("name", "Ana")]),
350            "Bienvenido, Ana!"
351        );
352        cleanup(&dir);
353    }
354
355    #[test]
356    fn get_regional_locale_prefers_exact_match_over_base() {
357        let dir = unique_dir("regional_exact");
358        write_fixtures(&dir);
359        // Add an exact es-MX dir with a different greeting
360        let es_mx = dir.join("es-mx");
361        fs::create_dir_all(&es_mx).unwrap();
362        fs::write(
363            es_mx.join("messages.json"),
364            serde_json::json!({"welcome": "¡Qué onda, :name!"}).to_string(),
365        )
366        .unwrap();
367        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
368        // Exact es-mx match wins over base "es"
369        assert_eq!(
370            t.get("es-MX", "welcome", &[("name", "Ana")]),
371            "¡Qué onda, Ana!"
372        );
373        cleanup(&dir);
374    }
375
376    #[test]
377    fn get_regional_locale_uses_global_fallback_when_no_base() {
378        let dir = unique_dir("regional_no_base");
379        write_fixtures(&dir);
380        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
381        // Request "fr-CA" — no fr-CA, no fr loaded; falls through to "en".
382        assert_eq!(
383            t.get("fr-CA", "welcome", &[("name", "Léa")]),
384            "Welcome, Léa!"
385        );
386        cleanup(&dir);
387    }
388
389    #[test]
390    fn choice_regional_locale_falls_back_to_base_language() {
391        let dir = unique_dir("regional_choice");
392        write_fixtures(&dir);
393        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
394        // es has its own plural form; es-MX request should reach it
395        // via base-language fallback, not slip through to English.
396        assert_eq!(t.choice("es-MX", "items.count", 5, &[]), "5 elementos");
397        cleanup(&dir);
398    }
399
400    #[test]
401    fn get_underscore_regional_input_falls_back_to_base() {
402        let dir = unique_dir("regional_underscore");
403        write_fixtures(&dir);
404        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
405        // Accept-Language hyphens are the common form, but the API also
406        // accepts underscore form. Both must normalize and fall back.
407        assert_eq!(
408            t.get("es_MX", "welcome", &[("name", "Ana")]),
409            "Bienvenido, Ana!"
410        );
411        cleanup(&dir);
412    }
413
414    #[test]
415    fn base_language_helper() {
416        assert_eq!(super::base_language("it-it"), Some("it"));
417        assert_eq!(super::base_language("zh-hans-cn"), Some("zh"));
418        assert_eq!(super::base_language("it"), None);
419        assert_eq!(super::base_language(""), None);
420    }
421}