use std::collections::HashMap; use std::sync::LazyLock; use serde_json::Value; /// Supported application locales. /// /// Each variant maps to an ISO 639-1 code and a human-readable label /// displayed in the language picker. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Locale { #[default] En, De, Fr, Es, Pt, } impl Locale { /// ISO 639-1 language code. pub fn code(self) -> &'static str { match self { Locale::En => "en", Locale::De => "de", Locale::Fr => "fr", Locale::Es => "es", Locale::Pt => "pt", } } /// Human-readable label in the locale's own language. pub fn label(self) -> &'static str { match self { Locale::En => "English", Locale::De => "Deutsch", Locale::Fr => "Francais", Locale::Es => "Espanol", Locale::Pt => "Portugues", } } /// All available locales. pub fn all() -> &'static [Locale] { &[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt] } /// Parse a locale from its ISO 639-1 code. /// /// Returns `Locale::En` for unrecognized codes. pub fn from_code(code: &str) -> Self { match code { "de" => Locale::De, "fr" => Locale::Fr, "es" => Locale::Es, "pt" => Locale::Pt, _ => Locale::En, } } } type TranslationMap = HashMap; /// All translations loaded at compile time and parsed lazily on first access. /// /// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O. /// Each locale's JSON is embedded via `include_str!` and flattened into /// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`). static TRANSLATIONS: LazyLock> = LazyLock::new(|| { let mut map = HashMap::with_capacity(5); map.insert( "en", parse_translations(include_str!("../../assets/i18n/en.json")), ); map.insert( "de", parse_translations(include_str!("../../assets/i18n/de.json")), ); map.insert( "fr", parse_translations(include_str!("../../assets/i18n/fr.json")), ); map.insert( "es", parse_translations(include_str!("../../assets/i18n/es.json")), ); map.insert( "pt", parse_translations(include_str!("../../assets/i18n/pt.json")), ); map }); /// Parse a JSON string into a flat `key -> value` map. /// /// Nested objects are flattened with dot separators: /// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`. fn parse_translations(json: &str) -> TranslationMap { // SAFETY: translation JSON files are bundled at compile time and are // validated during development. A malformed file will panic here during // the first access, which surfaces immediately in testing. let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default())); let mut map = TranslationMap::new(); flatten_json("", &value, &mut map); map } /// Recursively flatten a JSON value into dot-separated keys. fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) { match value { Value::Object(obj) => { for (key, val) in obj { let new_prefix = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; flatten_json(&new_prefix, val, map); } } Value::String(s) => { map.insert(prefix.to_string(), s.clone()); } // Non-string leaf values are skipped (numbers, bools, nulls) _ => {} } } /// Look up a translation for the given locale and key. /// /// Falls back to English if the key is missing in the target locale. /// Returns the raw key if not found in any locale (useful for debugging /// missing translations). /// /// # Arguments /// /// * `locale` - The target locale /// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`) /// /// # Returns /// /// The translated string, or the key itself as a fallback. pub fn t(locale: Locale, key: &str) -> String { TRANSLATIONS .get(locale.code()) .and_then(|map| map.get(key)) .cloned() .unwrap_or_else(|| { // Fallback to English TRANSLATIONS .get("en") .and_then(|map| map.get(key)) .cloned() .unwrap_or_else(|| key.to_string()) }) } /// Look up a translation and substitute variables. /// /// Variables in the translation string use `{name}` syntax. /// Each `(name, value)` pair in `vars` replaces `{name}` with `value`. /// /// # Arguments /// /// * `locale` - The target locale /// * `key` - Dot-separated translation key /// * `vars` - Slice of `(name, value)` pairs for substitution /// /// # Returns /// /// The translated string with all variables substituted. /// /// # Examples /// /// ``` /// use dashboard::i18n::{tw, Locale}; /// let text = tw(Locale::En, "common.up_to_seats", &[("n", "5")]); /// assert_eq!(text, "Up to 5 seats"); /// ``` pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String { let mut result = t(locale, key); for (name, value) in vars { result = result.replace(&format!("{{{name}}}"), value); } result } #[cfg(test)] mod tests { use super::*; #[test] fn english_lookup() { let result = t(Locale::En, "nav.dashboard"); assert_eq!(result, "Dashboard"); } #[test] fn german_lookup() { let result = t(Locale::De, "nav.dashboard"); assert_eq!(result, "Dashboard"); } #[test] fn fallback_to_english() { // If a key exists in English but not in another locale, English is returned let en = t(Locale::En, "common.loading"); let result = t(Locale::De, "common.loading"); // German should have its own translation, but if missing, falls back to EN assert!(!result.is_empty()); // Just verify it doesn't return the key itself assert_ne!(result, "common.loading"); let _ = en; // suppress unused warning } #[test] fn missing_key_returns_key() { let result = t(Locale::En, "nonexistent.key"); assert_eq!(result, "nonexistent.key"); } #[test] fn variable_substitution() { let result = tw(Locale::En, "common.up_to_seats", &[("n", "5")]); assert_eq!(result, "Up to 5 seats"); } #[test] fn locale_from_code() { assert_eq!(Locale::from_code("de"), Locale::De); assert_eq!(Locale::from_code("fr"), Locale::Fr); assert_eq!(Locale::from_code("unknown"), Locale::En); } #[test] fn all_locales_loaded() { for locale in Locale::all() { let result = t(*locale, "nav.dashboard"); assert!(!result.is_empty()); } } }