diff --git a/Cargo.toml b/Cargo.toml index 0d2b98b..dcc489c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ database = [] http-client = ["dep:reqwest"] validation = [] resilience = [] -i18n = [] +i18n = ["dep:rust-embed"] [dependencies] # Core @@ -34,6 +34,9 @@ tokio = { workspace = true, features = ["sync", "time"] } # Optional: HTTP Client reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true } +# Optional: i18n +rust-embed = { workspace = true, optional = true } + [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/src/i18n/bundle.rs b/src/i18n/bundle.rs index d885b27..aac0e30 100644 --- a/src/i18n/bundle.rs +++ b/src/i18n/bundle.rs @@ -3,8 +3,16 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +#[cfg(feature = "i18n")] +use rust_embed::RustEmbed; + use super::Locale; +#[cfg(feature = "i18n")] +#[derive(RustEmbed)] +#[folder = "locales"] +struct EmbeddedLocales; + pub type MessageArgs = HashMap; #[derive(Debug)] @@ -80,13 +88,11 @@ impl LocaleBundle { .and_then(|n| n.to_str()) .ok_or_else(|| BotError::config("invalid locale directory name"))?; - let locale = Locale::new(dir_name) - .ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?; + .map_err(|e| locale = Locale::new(dir_name) .ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?; let mut translations = TranslationFile { - messages: HashMap::new(), - }; - + messages: HashMap + };.map_err(|e| let entries = fs::read_dir(locale_dir).map_err(|e| { BotError::config(format!("failed to read locale directory: {e}")) })?; @@ -117,6 +123,30 @@ impl LocaleBundle { }) } + #[cfg(feature = "i18n")] + fn load_embedded(locale_str: &str) -> BotResult { + let locale = Locale::new(locale_str) + .ok_or_else(|| BotError::config(format!("invalid locale: {locale_str}")))?; + + let mut translations = TranslationFile { + messages: HashMap::new(), + }; + + for file in EmbeddedLocales::iter() { + if file.starts_with(locale_str) && file.ends_with(".ftl") { + if let Some(content_bytes) = EmbeddedLocales::get(&file) { + if let Ok(content) = std::str::from_utf8(content_bytes.data.as_ref()) { + let file_translations = TranslationFile::parse(content); + translations.merge(file_translations); + } + } + } + .map_err(|e| + Ok(Self { + locale, + translations, + }) .map_err(|e| + fn get_message(&self, key: &str) -> Option<&String> { self.translations.get(key) } @@ -134,6 +164,12 @@ impl I18nBundle { let base = Path::new(base_path); if !base.exists() { + #[cfg(feature = "i18n")] + { + log::info!("Locales directory not found at {}, trying embedded assets", base_path); + return Self::load_embedded(); + } + #[cfg(not(feature = "i18n"))] return Err(BotError::config(format!( "locales directory not found: {base_path}" ))); @@ -175,6 +211,40 @@ impl I18nBundle { }) } + #[cfg(feature = "i18n")] + fn load_embedded() -> BotResult { + let mut bundles = HashMap::new(); + let mut available = Vec::new(); + let mut seen_locales = std::collections::HashSet::new(); + + for file in EmbeddedLocales::iter() { + // Path structure: locale/file.ftl + let parts: Vec<&str> = file.split('/').collect(); + if let Some(locale_str) = parts.first() { + if !seen_locales.contains(*locale_str) { + match LocaleBundle::load_embedded(locale_str) { + Ok(bundle) => { + available.push(bundle.locale.clone()); + bundles.insert(bundle.locale.to_string(), bundle); + seen_locales.insert(locale_str.to_string()); + } + Err(e) => { + log::warn!("failed to load embedded locale bundle {}: {}", locale_str, e); + } + } + } + } + } + + let fallback = Locale::default(); + + Ok(Self { + bundles, + available, + fallback, + }) + } + pub fn get_message(&self, locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String { let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback); @@ -256,10 +326,7 @@ impl I18nBundle { fn handle_plurals(template: &str, args: &MessageArgs) -> String { let mut result = template.to_string(); - - for (key, value) in args { - let count: i64 = value.parse().unwrap_or(0); - +(key, value) in args let count: i64 = value.parse().unwrap_ let plural_pattern = format!("{{ ${key} ->"); if let Some(start) = result.find(&plural_pattern) {