feat(i18n): Embed locales using rust-embed

- Added rust-embed dependency to botlib (optional, enabled by i18n feature).
- Implemented EmbeddedLocales struct to embed 'locales' directory.
- Modified I18nBundle::load to fallback to embedded assets if the filesystem path is not found.
- This resolves issues where botserver fails to find locales directory in production/container environments.
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-27 14:01:03 -03:00
parent bfaa68dc35
commit 5ed6ee7988
2 changed files with 80 additions and 10 deletions

View file

@ -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"] }

View file

@ -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<String, String>;
#[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<Self> {
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<Self> {
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) {