1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::i18n::translations::TranslationMap;
8
9pub fn load_locale_file(locale: &str, locales_dir: &Path) -> Result<TranslationMap, String> {
31 if locale.is_empty() {
33 return Err("Locale code cannot be empty".to_string());
34 }
35
36 if !is_valid_locale_format(locale) {
37 return Err(format!(
38 "Invalid locale code format: '{locale}'. Expected format: language[-region] (e.g., 'en-US', 'de-DE')"
39 ));
40 }
41
42 let file_path = locales_dir.join(format!("{locale}.yml"));
43
44 if !file_path.exists() {
45 return Err(format!(
46 "Locale file not found: {}. Available locales can be checked in the locales/ directory.",
47 file_path.display()
48 ));
49 }
50
51 let contents = fs::read_to_string(&file_path)
52 .map_err(|e| format!("Failed to read locale file {}: {e}", file_path.display()))?;
53
54 if contents.trim().is_empty() {
55 return Err(format!("Locale file is empty: {}", file_path.display()));
56 }
57
58 parse_locale_yaml(&contents).map_err(|e| {
59 format!(
60 "Failed to parse locale file {}: {}. Please check YAML syntax.",
61 file_path.display(),
62 e
63 )
64 })
65}
66
67fn is_valid_locale_format(locale: &str) -> bool {
75 if locale.is_empty() || locale.len() > 20 {
76 return false;
77 }
78
79 locale.chars().all(|c| c.is_alphanumeric() || c == '-')
80 && !locale.starts_with('-')
81 && !locale.ends_with('-')
82 && !locale.contains("--")
83}
84
85fn parse_locale_yaml(yaml_content: &str) -> Result<TranslationMap, String> {
97 let doc: serde_norway::Value =
98 serde_norway::from_str(yaml_content).map_err(|e| format!("Failed to parse YAML: {e}"))?;
99
100 let mut translations = HashMap::new();
101
102 if let Some(locale_obj) = doc.as_mapping() {
104 for (_locale_key, locale_value) in locale_obj {
105 flatten_yaml_value(locale_value, "", &mut translations);
107 }
108 }
109
110 Ok(translations)
111}
112
113fn flatten_yaml_value(
124 value: &serde_norway::Value,
125 prefix: &str,
126 translations: &mut TranslationMap,
127) {
128 match value {
129 serde_norway::Value::Mapping(map) => {
130 for (key, val) in map {
131 if let Some(key_str) = key.as_str() {
132 let new_prefix = if prefix.is_empty() {
133 key_str.to_string()
134 } else {
135 format!("{prefix}.{key_str}")
136 };
137 flatten_yaml_value(val, &new_prefix, translations);
138 }
139 }
140 }
141 serde_norway::Value::String(s) => {
142 translations.insert(prefix.to_string(), s.clone());
143 }
144 serde_norway::Value::Sequence(_seq) => {
145 if let Ok(yaml_str) = serde_norway::to_string(value) {
148 translations.insert(prefix.to_string(), yaml_str.trim().to_string());
149 }
150 }
151 _ => {
152 let val_str = value.as_str().map_or_else(
154 || {
155 value.as_i64().map_or_else(
156 || {
157 value.as_f64().map_or_else(
158 || value.as_bool().map_or_else(String::new, |b| b.to_string()),
159 |n| n.to_string(),
160 )
161 },
162 |n| n.to_string(),
163 )
164 },
165 std::string::ToString::to_string,
166 );
167 translations.insert(prefix.to_string(), val_str);
168 }
169 }
170}
171
172pub struct LocaleLoader {
174 locales_dir: PathBuf,
176 cache: HashMap<String, TranslationMap>,
178}
179
180impl LocaleLoader {
181 #[must_use]
189 pub fn new(locales_dir: PathBuf) -> Self {
190 Self {
191 locales_dir,
192 cache: HashMap::new(),
193 }
194 }
195
196 pub fn load(&mut self, locale: &str) -> Result<TranslationMap, String> {
215 if self.cache.contains_key(locale) {
216 Ok(self
217 .cache
218 .get(locale)
219 .expect("locale should be in cache after contains_key check")
220 .clone())
221 } else {
222 match load_locale_file(locale, &self.locales_dir) {
223 Ok(translations) => {
224 let key_count = translations.len();
225 tracing::debug!(
226 "Loaded locale '{}' with {} translation keys",
227 locale,
228 key_count
229 );
230 self.cache.insert(locale.to_string(), translations.clone());
231 Ok(translations)
232 }
233 Err(e) => {
234 tracing::warn!("Failed to load locale '{}': {}", locale, e);
235 Err(e)
236 }
237 }
238 }
239 }
240
241 #[must_use]
243 pub fn locales_dir(&self) -> &Path {
244 &self.locales_dir
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use std::fs;
252 use tempfile::TempDir;
253
254 #[test]
255 fn test_parse_locale_yaml() {
256 let yaml = r#"
257de-DE:
258 app:
259 titles:
260 search: "Suche"
261 help: "Hilfe"
262"#;
263 let result = parse_locale_yaml(yaml).expect("Failed to parse test locale YAML");
264 assert_eq!(result.get("app.titles.search"), Some(&"Suche".to_string()));
265 assert_eq!(result.get("app.titles.help"), Some(&"Hilfe".to_string()));
266 }
267
268 #[test]
269 fn test_parse_locale_yaml_nested() {
270 let yaml = r#"
271en-US:
272 app:
273 modals:
274 preflight:
275 title_install: " Preflight: Install "
276 tabs:
277 summary: "Summary"
278 deps: "Deps"
279"#;
280 let result = parse_locale_yaml(yaml).expect("Failed to parse test locale YAML");
281 assert_eq!(
282 result.get("app.modals.preflight.title_install"),
283 Some(&" Preflight: Install ".to_string())
284 );
285 assert_eq!(
286 result.get("app.modals.preflight.tabs.summary"),
287 Some(&"Summary".to_string())
288 );
289 assert_eq!(
290 result.get("app.modals.preflight.tabs.deps"),
291 Some(&"Deps".to_string())
292 );
293 }
294
295 #[test]
296 fn test_parse_locale_yaml_invalid() {
297 let yaml = "invalid: yaml: content: [";
298 assert!(parse_locale_yaml(yaml).is_err());
299 }
300
301 #[test]
302 fn test_load_locale_file() {
303 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
304 let locales_dir = temp_dir.path();
305
306 let locale_file = locales_dir.join("test-LOCALE.yml");
308 let yaml_content = r#"
309test-LOCALE:
310 app:
311 titles:
312 search: "Test Search"
313"#;
314 fs::write(&locale_file, yaml_content).expect("Failed to write test locale file");
315
316 let result =
317 load_locale_file("test-LOCALE", locales_dir).expect("Failed to load test locale file");
318 assert_eq!(
319 result.get("app.titles.search"),
320 Some(&"Test Search".to_string())
321 );
322 }
323
324 #[test]
325 fn test_load_locale_file_not_found() {
326 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
327 let locales_dir = temp_dir.path();
328
329 let result = load_locale_file("nonexistent", locales_dir);
330 assert!(result.is_err());
331 assert!(
332 result
333 .expect_err("Expected error for nonexistent locale file")
334 .contains("not found")
335 );
336 }
337
338 #[test]
339 fn test_load_locale_file_invalid_format() {
340 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
341 let locales_dir = temp_dir.path();
342
343 let result = load_locale_file("invalid-format-", locales_dir);
345 assert!(result.is_err());
346 assert!(
347 result
348 .expect_err("Expected error for invalid locale format")
349 .contains("Invalid locale code format")
350 );
351 }
352
353 #[test]
354 fn test_load_locale_file_empty() {
355 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
356 let locales_dir = temp_dir.path();
357
358 let locale_file = locales_dir.join("empty.yml");
360 fs::write(&locale_file, "").expect("Failed to write empty test locale file");
361
362 let result = load_locale_file("empty", locales_dir);
363 assert!(result.is_err());
364 assert!(
365 result
366 .expect_err("Expected error for empty locale file")
367 .contains("empty")
368 );
369 }
370
371 #[test]
372 fn test_locale_loader_caching() {
373 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
374 let locales_dir = temp_dir.path();
375
376 let locale_file = locales_dir.join("cache-test.yml");
378 let yaml_content = r#"
379cache-test:
380 app:
381 titles:
382 search: "Cached"
383"#;
384 fs::write(&locale_file, yaml_content).expect("Failed to write test locale file");
385
386 let mut loader = LocaleLoader::new(locales_dir.to_path_buf());
387
388 let result1 = loader
390 .load("cache-test")
391 .expect("Failed to load locale in test");
392 assert_eq!(
393 result1.get("app.titles.search"),
394 Some(&"Cached".to_string())
395 );
396
397 let result2 = loader
399 .load("cache-test")
400 .expect("Failed to load cached locale in test");
401 assert_eq!(
402 result2.get("app.titles.search"),
403 Some(&"Cached".to_string())
404 );
405
406 assert_eq!(result1.len(), result2.len());
408 }
409
410 #[test]
411 fn test_is_valid_locale_format() {
412 assert!(is_valid_locale_format("en-US"));
414 assert!(is_valid_locale_format("de-DE"));
415 assert!(is_valid_locale_format("zh-Hans-CN"));
416 assert!(is_valid_locale_format("en"));
417
418 assert!(!is_valid_locale_format(""));
420 assert!(!is_valid_locale_format("-en-US"));
421 assert!(!is_valid_locale_format("en-US-"));
422 assert!(!is_valid_locale_format("en--US"));
423 assert!(!is_valid_locale_format("en US"));
424 }
425}