提交qiming-mcp-proxy
This commit is contained in:
391
qiming-mcp-proxy/mcp-common/src/i18n.rs
Normal file
391
qiming-mcp-proxy/mcp-common/src/i18n.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! 国际化模块
|
||||
//!
|
||||
//! 使用 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user