mod bundle;
mod i18n_impl;
pub mod locale;
pub(crate) use bundle::I18nBundle;
pub use i18n_impl::{I18nError, I18nFormatter};
use std::collections::HashMap;
use std::sync::OnceLock;
use unic_langid::LanguageIdentifier;
static I18N: OnceLock<I18nState> = OnceLock::new();
tokio::task_local! {
static REQUEST_LOCALE: Option<String>;
}
pub async fn with_request_locale<F, R>(locale: Option<String>, f: F) -> R
where
F: std::future::Future<Output = R>,
{
REQUEST_LOCALE.scope(locale, f).await
}
pub fn request_locale() -> Option<String> {
REQUEST_LOCALE.try_with(|lc| lc.clone()).unwrap_or(None)
}
struct I18nState {
bundle: I18nBundle,
default_locale: LanguageIdentifier,
}
pub fn init() {
I18N.get_or_init(|| {
let locale_str = locale::detect_locale();
let lang_id: LanguageIdentifier = locale_str
.parse()
.unwrap_or_else(|_| "en".parse().expect("fallback locale"));
log::info!("i18n initialized: locale={}", locale_str);
I18nState {
bundle: I18nBundle::load(),
default_locale: lang_id,
}
});
}
pub fn tr(key: &str) -> String {
tr_locale_with_args(key, None, None)
}
pub fn tr_with_args(key: &str, args: HashMap<String, String>) -> String {
tr_locale_with_args(key, None, Some(args))
}
pub fn tr_locale(key: &str, locale_str: Option<&str>) -> String {
tr_locale_with_args(key, locale_str, None)
}
fn tr_locale_with_args(
key: &str,
locale_str: Option<&str>,
args: Option<HashMap<String, String>>,
) -> String {
let state = match I18N.get() {
Some(s) => s,
None => {
log::warn!("i18n::tr called before init(), returning key: {key}");
return key.to_string();
}
};
let locale = if let Some(s) = locale_str {
s.parse().unwrap_or_else(|_| state.default_locale.clone())
} else if let Some(req_lc) = request_locale() {
req_lc
.parse()
.unwrap_or_else(|_| state.default_locale.clone())
} else {
state.default_locale.clone()
};
let empty_args = HashMap::new();
let args_ref = args.as_ref().unwrap_or(&empty_args);
state.bundle.get_message(key, &locale, args_ref)
}
pub fn current_locale() -> String {
I18N.get()
.map(|s| s.default_locale.to_string())
.unwrap_or_else(|| "en".to_string())
}
pub fn current_locale_id() -> LanguageIdentifier {
I18N.get()
.map(|s| s.default_locale.clone())
.unwrap_or_else(|| "en".parse().expect("fallback locale"))
}
pub fn tr_args(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[cfg(feature = "http")]
pub async fn i18n_middleware(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let locale = req
.headers()
.get(axum::http::header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.and_then(locale::parse_accept_language);
with_request_locale(locale, async { next.run(req).await }).await
}
#[cfg(test)]
mod tests {
use super::*;
fn ensure_init() {
init();
}
#[test]
fn test_tr_returns_string() {
ensure_init();
let result = tr("health-ok");
assert!(!result.is_empty());
}
#[test]
fn test_tr_unknown_key_returns_key() {
ensure_init();
let result = tr("nonexistent-key");
assert_eq!(result, "nonexistent-key");
}
#[test]
fn test_tr_with_args() {
ensure_init();
let args = tr_args(&[("detail", "bad port")]);
let result = tr_with_args("error-config", args);
assert!(
result.contains("bad port"),
"Expected 'bad port' in result, got: {result}"
);
}
#[test]
fn test_tr_locale_zh() {
ensure_init();
let result = tr_locale("health-ok", Some("zh"));
assert_eq!(result, "正常");
}
#[test]
fn test_tr_locale_en() {
ensure_init();
let result = tr_locale("health-ok", Some("en"));
assert_eq!(result, "OK");
}
#[test]
fn test_tr_args_helper() {
let args = tr_args(&[("key1", "val1"), ("key2", "val2")]);
assert_eq!(args.len(), 2);
assert_eq!(args.get("key1").unwrap(), "val1");
}
#[test]
fn test_current_locale_returns_valid_string() {
ensure_init();
let loc = current_locale();
assert!(!loc.is_empty());
}
#[tokio::test]
async fn test_request_locale_overrides_default() {
ensure_init();
let default_result = tr("health-ok");
let zh_result =
with_request_locale(Some("zh".to_string()), async { tr("health-ok") }).await;
assert_eq!(zh_result, "正常");
let en_result =
with_request_locale(Some("en".to_string()), async { tr("health-ok") }).await;
assert_eq!(en_result, "OK");
let after_result = tr("health-ok");
assert_eq!(default_result, after_result);
}
#[tokio::test]
async fn test_request_locale_with_args() {
ensure_init();
let args = tr_args(&[("detail", "端口无效")]);
let result = with_request_locale(Some("zh".to_string()), async {
tr_with_args("error-config", args)
})
.await;
assert!(
result.contains("端口无效"),
"Expected '端口无效' in result, got: {result}"
);
assert!(
result.contains("配置错误"),
"Expected Chinese prefix '配置错误' in result, got: {result}"
);
}
#[tokio::test]
async fn test_request_locale_none_falls_back_to_default() {
ensure_init();
let result = with_request_locale(None, async { tr("health-ok") }).await;
let default_result = tr("health-ok");
assert_eq!(result, default_result);
}
#[test]
fn test_request_locale_outside_scope_returns_none() {
assert!(request_locale().is_none());
}
#[test]
fn test_current_locale_id_returns_valid_id() {
ensure_init();
let id = current_locale_id();
let s = id.to_string();
assert!(s == "en" || s == "zh", "unexpected locale id: {}", s);
}
#[test]
fn test_tr_locale_with_invalid_locale_str_falls_back() {
ensure_init();
let result = tr_locale("health-ok", Some("invalid_locale!!!"));
assert!(!result.is_empty());
}
#[test]
fn test_tr_with_empty_args_map() {
ensure_init();
let args = HashMap::new();
let result = tr_with_args("health-ok", args);
assert!(!result.is_empty());
}
#[cfg(feature = "http")]
#[test]
fn test_parse_accept_language_empty_header() {
assert_eq!(locale::parse_accept_language(""), None);
}
#[cfg(feature = "http")]
#[test]
fn test_parse_accept_language_unsupported_only() {
assert_eq!(locale::parse_accept_language("fr,de,ja"), None);
}
}