1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::error::LangError;
8
9pub fn normalize_locale(locale: &str) -> String {
13 locale.to_lowercase().replace('_', "-")
14}
15
16pub 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 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
83fn 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
106fn 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 #[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 #[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 #[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 #[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 assert_eq!(t["es"]["greeting"], "Hola");
332 assert_eq!(t["es"]["farewell"], "Goodbye");
334 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 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 assert!(t.contains_key("en-us"));
366 assert_eq!(t["en-us"]["color"], "Color");
367 }
368}