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    pub fn get(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
37        let locale = normalize_locale(locale);
38        let value = self
39            .translations
40            .get(&locale)
41            .and_then(|m| m.get(key))
42            .or_else(|| {
43                self.translations
44                    .get(&self.fallback)
45                    .and_then(|m| m.get(key))
46            });
47
48        match value {
49            Some(template) => interpolate(template, params),
50            None => {
51                tracing::warn!(locale = %locale, key, "translation key not found");
52                key.to_string()
53            }
54        }
55    }
56
57    /// Look up a pluralized translation key.
58    ///
59    /// Selects the correct plural form from pipe-separated values, then
60    /// applies parameter interpolation. A `:count` parameter is automatically
61    /// added with the string representation of `count`.
62    pub fn choice(&self, locale: &str, key: &str, count: i64, params: &[(&str, &str)]) -> String {
63        let locale = normalize_locale(locale);
64        let value = self
65            .translations
66            .get(&locale)
67            .and_then(|m| m.get(key))
68            .or_else(|| {
69                self.translations
70                    .get(&self.fallback)
71                    .and_then(|m| m.get(key))
72            });
73
74        match value {
75            Some(template) => {
76                let form = select_plural_form(template, count);
77                let count_str = count.to_string();
78                let mut all_params: Vec<(&str, &str)> = params.to_vec();
79                all_params.push(("count", &count_str));
80                interpolate(&form, &all_params)
81            }
82            None => {
83                tracing::warn!(locale = %locale, key, "translation key not found");
84                key.to_string()
85            }
86        }
87    }
88
89    /// Check if a translation key exists for the given locale.
90    pub fn has(&self, locale: &str, key: &str) -> bool {
91        let locale = normalize_locale(locale);
92        self.translations
93            .get(&locale)
94            .is_some_and(|m| m.contains_key(key))
95    }
96
97    /// Return all available locale identifiers.
98    pub fn locales(&self) -> Vec<&str> {
99        self.translations.keys().map(|s| s.as_str()).collect()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::fs;
107    use std::path::{Path, PathBuf};
108    use std::sync::atomic::{AtomicU64, Ordering};
109
110    static COUNTER: AtomicU64 = AtomicU64::new(0);
111
112    /// Create a uniquely-named temp directory per test invocation.
113    fn unique_dir(label: &str) -> PathBuf {
114        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
115        let dir =
116            std::env::temp_dir().join(format!("ferro_lang_{}_{}_{}", label, std::process::id(), n));
117        let _ = fs::remove_dir_all(&dir);
118        dir
119    }
120
121    /// Write the standard en + es fixture into the given directory.
122    fn write_fixtures(dir: &Path) {
123        let en_dir = dir.join("en");
124        fs::create_dir_all(&en_dir).unwrap();
125        fs::write(
126            en_dir.join("messages.json"),
127            serde_json::json!({
128                "welcome": "Welcome, :name!",
129                "items.count": "One item|:count items",
130                "cart.summary": "{0} Your cart is empty|{1} :count item in your cart|[2,*] :count items in your cart",
131                "only_en": "English only"
132            })
133            .to_string(),
134        )
135        .unwrap();
136
137        let es_dir = dir.join("es");
138        fs::create_dir_all(&es_dir).unwrap();
139        fs::write(
140            es_dir.join("messages.json"),
141            serde_json::json!({
142                "welcome": "Bienvenido, :name!",
143                "items.count": "Un elemento|:count elementos"
144            })
145            .to_string(),
146        )
147        .unwrap();
148    }
149
150    fn cleanup(dir: &PathBuf) {
151        let _ = fs::remove_dir_all(dir);
152    }
153
154    #[test]
155    fn load_succeeds() {
156        let dir = unique_dir("load");
157        write_fixtures(&dir);
158        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
159        assert!(t.locales().contains(&"en"));
160        assert!(t.locales().contains(&"es"));
161        cleanup(&dir);
162    }
163
164    #[test]
165    fn get_with_interpolation() {
166        let dir = unique_dir("get_interp");
167        write_fixtures(&dir);
168        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
169        assert_eq!(
170            t.get("en", "welcome", &[("name", "Alice")]),
171            "Welcome, Alice!"
172        );
173        cleanup(&dir);
174    }
175
176    #[test]
177    fn get_returns_key_when_missing() {
178        let dir = unique_dir("get_missing");
179        write_fixtures(&dir);
180        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
181        assert_eq!(t.get("en", "nonexistent.key", &[]), "nonexistent.key");
182        cleanup(&dir);
183    }
184
185    #[test]
186    fn choice_returns_plural_form() {
187        let dir = unique_dir("choice_plural");
188        write_fixtures(&dir);
189        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
190        assert_eq!(t.choice("en", "items.count", 1, &[]), "One item");
191        assert_eq!(t.choice("en", "items.count", 5, &[]), "5 items");
192        cleanup(&dir);
193    }
194
195    #[test]
196    fn choice_auto_adds_count_param() {
197        let dir = unique_dir("choice_count");
198        write_fixtures(&dir);
199        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
200        assert_eq!(t.choice("en", "items.count", 42, &[]), "42 items");
201        cleanup(&dir);
202    }
203
204    #[test]
205    fn choice_explicit_ranges() {
206        let dir = unique_dir("choice_ranges");
207        write_fixtures(&dir);
208        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
209        assert_eq!(t.choice("en", "cart.summary", 0, &[]), "Your cart is empty");
210        assert_eq!(
211            t.choice("en", "cart.summary", 1, &[]),
212            "1 item in your cart"
213        );
214        assert_eq!(
215            t.choice("en", "cart.summary", 3, &[]),
216            "3 items in your cart"
217        );
218        cleanup(&dir);
219    }
220
221    #[test]
222    fn has_returns_correct() {
223        let dir = unique_dir("has");
224        write_fixtures(&dir);
225        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
226        assert!(t.has("en", "welcome"));
227        assert!(!t.has("en", "nonexistent"));
228        cleanup(&dir);
229    }
230
231    #[test]
232    fn locales_returns_all() {
233        let dir = unique_dir("locales");
234        write_fixtures(&dir);
235        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
236        let mut locales = t.locales();
237        locales.sort();
238        assert_eq!(locales, vec!["en", "es"]);
239        cleanup(&dir);
240    }
241
242    #[test]
243    fn fallback_locale_works() {
244        let dir = unique_dir("fallback");
245        write_fixtures(&dir);
246        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
247        // "only_en" exists in en but not es — should be pre-merged into es
248        assert_eq!(t.get("es", "only_en", &[]), "English only");
249        cleanup(&dir);
250    }
251
252    #[test]
253    fn locale_normalization() {
254        let dir = unique_dir("normalization");
255        write_fixtures(&dir);
256
257        let en_us_dir = dir.join("en_US");
258        fs::create_dir_all(&en_us_dir).unwrap();
259        fs::write(
260            en_us_dir.join("messages.json"),
261            serde_json::json!({
262                "greeting": "Hey!"
263            })
264            .to_string(),
265        )
266        .unwrap();
267
268        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
269        assert!(t.has("en-us", "greeting"));
270        assert!(t.has("en_US", "greeting"));
271        assert!(t.has("EN_US", "greeting"));
272        cleanup(&dir);
273    }
274
275    #[test]
276    fn nested_json_flattened() {
277        let dir = unique_dir("nested");
278        write_fixtures(&dir);
279
280        let fr_dir = dir.join("fr");
281        fs::create_dir_all(&fr_dir).unwrap();
282        fs::write(
283            fr_dir.join("auth.json"),
284            serde_json::json!({
285                "auth": {
286                    "login": "Connexion",
287                    "register": "Inscription"
288                }
289            })
290            .to_string(),
291        )
292        .unwrap();
293
294        let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
295        assert_eq!(t.get("fr", "auth.login", &[]), "Connexion");
296        assert_eq!(t.get("fr", "auth.register", &[]), "Inscription");
297        cleanup(&dir);
298    }
299
300    #[test]
301    fn empty_dir_errors() {
302        let dir = unique_dir("empty");
303        fs::create_dir_all(&dir).unwrap();
304        let result = Translator::load(dir.to_str().unwrap(), "en");
305        assert!(result.is_err());
306        cleanup(&dir);
307    }
308}