Skip to main content

ferro_lang/
loader.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::error::LangError;
8
9/// Normalize a locale identifier to lowercase with hyphens.
10///
11/// Converts `en_US` to `en-us`, `pt-BR` to `pt-br`, etc.
12pub fn normalize_locale(locale: &str) -> String {
13    locale.to_lowercase().replace('_', "-")
14}
15
16/// Load all translation files from a directory.
17///
18/// Expects the structure: `{path}/{locale}/*.json`
19///
20/// Each subdirectory name is treated as a locale identifier. All JSON files
21/// within a locale directory are merged into a single flat map using
22/// dot-notation keys.
23///
24/// After loading, fallback translations are pre-merged into each locale
25/// so runtime lookup requires only a single `HashMap::get`.
26pub fn load_translations(
27    path: &str,
28    fallback: &str,
29) -> Result<HashMap<String, HashMap<String, String>>, LangError> {
30    let base = Path::new(path);
31    let mut translations: HashMap<String, HashMap<String, String>> = HashMap::new();
32
33    let entries = fs::read_dir(base)?;
34
35    for entry in entries {
36        let entry = entry?;
37        let file_type = entry.file_type()?;
38
39        if !file_type.is_dir() {
40            continue;
41        }
42
43        let dir_name = entry.file_name();
44        let locale_raw = dir_name.to_string_lossy().to_string();
45        let locale = normalize_locale(&locale_raw);
46
47        let locale_map = load_locale_dir(&entry.path())?;
48        if !locale_map.is_empty() {
49            translations.insert(locale, locale_map);
50        }
51    }
52
53    if translations.is_empty() {
54        return Err(LangError::NoTranslationsLoaded);
55    }
56
57    let fallback_normalized = normalize_locale(fallback);
58
59    // Pre-merge fallback: insert missing keys from fallback into each locale.
60    if let Some(fallback_map) = translations.get(&fallback_normalized).cloned() {
61        for (locale, locale_map) in translations.iter_mut() {
62            if *locale == fallback_normalized {
63                continue;
64            }
65            for (key, value) in &fallback_map {
66                locale_map
67                    .entry(key.clone())
68                    .or_insert_with(|| value.clone());
69            }
70        }
71    }
72
73    let total_keys: usize = translations.values().map(|m| m.len()).sum();
74    tracing::info!(
75        locales = translations.len(),
76        total_keys,
77        "loaded translations"
78    );
79
80    Ok(translations)
81}
82
83/// Load all JSON files within a single locale directory.
84fn load_locale_dir(dir: &Path) -> Result<HashMap<String, String>, LangError> {
85    let mut map = HashMap::new();
86
87    let entries = fs::read_dir(dir)?;
88
89    for entry in entries {
90        let entry = entry?;
91        let path = entry.path();
92
93        if path.extension().and_then(|e| e.to_str()) != Some("json") {
94            continue;
95        }
96
97        let content = fs::read_to_string(&path)?;
98        let parsed: HashMap<String, Value> = serde_json::from_str(&content)?;
99
100        flatten_json(&parsed, "", &mut map);
101    }
102
103    Ok(map)
104}
105
106/// Flatten a nested JSON object into dot-notation keys.
107///
108/// Only string leaf values are stored. Non-string leaves are skipped
109/// with a warning.
110fn flatten_json(obj: &HashMap<String, Value>, prefix: &str, out: &mut HashMap<String, String>) {
111    for (key, value) in obj {
112        let full_key = if prefix.is_empty() {
113            key.clone()
114        } else {
115            format!("{prefix}.{key}")
116        };
117
118        match value {
119            Value::String(s) => {
120                out.insert(full_key, s.clone());
121            }
122            Value::Object(nested) => {
123                let nested_map: HashMap<String, Value> =
124                    nested.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
125                flatten_json(&nested_map, &full_key, out);
126            }
127            _ => {
128                tracing::warn!(
129                    key = %full_key,
130                    "skipping non-string translation value"
131                );
132            }
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::fs;
141    use tempfile::tempdir;
142
143    // ── normalize_locale ──────────────────────────────────────────────
144
145    #[test]
146    fn normalize_locale_lowercase() {
147        assert_eq!(normalize_locale("en"), "en");
148    }
149
150    #[test]
151    fn normalize_locale_underscore() {
152        assert_eq!(normalize_locale("en_US"), "en-us");
153    }
154
155    #[test]
156    fn normalize_locale_uppercase() {
157        assert_eq!(normalize_locale("pt-BR"), "pt-br");
158    }
159
160    #[test]
161    fn normalize_locale_mixed() {
162        assert_eq!(normalize_locale("zh_Hans_CN"), "zh-hans-cn");
163    }
164
165    // ── flatten_json (tested via load_translations) ───────────────────
166
167    #[test]
168    fn flatten_simple_object() {
169        let dir = tempdir().unwrap();
170        let en = dir.path().join("en");
171        fs::create_dir_all(&en).unwrap();
172        fs::write(
173            en.join("messages.json"),
174            serde_json::json!({"key": "val"}).to_string(),
175        )
176        .unwrap();
177
178        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
179        assert_eq!(t["en"]["key"], "val");
180    }
181
182    #[test]
183    fn flatten_nested_object() {
184        let dir = tempdir().unwrap();
185        let en = dir.path().join("en");
186        fs::create_dir_all(&en).unwrap();
187        fs::write(
188            en.join("messages.json"),
189            serde_json::json!({"a": {"b": "c"}}).to_string(),
190        )
191        .unwrap();
192
193        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
194        assert_eq!(t["en"]["a.b"], "c");
195    }
196
197    #[test]
198    fn flatten_deeply_nested() {
199        let dir = tempdir().unwrap();
200        let en = dir.path().join("en");
201        fs::create_dir_all(&en).unwrap();
202        fs::write(
203            en.join("messages.json"),
204            serde_json::json!({"a": {"b": {"c": "deep"}}}).to_string(),
205        )
206        .unwrap();
207
208        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
209        assert_eq!(t["en"]["a.b.c"], "deep");
210    }
211
212    #[test]
213    fn flatten_skips_non_string_leaves() {
214        let dir = tempdir().unwrap();
215        let en = dir.path().join("en");
216        fs::create_dir_all(&en).unwrap();
217        fs::write(
218            en.join("messages.json"),
219            serde_json::json!({
220                "valid": "hello",
221                "number": 42,
222                "boolean": true
223            })
224            .to_string(),
225        )
226        .unwrap();
227
228        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
229        assert_eq!(t["en"]["valid"], "hello");
230        assert!(!t["en"].contains_key("number"));
231        assert!(!t["en"].contains_key("boolean"));
232    }
233
234    // ── load_locale_dir (tested via load_translations) ────────────────
235
236    #[test]
237    fn load_locale_dir_single_file() {
238        let dir = tempdir().unwrap();
239        let en = dir.path().join("en");
240        fs::create_dir_all(&en).unwrap();
241        fs::write(
242            en.join("auth.json"),
243            serde_json::json!({"login": "Login", "register": "Register"}).to_string(),
244        )
245        .unwrap();
246
247        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
248        assert_eq!(t["en"]["login"], "Login");
249        assert_eq!(t["en"]["register"], "Register");
250    }
251
252    #[test]
253    fn load_locale_dir_multiple_files() {
254        let dir = tempdir().unwrap();
255        let en = dir.path().join("en");
256        fs::create_dir_all(&en).unwrap();
257        fs::write(
258            en.join("auth.json"),
259            serde_json::json!({"login": "Login"}).to_string(),
260        )
261        .unwrap();
262        fs::write(
263            en.join("messages.json"),
264            serde_json::json!({"welcome": "Welcome"}).to_string(),
265        )
266        .unwrap();
267
268        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
269        assert_eq!(t["en"]["login"], "Login");
270        assert_eq!(t["en"]["welcome"], "Welcome");
271    }
272
273    #[test]
274    fn load_locale_dir_ignores_non_json() {
275        let dir = tempdir().unwrap();
276        let en = dir.path().join("en");
277        fs::create_dir_all(&en).unwrap();
278        fs::write(
279            en.join("messages.json"),
280            serde_json::json!({"hello": "Hello"}).to_string(),
281        )
282        .unwrap();
283        fs::write(en.join("notes.txt"), "should be ignored").unwrap();
284
285        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
286        assert_eq!(t["en"].len(), 1);
287        assert_eq!(t["en"]["hello"], "Hello");
288    }
289
290    // ── load_translations ─────────────────────────────────────────────
291
292    #[test]
293    fn load_translations_single_locale() {
294        let dir = tempdir().unwrap();
295        let en = dir.path().join("en");
296        fs::create_dir_all(&en).unwrap();
297        fs::write(
298            en.join("messages.json"),
299            serde_json::json!({"greeting": "Hi"}).to_string(),
300        )
301        .unwrap();
302
303        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
304        assert_eq!(t.len(), 1);
305        assert!(t.contains_key("en"));
306    }
307
308    #[test]
309    fn load_translations_fallback_premerge() {
310        let dir = tempdir().unwrap();
311
312        let en = dir.path().join("en");
313        fs::create_dir_all(&en).unwrap();
314        fs::write(
315            en.join("messages.json"),
316            serde_json::json!({"greeting": "Hello", "farewell": "Goodbye"}).to_string(),
317        )
318        .unwrap();
319
320        let es = dir.path().join("es");
321        fs::create_dir_all(&es).unwrap();
322        fs::write(
323            es.join("messages.json"),
324            serde_json::json!({"greeting": "Hola"}).to_string(),
325        )
326        .unwrap();
327
328        let t = load_translations(dir.path().to_str().unwrap(), "en").unwrap();
329
330        // es has its own "greeting"
331        assert_eq!(t["es"]["greeting"], "Hola");
332        // es got "farewell" from fallback pre-merge
333        assert_eq!(t["es"]["farewell"], "Goodbye");
334        // en still has both
335        assert_eq!(t["en"]["greeting"], "Hello");
336        assert_eq!(t["en"]["farewell"], "Goodbye");
337    }
338
339    #[test]
340    fn load_translations_empty_dir_errors() {
341        let dir = tempdir().unwrap();
342        // No locale subdirectories
343        let result = load_translations(dir.path().to_str().unwrap(), "en");
344        assert!(result.is_err());
345        let err = result.unwrap_err();
346        assert!(
347            matches!(err, LangError::NoTranslationsLoaded),
348            "expected NoTranslationsLoaded, got: {err}"
349        );
350    }
351
352    #[test]
353    fn load_translations_normalizes_locale_dirs() {
354        let dir = tempdir().unwrap();
355        let en_us = dir.path().join("en_US");
356        fs::create_dir_all(&en_us).unwrap();
357        fs::write(
358            en_us.join("messages.json"),
359            serde_json::json!({"color": "Color"}).to_string(),
360        )
361        .unwrap();
362
363        let t = load_translations(dir.path().to_str().unwrap(), "en_US").unwrap();
364        // Directory "en_US" should be normalized to "en-us" key
365        assert!(t.contains_key("en-us"));
366        assert_eq!(t["en-us"]["color"], "Color");
367    }
368}