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())
        );
    }
}