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" ); } }
GitRust