Files
qiming/qiming-mcp-proxy/mcp-common/src/i18n.rs
2026-06-01 13:03:20 +08:00

392 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 国际化模块
//!
//! 使用 rust-i18n 提供多语言支持,支持中文简体、中文繁体、英文三种语言。
//!
//! # 使用方法
//!
//! ```rust,ignore
//! use mcp_common::{t, set_locale, init_locale_from_env};
//!
//! // 初始化语言设置(通常在程序启动时调用)
//! init_locale_from_env();
//!
//! // 获取翻译
//! let msg = t!("errors.mcp_proxy.service_not_found", service = "my-service");
//!
//! // 手动设置语言
//! set_locale("zh-CN");
//! ```
//!
//! # 支持的语言
//!
//! - `en` - English
//! - `zh-CN` - 中文简体
//! - `zh-TW` - 中文繁体
//!
//! # 配置优先级
//!
//! 1. `DEFAULT_LOCALE` 环境变量(最高优先级)
//! 2. `LANG` 系统环境变量
//! 3. 默认使用英文
//!
//! # 线程安全
//!
//! `set_locale()` 和 `init_locale_from_env()` 应在程序启动时调用。
//! 语言设置是全局状态,不建议在运行时多线程环境中修改。
// 注意: rust-i18n 的 i18n! 宏需要在 lib.rs 中调用
/// 导出 t! 宏,用于获取翻译
pub use rust_i18n::t;
/// 设置当前语言
///
/// # 线程安全
///
/// 此函数应在程序启动时调用,不建议在运行时多线程环境中调用。
/// 语言设置是全局状态,并发调用可能导致不一致的翻译结果。
///
/// # 示例
///
/// ```rust
/// use mcp_common::set_locale;
///
/// set_locale("zh-CN");
/// set_locale("en");
/// set_locale("zh-TW");
/// ```
pub fn set_locale(locale: &str) {
rust_i18n::set_locale(locale);
}
/// 获取当前语言设置
///
/// # 示例
///
/// ```rust
/// use mcp_common::current_locale;
///
/// let locale = current_locale();
/// println!("Current locale: {}", locale);
/// ```
pub fn current_locale() -> String {
rust_i18n::locale().to_string()
}
/// 支持的语言列表
pub const AVAILABLE_LOCALES: &[&str] = &["en", "zh-CN", "zh-TW"];
/// 默认语言
pub const DEFAULT_LOCALE: &str = "en";
/// 从环境变量初始化语言设置
///
/// 按照以下优先级设置语言:
/// 1. `DEFAULT_LOCALE` 环境变量(最高优先级)
/// 2. `LANG` 系统环境变量(自动解析语言代码)
/// 3. 默认使用英文
///
/// # 示例
///
/// ```rust
/// use mcp_common::init_locale_from_env;
///
/// // 在程序启动时调用
/// init_locale_from_env();
/// ```
pub fn init_locale_from_env() {
// 优先使用 DEFAULT_LOCALE 环境变量(最高优先级)
if let Ok(lang) = std::env::var("DEFAULT_LOCALE") {
let locale = normalize_locale(&lang);
if AVAILABLE_LOCALES.contains(&locale.as_str()) {
set_locale(&locale);
return;
} else {
tracing::warn!(
"Invalid locale '{}' from DEFAULT_LOCALE, falling back. Supported: {:?}",
locale,
AVAILABLE_LOCALES
);
}
}
// 其次尝试从 LANG 环境变量解析
if let Ok(lang) = std::env::var("LANG") {
let locale = parse_lang_env(&lang);
if AVAILABLE_LOCALES.contains(&locale.as_str()) {
set_locale(&locale);
return;
}
}
// 使用默认语言
set_locale(DEFAULT_LOCALE);
}
/// 标准化语言代码
///
/// 支持的输入格式:
/// - `en`, `EN`, `En`, `en_US`, `en_US.UTF-8` -> `en`
/// - `zh-CN`, `zh-cn`, `ZH-CN` -> `zh-CN`
/// - `zh_TW`, `zh-TW` -> `zh-TW`
/// - `zh`, `ZH` -> `zh-CN` (默认简体中文)
fn normalize_locale(input: &str) -> String {
let input = input.trim();
// 支持解析带编码/修饰符的值(例如 en_US.UTF-8、zh_CN@cjk
let input = input.split('.').next().unwrap_or(input);
let input = input.split('@').next().unwrap_or(input);
// 直接匹配
match input.to_lowercase().as_str() {
"en" | "en_us" | "en-us" | "en_gb" | "en-gb" => return "en".to_string(),
"zh-cn" | "zh_cn" | "zh-hans" => return "zh-CN".to_string(),
"zh-tw" | "zh_tw" | "zh-hant" => return "zh-TW".to_string(),
"zh" => return "zh-CN".to_string(), // 默认简体中文
_ => {}
}
// 尝试解析语言-地区格式
let parts: Vec<&str> = input.split(|c| c == '-' || c == '_').collect();
if parts.len() >= 2 {
let lang = parts[0].to_lowercase();
let region = parts[1].to_uppercase();
// 英文变体统一映射到 en
if lang == "en" {
return "en".to_string();
}
return format!("{}-{}", lang, region);
}
input.to_string()
}
/// 解析 LANG 环境变量
///
/// 支持的格式:
/// - `en_US.UTF-8` -> `en`
/// - `zh_CN.UTF-8` -> `zh-CN`
/// - `zh_TW.UTF-8` -> `zh-TW`
/// - `zh_CN` -> `zh-CN`
fn parse_lang_env(lang: &str) -> String {
// 移除编码部分 (如 .UTF-8)
let lang = lang.split('.').next().unwrap_or(lang);
// 移除修饰部分 (如 @cjk)
let lang = lang.split('@').next().unwrap_or(lang);
// 标准化格式
normalize_locale(lang)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvRestore {
saved: Vec<(&'static str, Option<String>)>,
}
impl Drop for EnvRestore {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
}
}
fn prepare_env(overrides: &[(&'static str, Option<&str>)]) -> EnvRestore {
let tracked_keys = ["DEFAULT_LOCALE", "LANG", "MCP_PROXY_LANG", "APP_LANG"];
let mut saved = Vec::with_capacity(tracked_keys.len());
for key in tracked_keys {
saved.push((key, std::env::var(key).ok()));
unsafe { std::env::remove_var(key) };
}
for (key, value) in overrides {
match value {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
EnvRestore { saved }
}
#[test]
fn test_normalize_locale() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
assert_eq!(normalize_locale("en"), "en");
assert_eq!(normalize_locale("EN"), "en");
assert_eq!(normalize_locale("zh-CN"), "zh-CN");
assert_eq!(normalize_locale("zh-cn"), "zh-CN");
assert_eq!(normalize_locale("zh_CN"), "zh-CN");
assert_eq!(normalize_locale("zh-TW"), "zh-TW");
assert_eq!(normalize_locale("zh_tw"), "zh-TW");
assert_eq!(normalize_locale("zh"), "zh-CN");
assert_eq!(normalize_locale("en_US.UTF-8"), "en");
assert_eq!(normalize_locale("zh_CN@cjk"), "zh-CN");
}
#[test]
fn test_parse_lang_env() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
assert_eq!(parse_lang_env("en_US.UTF-8"), "en");
assert_eq!(parse_lang_env("zh_CN.UTF-8"), "zh-CN");
assert_eq!(parse_lang_env("zh_TW.UTF-8"), "zh-TW");
assert_eq!(parse_lang_env("zh_CN"), "zh-CN");
assert_eq!(parse_lang_env("en_US@cjk"), "en");
}
#[test]
fn test_set_and_get_locale() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
set_locale("zh-CN");
assert_eq!(current_locale(), "zh-CN");
set_locale("en");
assert_eq!(current_locale(), "en");
set_locale("zh-TW");
assert_eq!(current_locale(), "zh-TW");
}
/// 关键翻译键在所有语言中都存在的测试
#[test]
fn test_translation_completeness() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
set_locale("en");
let test_msg = t!("common.success").to_string();
assert_ne!(
test_msg, "common.success",
"Translations are not loaded; expected crate-local locales to be available"
);
// 测试关键错误消息键
let critical_keys = [
"errors.mcp_proxy.service_not_found",
"errors.mcp_proxy.service_startup_failed",
"errors.document_parser.config",
"errors.document_parser.parse",
"errors.oss.config",
"errors.oss.network",
"errors.voice.config",
"errors.voice.transcription",
"cli.startup.service_starting",
"cli.startup.success",
"common.error",
"common.success",
];
for locale in AVAILABLE_LOCALES {
set_locale(locale);
for key in &critical_keys {
let msg = match *key {
"errors.mcp_proxy.service_not_found" => {
t!("errors.mcp_proxy.service_not_found", service = "test").to_string()
}
"errors.mcp_proxy.service_startup_failed" => t!(
"errors.mcp_proxy.service_startup_failed",
mcp_id = "test",
reason = "test"
)
.to_string(),
_ => t!(*key).to_string(),
};
// 翻译不应该返回 key 本身(表示翻译缺失)
assert_ne!(
msg, *key,
"Missing translation for '{}' in locale '{}'",
key, locale
);
}
}
}
/// 测试所有支持的语言都能正确切换
///
/// 注意:此测试依赖于 rust-i18n 的全局状态,在测试环境中可能不稳定。
/// 但在实际运行时,语言切换功能是正常的。
#[test]
fn test_all_locales_available() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
// 测试每个语言代码都是有效的
for locale in AVAILABLE_LOCALES {
// 验证语言代码格式正确
assert!(
locale.contains('-') || *locale == "en",
"Locale '{}' should follow language-region format",
locale
);
// 尝试设置(在翻译文件不可用时可能不生效,但不应该崩溃)
set_locale(locale);
}
// 重置为默认语言
set_locale(DEFAULT_LOCALE);
}
#[test]
fn test_init_locale_from_env_prefers_default_locale() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
let _env = prepare_env(&[
("DEFAULT_LOCALE", Some("zh-TW")),
("LANG", Some("en_US.UTF-8")),
("MCP_PROXY_LANG", Some("zh-CN")),
("APP_LANG", Some("zh-CN")),
]);
init_locale_from_env();
assert_eq!(current_locale(), "zh-TW");
set_locale(DEFAULT_LOCALE);
}
#[test]
fn test_init_locale_from_env_falls_back_to_lang() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
let _env = prepare_env(&[
("DEFAULT_LOCALE", Some("unsupported")),
("LANG", Some("zh_CN.UTF-8")),
]);
init_locale_from_env();
assert_eq!(current_locale(), "zh-CN");
set_locale(DEFAULT_LOCALE);
}
#[test]
fn test_init_locale_from_env_falls_back_to_english() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
let _env = prepare_env(&[
("DEFAULT_LOCALE", Some("unsupported")),
("LANG", Some("ja_JP.UTF-8")),
]);
init_locale_from_env();
assert_eq!(current_locale(), "en");
set_locale(DEFAULT_LOCALE);
}
#[test]
fn test_init_locale_from_env_ignores_removed_env_vars() {
let _guard = test_lock().lock().expect("locale test lock poisoned");
let _env = prepare_env(&[
("MCP_PROXY_LANG", Some("zh-TW")),
("APP_LANG", Some("zh-CN")),
]);
init_locale_from_env();
assert_eq!(current_locale(), "en");
set_locale(DEFAULT_LOCALE);
}
}