392 lines
12 KiB
Rust
392 lines
12 KiB
Rust
//! 国际化模块
|
||
//!
|
||
//! 使用 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);
|
||
}
|
||
}
|