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 {
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 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 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 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 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 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 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}