service.rs 264 lignes · 8756 octets
//! I18n Service
//!
//! This module provides the main I18nService for loading and managing translations.

use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::error::AppError;

/// I18n Service for managing translations
///
/// This service loads translations from YAML files and provides
/// translation lookup functionality.
pub struct I18nService {
    /// Translations: HashMap<locale, HashMap<key, value>>
    translations: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
}

impl I18nService {
    /// Create a new I18nService
    pub fn new() -> Self {
        Self {
            translations: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Load translations from a locale file
    ///
    /// # Arguments
    ///
    /// * `locale` - Language code (e.g., "fr", "en")
    /// * `translations` - HashMap of translation keys to values
    pub async fn load_translations(
        &self,
        locale: String,
        translations: HashMap<String, String>,
    ) -> Result<(), AppError> {
        let mut trans = self.translations.write().await;
        let locale_trans = trans.entry(locale).or_insert_with(HashMap::new);
        // Merge: existing keys are preserved (core translations loaded first have priority)
        for (key, value) in translations {
            locale_trans.entry(key).or_insert(value);
        }
        Ok(())
    }

    /// Merge translations into existing locale translations
    ///
    /// This method merges new translations into existing ones. If a key already exists,
    /// the existing value is kept (core has priority over apps by default).
    ///
    /// # Arguments
    ///
    /// * `locale` - Language code (e.g., "fr", "en")
    /// * `translations` - HashMap of translation keys to values to merge
    /// * `overwrite` - If true, overwrite existing keys. If false, keep existing (default: false)
    pub async fn merge_translations(
        &self,
        locale: String,
        translations: HashMap<String, String>,
        overwrite: bool,
    ) -> Result<(), AppError> {
        let mut trans = self.translations.write().await;

        let locale_trans = trans.entry(locale).or_insert_with(HashMap::new);

        if overwrite {
            // Overwrite mode: new translations take priority
            for (key, value) in translations {
                locale_trans.insert(key, value);
            }
        } else {
            // Default mode: existing translations have priority (core over apps)
            for (key, value) in translations {
                locale_trans.entry(key).or_insert(value);
            }
        }

        Ok(())
    }

    /// Load app translations and merge them with existing translations
    ///
    /// App translations are prefixed with app_id namespace (e.g., "todo.title").
    /// Core translations have priority over app translations by default.
    ///
    /// # Arguments
    ///
    /// * `app_id` - The app identifier (used for logging)
    /// * `app_translations` - HashMap of locale -> translations (already namespaced)
    pub async fn load_app_translations(
        &self,
        app_id: &str,
        app_translations: HashMap<String, HashMap<String, String>>,
    ) -> Result<(), AppError> {
        let mut total_keys = 0;
        for (locale, translations) in app_translations {
            total_keys += translations.len();
            // Merge with overwrite=false (core has priority)
            self.merge_translations(locale, translations, false).await?;
        }

        if total_keys > 0 {
            tracing::debug!(
                app_id,
                total_keys,
                "Merged app translations into i18n service"
            );
        }

        Ok(())
    }

    /// Get a translation for a key and locale
    ///
    /// # Arguments
    ///
    /// * `key` - Translation key (e.g., "login.title")
    /// * `locale` - Language code (e.g., "fr")
    ///
    /// # Returns
    ///
    /// The translated string, or the key itself if translation is missing
    pub async fn translate(&self, key: &str, locale: &str) -> String {
        let trans = self.translations.read().await;

        // T107 (FR-005.3): Regional variant fallback
        // Try the requested locale first
        if let Some(locale_trans) = trans.get(locale) {
            if let Some(translation) = locale_trans.get(key) {
                return translation.clone();
            }
        }

        // If locale is a regional variant (e.g., "fr_CA", "en_US") and translation not found,
        // try the base language (e.g., "fr", "en")
        if locale.len() > 2 && locale.contains('_') {
            let base_lang = &locale[..2];
            if let Some(locale_trans) = trans.get(base_lang) {
                if let Some(translation) = locale_trans.get(key) {
                    tracing::debug!(
                        requested_locale = locale,
                        fallback_locale = base_lang,
                        key = key,
                        "Using base language fallback for regional variant"
                    );
                    return translation.clone();
                }
            }
        }

        // Fallback to French if translation not found in requested locale
        if locale != "fr" {
            if let Some(fr_trans) = trans.get("fr") {
                if let Some(translation) = fr_trans.get(key) {
                    tracing::debug!(
                        requested_locale = locale,
                        fallback_locale = "fr",
                        key = key,
                        "Using French fallback for missing translation"
                    );
                    return translation.clone();
                }
            }
        }

        // Log missing translation in debug mode (per T047)
        if cfg!(debug_assertions) || std::env::var("RUSTWARDEN_DEBUG_I18N").is_ok() {
            // Log available locales and keys for debugging
            let available_locales: Vec<String> = trans.keys().cloned().collect();
            let available_keys: Vec<String> = trans
                .get(locale)
                .map(|locale_trans| locale_trans.keys().cloned().collect())
                .unwrap_or_default();

            tracing::debug!(
                locale = locale,
                key = key,
                available_locales = ?available_locales,
                available_keys_count = available_keys.len(),
                sample_keys = ?available_keys.iter().take(5).collect::<Vec<_>>(),
                "Missing translation key"
            );
        }

        // Final fallback: return the key directly
        key.to_string()
    }

    /// Get a translation with variable interpolation
    ///
    /// # Arguments
    ///
    /// * `key` - Translation key
    /// * `locale` - Language code
    /// * `vars` - HashMap of variable names to values
    ///
    /// # Returns
    ///
    /// The translated string with variables replaced, or the key if translation is missing
    pub async fn translate_with_vars(
        &self,
        key: &str,
        locale: &str,
        vars: &HashMap<String, String>,
    ) -> String {
        let mut translation = self.translate(key, locale).await;

        // Replace variables in format {variable}
        for (var_name, var_value) in vars {
            let placeholder = format!("{{{}}}", var_name);
            translation = translation.replace(&placeholder, var_value);
        }

        translation
    }
}

impl Default for I18nService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_translate() {
        let service = I18nService::new();

        let mut translations = HashMap::new();
        translations.insert("hello".to_string(), "Bonjour".to_string());
        service
            .load_translations("fr".to_string(), translations)
            .await
            .unwrap();

        assert_eq!(service.translate("hello", "fr").await, "Bonjour");
        assert_eq!(service.translate("missing", "fr").await, "missing"); // Returns key
    }

    #[tokio::test]
    async fn test_translate_with_vars() {
        let service = I18nService::new();

        let mut translations = HashMap::new();
        translations.insert("welcome".to_string(), "Bienvenue {name}".to_string());
        service
            .load_translations("fr".to_string(), translations)
            .await
            .unwrap();

        let mut vars = HashMap::new();
        vars.insert("name".to_string(), "John".to_string());

        assert_eq!(
            service.translate_with_vars("welcome", "fr", &vars).await,
            "Bienvenue John"
        );
    }
}