pacsea/i18n/
resolver.rs

1//! Locale resolution with fallback chain support.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7use crate::i18n::detection::detect_system_locale;
8
9/// What: Resolve the effective locale to use, following fallback chain.
10///
11/// Inputs:
12/// - `settings_locale`: Locale from settings.conf (empty string means auto-detect)
13/// - `i18n_config_path`: Path to config/i18n.yml
14///
15/// Output:
16/// - Resolved locale code (e.g., "de-DE")
17///
18/// Details:
19/// - Priority: `settings_locale` -> system locale -> default from `i18n.yml`
20/// - Applies fallback chain from `i18n.yml` (e.g., de-CH -> de-DE -> en-US)
21/// - Validates locale format (basic check for valid locale code structure)
22#[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    // Determine initial locale
29    let initial_locale = if settings_locale.trim().is_empty() {
30        // Auto-detect from system
31        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        // Validate locale format (basic check)
41        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    // Apply fallback chain
53    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
71/// What: Validate locale code format.
72///
73/// Inputs:
74/// - `locale`: Locale code to validate
75///
76/// Output:
77/// - `true` if format looks valid, `false` otherwise
78///
79/// Details:
80/// - Checks for basic structure: language[-region] or language[-script][-region]
81/// - Allows simple language codes (e.g., "en") or full codes (e.g., "en-US")
82/// - Rejects obviously invalid formats (empty, spaces, special chars)
83fn is_valid_locale_format(locale: &str) -> bool {
84    if locale.is_empty() || locale.len() > 20 {
85        return false;
86    }
87
88    // Basic pattern: language[-region] or language[-script][-region]
89    // Allow: en, en-US, de-DE, zh-Hans-CN, etc.
90    // Reject: spaces, most special chars (except hyphens)
91    locale.chars().all(|c| c.is_alphanumeric() || c == '-')
92        && !locale.starts_with('-')
93        && !locale.ends_with('-')
94        && !locale.contains("--")
95}
96
97/// What: Resolve locale using fallback chain.
98///
99/// Inputs:
100/// - `locale`: Initial locale code
101/// - `fallbacks`: Map of locale -> fallback locale
102/// - `default_locale`: Ultimate fallback (usually "en-US")
103///
104/// Output:
105/// - Resolved locale that exists in available locales
106///
107/// Details:
108/// - Follows fallback chain until reaching a locale without fallback or default
109/// - Prevents infinite loops with cycle detection
110/// - Logs warnings for suspicious fallback chains
111fn 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    // Follow fallback chain until we find a valid locale or hit default
121    while visited.insert(current.clone()) {
122        // Check if we have a fallback for this locale
123        if let Some(fallback) = fallbacks.get(&current) {
124            tracing::debug!("Locale '{}' has fallback: {}", current, fallback);
125            current.clone_from(fallback);
126        } else {
127            // No fallback defined - check if this locale is available
128            if available_locales.contains(&current) {
129                // Locale is available, use it directly
130                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                // Default locale is always valid
137                tracing::debug!(
138                    "Locale '{}' has no fallback and is the default locale, using it directly",
139                    current
140                );
141                return current;
142            }
143            // Locale not available and not default, fall back to default
144            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        // Safety check: prevent infinite loops
153        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    // Detected a cycle in fallback chain
165    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
173/// What: Load fallback mappings from i18n.yml.
174///
175/// Inputs:
176/// - `config_path`: Path to config/i18n.yml
177///
178/// Output:
179/// - `HashMap` mapping locale codes to their fallback locales
180fn 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
204/// What: Load available locales from i18n.yml.
205///
206/// Inputs:
207/// - `config_path`: Path to config/i18n.yml
208///
209/// Output:
210/// - `HashSet` of available locale codes
211fn 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
233/// What: Load default locale from i18n.yml.
234///
235/// Inputs:
236/// - `config_path`: Path to config/i18n.yml
237///
238/// Output:
239/// - Default locale code (defaults to "en-US" if not found)
240fn 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
251/// Locale resolver that caches configuration.
252pub struct LocaleResolver {
253    /// Map of locale to fallback locale.
254    fallbacks: HashMap<String, String>,
255    /// Default locale to use when no match is found.
256    default_locale: String,
257    /// Set of available locales.
258    available_locales: std::collections::HashSet<String>,
259}
260
261impl LocaleResolver {
262    /// What: Create a new `LocaleResolver` by loading `i18n.yml`.
263    ///
264    /// Inputs:
265    /// - `i18n_config_path`: Path to config/i18n.yml
266    ///
267    /// Output:
268    /// - `LocaleResolver` instance
269    #[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    /// What: Resolve locale using cached fallback configuration.
279    ///
280    /// Inputs:
281    /// - `settings_locale`: Locale from settings.conf
282    ///
283    /// Output:
284    /// - Resolved locale code
285    #[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            // Validate locale format (basic check)
292            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        // Test fallback chain: de-CH -> de-DE (available, stops here)
354        assert_eq!(
355            resolve_with_fallbacks("de-CH", &fallbacks, "en-US", &available_locales),
356            "de-DE" // de-CH -> de-DE (available, stops)
357        );
358
359        // Test that de-DE is used directly when available
360        assert_eq!(
361            resolve_with_fallbacks("de-DE", &fallbacks, "en-US", &available_locales),
362            "de-DE"
363        );
364
365        // Test that default locale returns itself
366        assert_eq!(
367            resolve_with_fallbacks("en-US", &fallbacks, "en-US", &available_locales),
368            "en-US"
369        );
370
371        // Test single-part locale fallback
372        // de -> de-DE (available, stops)
373        assert_eq!(
374            resolve_with_fallbacks("de", &fallbacks, "en-US", &available_locales),
375            "de-DE" // de -> de-DE (available, stops)
376        );
377
378        // Test that unavailable locale falls back to default
379        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" // de-DE not available, falls back to default
384        );
385    }
386
387    #[test]
388    fn test_resolve_with_fallbacks_cycle_detection() {
389        let mut fallbacks = HashMap::new();
390        // Create a cycle: a -> b -> c -> a
391        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        // Should detect cycle and return default
398        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        // Create a long chain
406        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        // Should hit max length limit and return default
414        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        // Valid formats
421        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        // Invalid formats
428        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))); // Too long
435    }
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"); // Should default to en-US
486    }
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        // Test with explicit locale from settings
501        // de-CH -> de-DE -> (no fallback) -> en-US (default)
502        let result = resolve_locale("de-CH", &config_path);
503        assert_eq!(result, "en-US"); // Should fallback through chain to default
504
505        // Test with valid locale that has no fallback
506        let result = resolve_locale("en-US", &config_path);
507        assert_eq!(result, "en-US");
508
509        // Test with invalid locale format
510        let result = resolve_locale("invalid-format-", &config_path);
511        // Should fallback to system/default (may vary based on environment)
512        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        // Test with empty settings (should auto-detect or use default)
528        let result = resolve_locale("", &config_path);
529        // Result depends on system locale, but should not be empty
530        assert!(!result.is_empty());
531
532        // Test with whitespace-only settings
533        let result = resolve_locale("   ", &config_path);
534        assert!(!result.is_empty());
535    }
536}