loader.rs
410 lignes · 14028 octets
//! Translation loader //! //! This module provides functionality to load translations from YAML files. use std::collections::HashMap; use std::path::Path; use tracing::{error, info, warn}; use yaml_rust2::{Yaml, YamlLoader}; use crate::error::AppError; // Recommended limits per FR-005.2 const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024; // 10MB const MAX_KEYS_PER_LOCALE: usize = 1000; /// Load translations from a YAML file /// /// # Arguments /// /// * `file_path` - Path to the YAML translation file /// /// # Returns /// /// HashMap of translation keys to values (flattened from YAML structure) /// /// # Validation (FR-005, FR-005.1, FR-005.2) /// /// This function performs the following validations: /// - UTF-8 encoding validation (FR-005) /// - YAML structure validation with tolerance (FR-005.1) /// - File size warning (FR-005.2) /// - Translation key count warning (FR-005.2) pub async fn load_translations_from_file( file_path: impl AsRef<Path>, ) -> Result<HashMap<String, String>, AppError> { let path = file_path.as_ref(); // T101 (FR-005): UTF-8 encoding validation // Read file as bytes first to validate encoding let file_bytes = tokio::fs::read(path).await.map_err(|e| { error!( file_path = %path.display(), error = %e, "Failed to read translation file" ); AppError::Internal(format!( "Failed to read translation file {}: {}", path.display(), e )) })?; // Check file size (T104 - FR-005.2) let file_size = file_bytes.len() as u64; if file_size > MAX_FILE_SIZE_BYTES { warn!( file_path = %path.display(), file_size_bytes = file_size, recommended_limit_bytes = MAX_FILE_SIZE_BYTES, "Translation file exceeds recommended size limit (10MB). Consider splitting into multiple files." ); } // Validate UTF-8 encoding (T101 - FR-005) let content = match String::from_utf8(file_bytes) { Ok(content) => content, Err(e) => { error!( file_path = %path.display(), error = %e, invalid_utf8_sequence = ?e.utf8_error(), "Translation file is not valid UTF-8. File will be rejected." ); return Err(AppError::Internal(format!( "Translation file {} is not valid UTF-8 encoding. Error: {}. Please ensure all translation files use UTF-8 encoding.", path.display(), e ))); } }; // T102 (FR-005.1): YAML validation with tolerance // Parse YAML using yaml-rust2 with detailed error reporting let docs = match YamlLoader::load_from_str(&content) { Ok(docs) => docs, Err(e) => { // T103 (FR-005.1): Administrator-friendly error logging error!( file_path = %path.display(), error_type = "YAML parsing error", error_message = %e, "Failed to parse YAML translation file. File will be rejected, but other files will continue loading." ); return Err(AppError::Internal(format!( "Failed to parse YAML file {}: {}. Please check the YAML syntax. The file will be rejected, but other translation files will continue loading.", path.display(), e ))); } }; if docs.is_empty() { warn!( file_path = %path.display(), "YAML file is empty or contains no documents" ); return Ok(HashMap::new()); } let yaml = &docs[0]; // Flatten YAML structure to dot-separated keys let mut translations = HashMap::new(); flatten_yaml_value(yaml, &mut translations, String::new()); // T105 (FR-005.2): Translation key count warning let key_count = translations.len(); if key_count > MAX_KEYS_PER_LOCALE { warn!( file_path = %path.display(), key_count = key_count, recommended_limit = MAX_KEYS_PER_LOCALE, "Translation file contains more than recommended number of keys (1000). Consider splitting into multiple files or namespaces." ); } info!( file_path = %path.display(), key_count = key_count, file_size_bytes = file_size, "Loaded translations from file" ); Ok(translations) } /// Flatten a YAML Value into dot-separated keys /// /// Converts: /// ```yaml /// login: /// title: "Connexion" /// username: "Nom d'utilisateur" /// ``` /// Into: /// ```text /// "login.title" => "Connexion" /// "login.username" => "Nom d'utilisateur" /// ``` fn flatten_yaml_value(yaml: &Yaml, result: &mut HashMap<String, String>, prefix: String) { match yaml { Yaml::Hash(hash) => { for (key, val) in hash { let key_str = match key { Yaml::String(s) => s.clone(), Yaml::Integer(i) => i.to_string(), Yaml::Real(r) => r.clone(), _ => continue, }; let new_prefix = if prefix.is_empty() { key_str } else { format!("{}.{}", prefix, key_str) }; flatten_yaml_value(val, result, new_prefix); } } Yaml::String(s) => { result.insert(prefix, s.clone()); } Yaml::Integer(i) => { result.insert(prefix, i.to_string()); } Yaml::Real(r) => { result.insert(prefix, r.clone()); } Yaml::Boolean(b) => { result.insert(prefix, b.to_string()); } Yaml::Null => { // Skip null values } Yaml::Array(_) => { // Arrays are not supported in translation files warn!( "Array found in translation file at key '{}', skipping", prefix ); } _ => { warn!("Unsupported YAML type at key '{}', skipping", prefix); } } } /// Load all translation files from a directory /// /// # Arguments /// /// * `dir_path` - Path to directory containing translation files (e.g., "locales/") /// /// # Returns /// /// HashMap of locale -> translations /// /// # Regional Variant Fallback (FR-005.3) /// /// This function implements regional variant fallback: /// - If "fr_CA.yml" is requested but not found, it will try "fr.yml" /// - If "en_US.yml" is requested but not found, it will try "en.yml" /// - The base language (first 2 characters) is used as fallback pub async fn load_translations_from_directory( dir_path: impl AsRef<Path>, ) -> Result<HashMap<String, HashMap<String, String>>, AppError> { let dir = dir_path.as_ref(); if !dir.exists() { warn!("Translation directory {} does not exist", dir.display()); return Ok(HashMap::new()); } let mut all_translations = HashMap::new(); let mut loaded_locales = std::collections::HashSet::new(); // Read directory let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| { error!( directory = %dir.display(), error = %e, "Failed to read translation directory" ); AppError::Internal(format!("Failed to read directory {}: {}", dir.display(), e)) })?; while let Some(entry) = entries.next_entry().await.map_err(|e| { error!( directory = %dir.display(), error = %e, "Failed to read directory entry" ); AppError::Internal(format!("Failed to read directory entry: {}", e)) })? { let path = entry.path(); // Only process .yml and .yaml files if let Some(ext) = path.extension() { if ext == "yml" || ext == "yaml" { // Extract locale from filename (e.g., "fr.yml" -> "fr", "fr_CA.yml" -> "fr_CA") if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { match load_translations_from_file(&path).await { Ok(translations) => { all_translations.insert(stem.to_string(), translations); loaded_locales.insert(stem.to_string()); info!( locale = stem, file_path = %path.display(), "Loaded translations for locale" ); } Err(e) => { // T102, T103 (FR-005.1): Continue loading other files even if one fails error!( file_path = %path.display(), locale = stem, error = %e, "Failed to load translations from file. File rejected, continuing with other files." ); // Continue loading other files (tolerance) } } } } } } // T107 (FR-005.3): Regional variant fallback // Note: The loader simply loads all available files. Regional variant fallback // is handled at the service/extractor/helper level: // - If "fr_CA" is requested but not found, the service will fallback to "fr" // - This is implemented in I18nService::translate() and helpers.rs // - The loader doesn't need to implement fallback logic, it just loads what exists Ok(all_translations) } /// Load translations from an app's locales directory /// /// # Arguments /// /// * `app_id` - The app identifier (e.g., "todo") /// * `app_locales_dir` - Path to the app's locales directory (e.g., "crates/apps/todo/locales/") /// /// # Returns /// /// HashMap of locale -> translations (with app_id namespace prefix) pub async fn load_app_translations( app_id: &str, app_locales_dir: impl AsRef<Path>, ) -> Result<HashMap<String, HashMap<String, String>>, AppError> { let dir = app_locales_dir.as_ref(); if !dir.exists() { // Missing translation files are handled gracefully (per T038) info!( app_id, "App translation directory {} does not exist, skipping", dir.display() ); return Ok(HashMap::new()); } // Load translations from directory let mut all_translations = HashMap::new(); // Read directory let mut entries = match tokio::fs::read_dir(dir).await { Ok(entries) => entries, Err(e) => { warn!( app_id, "Failed to read app translation directory {}: {}", dir.display(), e ); return Ok(HashMap::new()); } }; while let Some(entry) = entries .next_entry() .await .map_err(|e| AppError::Internal(format!("Failed to read directory entry: {}", e)))? { let path = entry.path(); // Only process .yml and .yaml files if let Some(ext) = path.extension() { if ext == "yml" || ext == "yaml" { // Extract locale from filename (e.g., "fr.yml" -> "fr") if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { match load_translations_from_file(&path).await { Ok(mut translations) => { // Prefix all keys with app_id namespace (e.g., "todo.title") let mut namespaced_translations = HashMap::new(); for (key, value) in translations.drain() { let namespaced_key = format!("{}.{}", app_id, key); namespaced_translations.insert(namespaced_key, value); } all_translations.insert(stem.to_string(), namespaced_translations); info!( app_id, locale = stem, count = all_translations.get(stem).map(|t| t.len()).unwrap_or(0), "Loaded app translations" ); } Err(e) => { // T102, T103 (FR-005.1): Continue loading other files even if one fails error!( app_id = app_id, file_path = %path.display(), locale = stem, error = %e, "Failed to load app translations from file. File rejected, continuing with other files." ); // Continue loading other files (tolerance) } } } } } } Ok(all_translations) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[test] fn test_flatten_yaml_value() { let yaml_str = r#" login: title: "Connexion" username: "Nom d'utilisateur" error: invalid_credentials: "Identifiants incorrects" "#; let docs = YamlLoader::load_from_str(yaml_str).unwrap(); let yaml = &docs[0]; let mut result = HashMap::new(); flatten_yaml_value(yaml, &mut result, String::new()); assert_eq!(result.get("login.title"), Some(&"Connexion".to_string())); assert_eq!( result.get("login.username"), Some(&"Nom d'utilisateur".to_string()) ); assert_eq!( result.get("error.invalid_credentials"), Some(&"Identifiants incorrects".to_string()) ); } }
GitRust