pacsea/i18n/
loader.rs

1//! Locale file loading and parsing.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::i18n::translations::TranslationMap;
8
9/// What: Load a locale YAML file and parse it into a `TranslationMap`.
10///
11/// Inputs:
12/// - `locale`: Locale code (e.g., "de-DE")
13/// - `locales_dir`: Path to locales directory
14///
15/// Output:
16/// - `Result<TranslationMap, String>` containing translations or error
17///
18/// # Errors
19/// - Returns `Err` when the locale code is empty or has an invalid format
20/// - Returns `Err` when the locale file does not exist in the locales directory
21/// - Returns `Err` when the locale file cannot be read (I/O error)
22/// - Returns `Err` when the locale file is empty
23/// - Returns `Err` when the YAML content cannot be parsed
24///
25/// Details:
26/// - Loads file from `locales_dir/{locale}.yml`
27/// - Parses YAML structure into nested `HashMap`
28/// - Returns error if file not found or invalid YAML
29/// - Validates locale format before attempting to load
30pub fn load_locale_file(locale: &str, locales_dir: &Path) -> Result<TranslationMap, String> {
31    // Validate locale format
32    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
67/// What: Validate locale code format (same as resolver).
68///
69/// Inputs:
70/// - `locale`: Locale code to validate
71///
72/// Output:
73/// - `true` if format looks valid, `false` otherwise
74fn 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
85/// What: Parse YAML content into a `TranslationMap`.
86///
87/// Inputs:
88/// - `yaml_content`: YAML file content as string
89///
90/// Output:
91/// - `Result<TranslationMap, String>` containing parsed translations
92///
93/// Details:
94/// - Expects top-level key matching locale code (e.g., "de-DE:")
95/// - Flattens nested structure into dot-notation keys
96fn 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    // Get the top-level locale key (e.g., "de-DE")
103    if let Some(locale_obj) = doc.as_mapping() {
104        for (_locale_key, locale_value) in locale_obj {
105            // Flatten the nested structure (skip the top-level locale key)
106            flatten_yaml_value(locale_value, "", &mut translations);
107        }
108    }
109
110    Ok(translations)
111}
112
113/// What: Recursively flatten YAML structure into dot-notation keys.
114///
115/// Inputs:
116/// - `value`: Current YAML value
117/// - `prefix`: Current key prefix (e.g., "app.titles")
118/// - `translations`: Map to populate
119///
120/// Details:
121/// - Converts nested maps to dot-notation (e.g., app.titles.search)
122/// - Handles arrays by preserving them as YAML values
123fn 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            // Store arrays as YAML strings for now
146            // Can be enhanced later to handle arrays properly
147            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            // Convert other types to string representation
153            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
172/// Locale loader that caches loaded translations.
173pub struct LocaleLoader {
174    /// Directory containing locale translation files.
175    locales_dir: PathBuf,
176    /// Cache of loaded translations by locale name.
177    cache: HashMap<String, TranslationMap>,
178}
179
180impl LocaleLoader {
181    /// What: Create a new `LocaleLoader`.
182    ///
183    /// Inputs:
184    /// - `locales_dir`: Path to locales directory
185    ///
186    /// Output:
187    /// - `LocaleLoader` instance
188    #[must_use]
189    pub fn new(locales_dir: PathBuf) -> Self {
190        Self {
191            locales_dir,
192            cache: HashMap::new(),
193        }
194    }
195
196    /// What: Load locale file, using cache if available.
197    ///
198    /// Inputs:
199    /// - `locale`: Locale code to load
200    ///
201    /// Output:
202    /// - `Result<TranslationMap, String>` containing translations
203    ///
204    /// # Errors
205    /// - Returns `Err` when the locale file cannot be loaded (see `load_locale_file` for specific error conditions)
206    ///
207    /// # Panics
208    /// - Panics if the cache is modified between the `contains_key` check and the `get` call (should not happen in single-threaded usage)
209    ///
210    /// Details:
211    /// - Caches loaded translations to avoid re-reading files
212    /// - Returns cached version if available
213    /// - Logs warnings for missing or invalid locale files
214    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    /// What: Get locales directory path.
242    #[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        // Create a test locale file
307        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        // Test with invalid locale format
344        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        // Create an empty locale file
359        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        // Create a test locale file
377        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        // First load
389        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        // Second load should use cache
398        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        // Both should be the same reference (cached)
407        assert_eq!(result1.len(), result2.len());
408    }
409
410    #[test]
411    fn test_is_valid_locale_format() {
412        // Valid formats
413        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        // Invalid formats
419        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}