Files
certifai/src/i18n/mod.rs
Sharang Parnerkar 208450e618
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 4s
feat: use librechat instead of own chat (#14)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #14
2026-02-24 10:45:41 +00:00

243 lines
6.9 KiB
Rust

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<String, String>;
/// 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<HashMap<&'static str, TranslationMap>> = 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());
}
}
}