1use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7use crate::i18n::detection::detect_system_locale;
8
9#[must_use]
23pub fn resolve_locale(settings_locale: &str, i18n_config_path: &PathBuf) -> String {
24 let fallbacks = load_fallbacks(i18n_config_path);
25 let default_locale = load_default_locale(i18n_config_path);
26 let available_locales = load_available_locales(i18n_config_path);
27
28 let initial_locale = if settings_locale.trim().is_empty() {
30 detect_system_locale().unwrap_or_else(|| {
32 tracing::debug!(
33 "System locale detection failed, using default: {}",
34 default_locale
35 );
36 default_locale.clone()
37 })
38 } else {
39 let trimmed = settings_locale.trim().to_string();
40 if is_valid_locale_format(&trimmed) {
42 trimmed
43 } else {
44 tracing::warn!(
45 "Invalid locale format in settings.conf: '{}'. Using system locale or default.",
46 trimmed
47 );
48 detect_system_locale().unwrap_or_else(|| default_locale.clone())
49 }
50 };
51
52 let resolved = resolve_with_fallbacks(
54 &initial_locale,
55 &fallbacks,
56 &default_locale,
57 &available_locales,
58 );
59
60 if resolved != initial_locale {
61 tracing::debug!(
62 "Locale '{}' resolved to '{}' via fallback chain",
63 initial_locale,
64 resolved
65 );
66 }
67
68 resolved
69}
70
71fn is_valid_locale_format(locale: &str) -> bool {
84 if locale.is_empty() || locale.len() > 20 {
85 return false;
86 }
87
88 locale.chars().all(|c| c.is_alphanumeric() || c == '-')
92 && !locale.starts_with('-')
93 && !locale.ends_with('-')
94 && !locale.contains("--")
95}
96
97fn resolve_with_fallbacks(
112 locale: &str,
113 fallbacks: &HashMap<String, String>,
114 default_locale: &str,
115 available_locales: &std::collections::HashSet<String>,
116) -> String {
117 let mut current = locale.to_string();
118 let mut visited = std::collections::HashSet::new();
119
120 while visited.insert(current.clone()) {
122 if let Some(fallback) = fallbacks.get(¤t) {
124 tracing::debug!("Locale '{}' has fallback: {}", current, fallback);
125 current.clone_from(fallback);
126 } else {
127 if available_locales.contains(¤t) {
129 tracing::debug!(
131 "Locale '{}' has no fallback but is available, using it directly",
132 current
133 );
134 return current;
135 } else if current == default_locale {
136 tracing::debug!(
138 "Locale '{}' has no fallback and is the default locale, using it directly",
139 current
140 );
141 return current;
142 }
143 tracing::debug!(
145 "Locale '{}' has no fallback and is not available, falling back to default: {}",
146 current,
147 default_locale
148 );
149 return default_locale.to_string();
150 }
151
152 if visited.len() > 10 {
154 tracing::warn!(
155 "Fallback chain too long ({} steps) for locale '{}', using default: {}",
156 visited.len(),
157 locale,
158 default_locale
159 );
160 return default_locale.to_string();
161 }
162 }
163
164 tracing::warn!(
166 "Detected cycle in fallback chain for locale '{}', using default: {}",
167 locale,
168 default_locale
169 );
170 default_locale.to_string()
171}
172
173fn load_fallbacks(config_path: &PathBuf) -> HashMap<String, String> {
181 let mut fallbacks = HashMap::new();
182
183 if let Ok(contents) = fs::read_to_string(config_path)
184 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
185 && let Some(fallbacks_map) = doc.get("fallbacks").and_then(|v| v.as_mapping())
186 {
187 for (key, value) in fallbacks_map {
188 if let (Some(k), Some(v)) = (key.as_str(), value.as_str()) {
189 fallbacks.insert(k.to_string(), v.to_string());
190 }
191 }
192 tracing::debug!(
193 "Loaded {} fallback mappings from i18n.yml: {:?}",
194 fallbacks.len(),
195 fallbacks.keys().collect::<Vec<_>>()
196 );
197 } else {
198 tracing::warn!("Failed to load fallbacks from i18n.yml");
199 }
200
201 fallbacks
202}
203
204fn load_available_locales(config_path: &PathBuf) -> std::collections::HashSet<String> {
212 let mut locales = std::collections::HashSet::new();
213
214 if let Ok(contents) = fs::read_to_string(config_path)
215 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
216 && let Some(locales_map) = doc.get("locales").and_then(|v| v.as_mapping())
217 {
218 for key in locales_map.keys() {
219 if let Some(locale) = key.as_str() {
220 locales.insert(locale.to_string());
221 }
222 }
223 tracing::debug!(
224 "Loaded {} available locales from i18n.yml: {:?}",
225 locales.len(),
226 locales.iter().collect::<Vec<_>>()
227 );
228 }
229
230 locales
231}
232
233fn load_default_locale(config_path: &PathBuf) -> String {
241 if let Ok(contents) = fs::read_to_string(config_path)
242 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
243 && let Some(default) = doc.get("default_locale").and_then(|v| v.as_str())
244 {
245 return default.to_string();
246 }
247
248 "en-US".to_string()
249}
250
251pub struct LocaleResolver {
253 fallbacks: HashMap<String, String>,
255 default_locale: String,
257 available_locales: std::collections::HashSet<String>,
259}
260
261impl LocaleResolver {
262 #[must_use]
270 pub fn new(i18n_config_path: &PathBuf) -> Self {
271 Self {
272 fallbacks: load_fallbacks(i18n_config_path),
273 default_locale: load_default_locale(i18n_config_path),
274 available_locales: load_available_locales(i18n_config_path),
275 }
276 }
277
278 #[must_use]
286 pub fn resolve(&self, settings_locale: &str) -> String {
287 let initial_locale = if settings_locale.trim().is_empty() {
288 detect_system_locale().unwrap_or_else(|| self.default_locale.clone())
289 } else {
290 let trimmed = settings_locale.trim().to_string();
291 if is_valid_locale_format(&trimmed) {
293 trimmed
294 } else {
295 tracing::warn!(
296 "Invalid locale format in settings.conf: '{}'. Using system locale or default.",
297 trimmed
298 );
299 detect_system_locale().unwrap_or_else(|| self.default_locale.clone())
300 }
301 };
302
303 tracing::debug!(
304 "Resolving locale '{}' with {} fallbacks available",
305 initial_locale,
306 self.fallbacks.len()
307 );
308 if initial_locale == "ch" {
309 tracing::debug!(
310 "Checking for 'ch' in fallbacks: {}",
311 self.fallbacks.contains_key("ch")
312 );
313 if let Some(fallback) = self.fallbacks.get("ch") {
314 tracing::debug!("Found fallback for 'ch': {}", fallback);
315 }
316 }
317
318 let resolved = resolve_with_fallbacks(
319 &initial_locale,
320 &self.fallbacks,
321 &self.default_locale,
322 &self.available_locales,
323 );
324
325 if resolved != initial_locale {
326 tracing::debug!(
327 "Locale '{}' resolved to '{}' via fallback chain",
328 initial_locale,
329 resolved
330 );
331 }
332
333 resolved
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use std::fs;
341 use tempfile::TempDir;
342
343 #[test]
344 fn test_resolve_with_fallbacks() {
345 let mut fallbacks = HashMap::new();
346 fallbacks.insert("de-CH".to_string(), "de-DE".to_string());
347 fallbacks.insert("de".to_string(), "de-DE".to_string());
348
349 let mut available_locales = std::collections::HashSet::new();
350 available_locales.insert("de-DE".to_string());
351 available_locales.insert("en-US".to_string());
352
353 assert_eq!(
355 resolve_with_fallbacks("de-CH", &fallbacks, "en-US", &available_locales),
356 "de-DE" );
358
359 assert_eq!(
361 resolve_with_fallbacks("de-DE", &fallbacks, "en-US", &available_locales),
362 "de-DE"
363 );
364
365 assert_eq!(
367 resolve_with_fallbacks("en-US", &fallbacks, "en-US", &available_locales),
368 "en-US"
369 );
370
371 assert_eq!(
374 resolve_with_fallbacks("de", &fallbacks, "en-US", &available_locales),
375 "de-DE" );
377
378 let mut available_locales_no_de = std::collections::HashSet::new();
380 available_locales_no_de.insert("en-US".to_string());
381 assert_eq!(
382 resolve_with_fallbacks("de-DE", &fallbacks, "en-US", &available_locales_no_de),
383 "en-US" );
385 }
386
387 #[test]
388 fn test_resolve_with_fallbacks_cycle_detection() {
389 let mut fallbacks = HashMap::new();
390 fallbacks.insert("a".to_string(), "b".to_string());
392 fallbacks.insert("b".to_string(), "c".to_string());
393 fallbacks.insert("c".to_string(), "a".to_string());
394
395 let available_locales = std::collections::HashSet::new();
396
397 let result = resolve_with_fallbacks("a", &fallbacks, "en-US", &available_locales);
399 assert_eq!(result, "en-US");
400 }
401
402 #[test]
403 fn test_resolve_with_fallbacks_long_chain() {
404 let mut fallbacks = HashMap::new();
405 for i in 0..15 {
407 let next = i + 1;
408 fallbacks.insert(format!("loc{i}"), format!("loc{next}"));
409 }
410
411 let available_locales = std::collections::HashSet::new();
412
413 let result = resolve_with_fallbacks("loc0", &fallbacks, "en-US", &available_locales);
415 assert_eq!(result, "en-US");
416 }
417
418 #[test]
419 fn test_is_valid_locale_format() {
420 assert!(is_valid_locale_format("en-US"));
422 assert!(is_valid_locale_format("de-DE"));
423 assert!(is_valid_locale_format("zh-Hans-CN"));
424 assert!(is_valid_locale_format("en"));
425 assert!(is_valid_locale_format("fr-FR"));
426
427 assert!(!is_valid_locale_format(""));
429 assert!(!is_valid_locale_format("-en-US"));
430 assert!(!is_valid_locale_format("en-US-"));
431 assert!(!is_valid_locale_format("en--US"));
432 assert!(!is_valid_locale_format("en US"));
433 assert!(!is_valid_locale_format("en@US"));
434 assert!(!is_valid_locale_format(&"x".repeat(21))); }
436
437 #[test]
438 fn test_load_fallbacks() {
439 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
440 let config_path = temp_dir.path().join("i18n.yml");
441
442 let yaml_content = r"
443default_locale: en-US
444fallbacks:
445 de-CH: de-DE
446 de: de-DE
447 fr: fr-FR
448";
449 fs::write(&config_path, yaml_content).expect("Failed to write test config file");
450
451 let fallbacks = load_fallbacks(&config_path);
452 assert_eq!(fallbacks.get("de-CH"), Some(&"de-DE".to_string()));
453 assert_eq!(fallbacks.get("de"), Some(&"de-DE".to_string()));
454 assert_eq!(fallbacks.get("fr"), Some(&"fr-FR".to_string()));
455 }
456
457 #[test]
458 fn test_load_default_locale() {
459 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
460 let config_path = temp_dir.path().join("i18n.yml");
461
462 let yaml_content = r"
463default_locale: de-DE
464fallbacks:
465 de-CH: de-DE
466";
467 fs::write(&config_path, yaml_content).expect("Failed to write test config file");
468
469 let default = load_default_locale(&config_path);
470 assert_eq!(default, "de-DE");
471 }
472
473 #[test]
474 fn test_load_default_locale_missing() {
475 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
476 let config_path = temp_dir.path().join("i18n.yml");
477
478 let yaml_content = r"
479fallbacks:
480 de-CH: de-DE
481";
482 fs::write(&config_path, yaml_content).expect("Failed to write test config file");
483
484 let default = load_default_locale(&config_path);
485 assert_eq!(default, "en-US"); }
487
488 #[test]
489 fn test_resolve_locale_with_settings() {
490 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
491 let config_path = temp_dir.path().join("i18n.yml");
492
493 let yaml_content = r"
494default_locale: en-US
495fallbacks:
496 de-CH: de-DE
497";
498 fs::write(&config_path, yaml_content).expect("Failed to write test config file");
499
500 let result = resolve_locale("de-CH", &config_path);
503 assert_eq!(result, "en-US"); let result = resolve_locale("en-US", &config_path);
507 assert_eq!(result, "en-US");
508
509 let result = resolve_locale("invalid-format-", &config_path);
511 assert!(!result.is_empty());
513 }
514
515 #[test]
516 fn test_resolve_locale_empty_settings() {
517 let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
518 let config_path = temp_dir.path().join("i18n.yml");
519
520 let yaml_content = r"
521default_locale: en-US
522fallbacks:
523 de-CH: de-DE
524";
525 fs::write(&config_path, yaml_content).expect("Failed to write test config file");
526
527 let result = resolve_locale("", &config_path);
529 assert!(!result.is_empty());
531
532 let result = resolve_locale(" ", &config_path);
534 assert!(!result.is_empty());
535 }
536}