use std::collections::HashMap;
use std::sync::OnceLock;
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
use unic_langid::LanguageIdentifier;
pub(crate) const SUPPORTED_LOCALES: &[&str] = &["en", "zh"];
const EN_FTL: &str = concat!(
include_str!("locales/en/errors.ftl"),
"\n",
include_str!("locales/en/messages.ftl"),
);
const ZH_FTL: &str = concat!(
include_str!("locales/zh/errors.ftl"),
"\n",
include_str!("locales/zh/messages.ftl"),
);
static EN_BUNDLE: OnceLock<FluentBundle<FluentResource>> = OnceLock::new();
static ZH_BUNDLE: OnceLock<FluentBundle<FluentResource>> = OnceLock::new();
#[derive(Clone, Copy)]
pub(crate) struct I18nBundle;
impl I18nBundle {
pub fn load() -> Self {
for locale in SUPPORTED_LOCALES {
let _ = match *locale {
"zh" => ZH_BUNDLE.get_or_init(build_zh_bundle),
_ => EN_BUNDLE.get_or_init(build_en_bundle),
};
}
Self
}
pub fn get_message(
&self,
key: &str,
locale: &LanguageIdentifier,
args: &HashMap<String, String>,
) -> String {
let lang = locale.language.as_str();
format_from_bundle(lang, key, args)
.or_else(|| format_from_bundle(Self::fallback_locale().language.as_str(), key, args))
.unwrap_or_else(|| key.to_string())
}
fn fallback_locale() -> LanguageIdentifier {
"en".parse().expect("fallback locale 'en' is valid")
}
}
fn format_from_bundle(lang: &str, key: &str, args: &HashMap<String, String>) -> Option<String> {
let bundle = match lang {
"zh" => ZH_BUNDLE.get_or_init(build_zh_bundle),
_ => EN_BUNDLE.get_or_init(build_en_bundle),
};
let msg = bundle.get_message(key)?;
let pattern = msg.value()?;
let mut fluent_args = FluentArgs::new();
for (name, value) in args {
fluent_args.set(name.as_str(), FluentValue::from(value.clone()));
}
let mut errors = vec![];
let result = bundle.format_pattern(pattern, Some(&fluent_args), &mut errors);
Some(result.to_string())
}
fn build_en_bundle() -> FluentBundle<FluentResource> {
let resource = FluentResource::try_new(EN_FTL.to_string()).unwrap_or_else(|e| e.0);
let langid: LanguageIdentifier = "en".parse().expect("'en' is a valid language identifier");
let mut bundle = FluentBundle::new_concurrent(vec![langid]);
bundle.set_use_isolating(false);
bundle
.add_resource(resource)
.expect("EN resources should add without conflict");
bundle
}
fn build_zh_bundle() -> FluentBundle<FluentResource> {
let resource = FluentResource::try_new(ZH_FTL.to_string()).unwrap_or_else(|e| e.0);
let langid: LanguageIdentifier = "zh".parse().expect("'zh' is a valid language identifier");
let mut bundle = FluentBundle::new_concurrent(vec![langid]);
bundle.set_use_isolating(false);
bundle
.add_resource(resource)
.expect("ZH resources should add without conflict");
bundle
}
#[cfg(test)]
mod tests {
use super::*;
fn extract_keys(ftl: &str) -> std::collections::BTreeSet<String> {
let mut keys = std::collections::BTreeSet::new();
for line in ftl.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed != line {
continue;
}
if let Some((key, _)) = trimmed.split_once('=') {
let key = key.trim();
if !key.is_empty() {
keys.insert(key.to_string());
}
}
}
keys
}
#[test]
fn test_key_parity_en_zh() {
let en_keys = extract_keys(EN_FTL);
let zh_keys = extract_keys(ZH_FTL);
assert!(!en_keys.is_empty(), "en FTL should contain messages");
let missing_in_zh: Vec<_> = en_keys.difference(&zh_keys).collect();
let missing_in_en: Vec<_> = zh_keys.difference(&en_keys).collect();
assert!(
missing_in_zh.is_empty(),
"keys missing in zh FTL: {missing_in_zh:?}"
);
assert!(
missing_in_en.is_empty(),
"keys missing in en FTL: {missing_in_en:?}"
);
}
#[test]
fn test_every_key_resolves_in_both_bundles() {
let no_args = HashMap::new();
for key in extract_keys(EN_FTL) {
let en = format_from_bundle("en", &key, &no_args);
let zh = format_from_bundle("zh", &key, &no_args);
assert!(en.is_some(), "key '{key}' missing in en bundle");
assert!(zh.is_some(), "key '{key}' missing in zh bundle");
assert_ne!(en, Some(key.clone()), "en value degenerated to key");
assert_ne!(zh, Some(key.clone()), "zh value degenerated to key");
}
}
#[test]
fn test_unknown_language_falls_back_to_en_bundle() {
let no_args = HashMap::new();
assert_eq!(
format_from_bundle("ar", "health-ok", &no_args),
Some("OK".to_string())
);
assert_eq!(format_from_bundle("en", "nonexistent-key", &no_args), None);
}
#[test]
fn test_get_message_missing_key_returns_key_without_panic() {
let bundle = I18nBundle::load();
let fr: LanguageIdentifier = "fr".parse().unwrap();
let args = HashMap::new();
assert_eq!(
bundle.get_message("nonexistent-key", &fr, &args),
"nonexistent-key"
);
}
#[test]
fn test_get_message_unsupported_locale_falls_back_to_en() {
let bundle = I18nBundle::load();
let fr: LanguageIdentifier = "fr".parse().unwrap();
let args = HashMap::new();
assert_eq!(bundle.get_message("health-ok", &fr, &args), "OK");
}
#[test]
fn test_get_message_en() {
let bundle = I18nBundle::load();
let en: LanguageIdentifier = "en".parse().unwrap();
let args = HashMap::new();
assert_eq!(bundle.get_message("health-ok", &en, &args), "OK");
}
#[test]
fn test_get_message_zh() {
let bundle = I18nBundle::load();
let zh: LanguageIdentifier = "zh".parse().unwrap();
let args = HashMap::new();
assert_eq!(bundle.get_message("health-ok", &zh, &args), "正常");
}
#[test]
fn test_get_message_zh_cn_language_subtag() {
let bundle = I18nBundle::load();
let zh_cn: LanguageIdentifier = "zh-CN".parse().unwrap();
let args = HashMap::new();
assert_eq!(bundle.get_message("health-ok", &zh_cn, &args), "正常");
}
#[test]
fn test_get_message_with_args() {
let bundle = I18nBundle::load();
let en: LanguageIdentifier = "en".parse().unwrap();
let mut args = HashMap::new();
args.insert("detail".to_string(), "bad port".to_string());
let msg = bundle.get_message("error-config", &en, &args);
assert!(msg.contains("bad port"), "Expected 'bad port' in '{msg}'");
}
#[test]
fn test_format_from_bundle_multiple_vars() {
let mut args = HashMap::new();
args.insert("index".to_string(), "0".to_string());
args.insert("max".to_string(), "8192".to_string());
args.insert("got".to_string(), "9000".to_string());
let msg =
format_from_bundle("en", "validate-text-length", &args).expect("key in en bundle");
assert_eq!(msg, "Text at index 0 exceeds max length 8192 (got 9000)");
}
#[test]
fn test_fallback_locale_is_en() {
assert_eq!(I18nBundle::fallback_locale().language.as_str(), "en");
}
#[test]
fn test_supported_locales_en_zh_only() {
assert_eq!(SUPPORTED_LOCALES, &["en", "zh"]);
}
}