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
8pub struct Translator {
14 translations: HashMap<String, HashMap<String, String>>,
15 fallback: String,
16}
17
18impl Translator {
19 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 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 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 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 pub fn locales(&self) -> Vec<&str> {
114 self.translations.keys().map(|s| s.as_str()).collect()
115 }
116}
117
118fn 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 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 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 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 #[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 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 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 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 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 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 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}