/// LocaleBundle — loads and caches translation strings. /// /// A bundle holds all translation strings for one locale. Strings are /// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle /// supports both flat strings and plural forms. use std::collections::HashMap; use crate::locale::Locale; use crate::plural::{plural_form, PluralForm}; /// A single translation value — either a simple string or a plural map. #[derive(Debug, Clone)] pub enum TranslationValue { /// A simple translated string. May contain `{key}` interpolation placeholders. Simple(String), /// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other". Plural(HashMap), } /// A bundle of translations for a single locale. #[derive(Debug, Clone)] pub struct LocaleBundle { pub locale: Locale, translations: HashMap, } impl LocaleBundle { /// Create an empty bundle for a locale. pub fn new(locale: Locale) -> Self { Self { locale, translations: HashMap::new(), } } /// Insert a simple translation. pub fn insert(&mut self, key: impl Into, value: impl Into) { self.translations.insert( key.into(), TranslationValue::Simple(value.into()), ); } /// Insert a plural translation. pub fn insert_plural( &mut self, key: impl Into, forms: HashMap, ) { self.translations .insert(key.into(), TranslationValue::Plural(forms)); } /// Look up a key and return the simple string (no interpolation). pub fn get_raw(&self, key: &str) -> Option<&str> { match self.translations.get(key)? { TranslationValue::Simple(s) => Some(s.as_str()), TranslationValue::Plural(_) => None, } } /// Look up a key with variable interpolation. /// /// Replaces `{name}` placeholders with values from `vars`. pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option { let raw = match self.translations.get(key)? { TranslationValue::Simple(s) => s.clone(), TranslationValue::Plural(forms) => { // For translate(), use "other" as default forms.get("other")?.clone() } }; Some(interpolate(&raw, vars)) } /// Look up a plural key with a count. /// /// Selects the correct plural form for the locale's language and count, /// then interpolates `{n}` and any other `vars`. pub fn translate_plural( &self, key: &str, count: i64, vars: &HashMap<&str, String>, ) -> Option { let forms = match self.translations.get(key)? { TranslationValue::Plural(f) => f, TranslationValue::Simple(s) => { // Fall through: treat the simple string as "other" let mut result_vars = vars.clone(); result_vars.insert("n", count.to_string()); return Some(interpolate(s, &result_vars)); } }; let form = plural_form(&self.locale.language, count); let form_key = match form { PluralForm::Zero => "zero", PluralForm::One => "one", PluralForm::Two => "two", PluralForm::Few => "few", PluralForm::Many => "many", PluralForm::Other => "other", }; let template = forms .get(form_key) .or_else(|| forms.get("other"))?; let mut result_vars = vars.clone(); result_vars.insert("n", count.to_string()); Some(interpolate(template, &result_vars)) } /// Number of translations loaded. pub fn len(&self) -> usize { self.translations.len() } pub fn is_empty(&self) -> bool { self.translations.is_empty() } } /// Replace `{key}` placeholders in `template` with values from `vars`. fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String { let mut result = template.to_string(); for (key, value) in vars { result = result.replace(&format!("{{{}}}", key), value); } result } /// Load a bundle from a TOML string. /// /// Format: /// ```toml /// [profile] /// follow = "Follow" /// followers = { one = "{n} Follower", other = "{n} Followers" } /// ``` pub fn load_toml(locale: Locale, toml_str: &str) -> Result { let value: toml::Value = toml::from_str(toml_str) .map_err(|e| format!("TOML parse error: {}", e))?; let mut bundle = LocaleBundle::new(locale); if let toml::Value::Table(table) = value { load_table(&mut bundle, &table, ""); } Ok(bundle) } fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) { for (key, value) in table { let full_key = if prefix.is_empty() { key.clone() } else { format!("{}.{}", prefix, key) }; match value { toml::Value::String(s) => { bundle.insert(full_key, s.clone()); } toml::Value::Table(inner) => { // Check if it's a plural table (has "one", "other", etc.) let is_plural = inner.contains_key("one") || inner.contains_key("other") || inner.contains_key("zero") || inner.contains_key("few") || inner.contains_key("many"); if is_plural { let mut forms = HashMap::new(); for (form, form_val) in inner { if let toml::Value::String(s) = form_val { forms.insert(form.clone(), s.clone()); } } bundle.insert_plural(full_key, forms); } else { // Nested namespace load_table(bundle, inner, &full_key); } } _ => {} } } } #[cfg(test)] mod tests { use super::*; fn en_bundle() -> LocaleBundle { let mut b = LocaleBundle::new(Locale::en_us()); b.insert("profile.follow", "Follow"); b.insert("profile.bio", "Bio"); let mut forms = HashMap::new(); forms.insert("one".to_string(), "{n} Follower".to_string()); forms.insert("other".to_string(), "{n} Followers".to_string()); b.insert_plural("profile.followers", forms); b } #[test] fn get_raw_simple() { let b = en_bundle(); assert_eq!(b.get_raw("profile.follow"), Some("Follow")); } #[test] fn get_raw_missing() { let b = en_bundle(); assert_eq!(b.get_raw("nonexistent.key"), None); } #[test] fn translate_simple() { let b = en_bundle(); let vars = HashMap::new(); assert_eq!( b.translate("profile.follow", &vars), Some("Follow".to_string()) ); } #[test] fn translate_plural_one() { let b = en_bundle(); let vars = HashMap::new(); assert_eq!( b.translate_plural("profile.followers", 1, &vars), Some("1 Follower".to_string()) ); } #[test] fn translate_plural_many() { let b = en_bundle(); let vars = HashMap::new(); assert_eq!( b.translate_plural("profile.followers", 42, &vars), Some("42 Followers".to_string()) ); } #[test] fn interpolation_replaces_placeholder() { let mut b = LocaleBundle::new(Locale::en_us()); b.insert("greeting", "Hello, {name}!"); let mut vars = HashMap::new(); vars.insert("name", "Alice".to_string()); assert_eq!( b.translate("greeting", &vars), Some("Hello, Alice!".to_string()) ); } #[test] fn bundle_len() { let b = en_bundle(); assert_eq!(b.len(), 3); } #[test] fn load_toml_simple() { let toml = r#" [profile] follow = "Follow" bio = "Bio" "#; let bundle = load_toml(Locale::en_us(), toml).unwrap(); assert_eq!(bundle.get_raw("profile.follow"), Some("Follow")); assert_eq!(bundle.get_raw("profile.bio"), Some("Bio")); } #[test] fn load_toml_plural() { let toml = r#" [profile] followers = { one = "{n} Follower", other = "{n} Followers" } "#; let bundle = load_toml(Locale::en_us(), toml).unwrap(); let vars = HashMap::new(); assert_eq!( bundle.translate_plural("profile.followers", 1, &vars), Some("1 Follower".to_string()) ); } #[test] fn load_toml_invalid() { let result = load_toml(Locale::en_us(), "not valid toml %%%"); assert!(result.is_err()); } }