use parking_lot::RwLock;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum I18nError {
#[error("语言包文件读取失败: {path} — {source}")]
FileRead {
path: String,
#[source]
source: std::io::Error,
},
#[error("语言包文件解析失败: {path} — {source}")]
Parse {
path: String,
#[source]
source: serde_json::Error,
},
}
#[derive(Debug, Clone)]
pub struct I18n {
data: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
current_lang: Arc<RwLock<String>>,
default_lang: Arc<RwLock<String>>,
}
impl Default for I18n {
fn default() -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
current_lang: Arc::new(RwLock::new("zh-cn".to_string())),
default_lang: Arc::new(RwLock::new("zh-cn".to_string())),
}
}
}
impl I18n {
pub fn new() -> Self {
Self::default()
}
pub fn with_default_lang(default_lang: &str) -> Self {
let i18n = Self::default();
*i18n.default_lang.write() = default_lang.to_string();
*i18n.current_lang.write() = default_lang.to_string();
i18n
}
pub fn set(&self, lang: &str, key: &str, value: &str) {
let mut data = self.data.write();
data.entry(lang.to_string())
.or_default()
.insert(key.to_string(), value.to_string());
}
pub fn get(
&self,
key: &str,
vars: &HashMap<String, String>,
lang: Option<&str>,
) -> Option<String> {
let data = self.data.read();
let current_lang = self.current_lang.read().clone();
let default_lang = self.default_lang.read().clone();
let lookup_langs: Vec<&str> = match lang {
Some(l) => vec![l, &default_lang],
None => vec![¤t_lang, &default_lang],
};
for lookup_lang in lookup_langs {
if let Some(lang_data) = data.get(lookup_lang) {
if let Some(raw_value) = lang_data.get(key) {
return Some(self.interpolate(raw_value, vars));
}
}
}
None
}
pub fn get_simple(&self, key: &str, lang: Option<&str>) -> Option<String> {
let empty_vars = HashMap::new();
self.get(key, &empty_vars, lang)
}
pub fn has(&self, key: &str, lang: Option<&str>) -> bool {
let data = self.data.read();
let current_lang = self.current_lang.read().clone();
let default_lang = self.default_lang.read().clone();
let lookup_langs: Vec<&str> = match lang {
Some(l) => vec![l, &default_lang],
None => vec![¤t_lang, &default_lang],
};
for lookup_lang in lookup_langs {
if let Some(lang_data) = data.get(lookup_lang) {
if lang_data.contains_key(key) {
return true;
}
}
}
false
}
pub fn load_from_file(&self, path: impl AsRef<Path>, lang: &str) -> Result<(), I18nError> {
let path_ref = path.as_ref();
let content = fs::read_to_string(path_ref).map_err(|e| I18nError::FileRead {
path: path_ref.display().to_string(),
source: e,
})?;
self.load_from_json_str(&content, lang, &path_ref.display().to_string())
}
pub fn load_from_json_str(
&self,
json_str: &str,
lang: &str,
path_for_error: &str,
) -> Result<(), I18nError> {
let parsed: HashMap<String, Value> =
serde_json::from_str(json_str).map_err(|e| I18nError::Parse {
path: path_for_error.to_string(),
source: e,
})?;
let mut data = self.data.write();
let lang_data = data.entry(lang.to_string()).or_default();
for (key, value) in parsed {
if let Some(s) = value.as_str() {
lang_data.insert(key, s.to_string());
}
}
Ok(())
}
pub fn current_lang(&self) -> String {
self.current_lang.read().clone()
}
pub fn set_current_lang(&self, lang: &str) {
*self.current_lang.write() = lang.to_string();
}
pub fn set_default_lang(&self, lang: &str) {
*self.default_lang.write() = lang.to_string();
}
pub fn default_lang(&self) -> String {
self.default_lang.read().clone()
}
fn interpolate(&self, template: &str, vars: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
let colon_placeholder = format!(":{}", key);
result = result.replace(&colon_placeholder, value);
let brace_placeholder = format!("{{{}}}", key);
result = result.replace(&brace_placeholder, value);
}
result
}
pub fn all_for_lang(&self, lang: &str) -> HashMap<String, String> {
let data = self.data.read();
data.get(lang).cloned().unwrap_or_default()
}
pub fn available_langs(&self) -> Vec<String> {
let data = self.data.read();
data.keys().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_new_i18n_default_lang() {
let i18n = I18n::new();
assert_eq!(i18n.current_lang(), "zh-cn");
assert_eq!(i18n.default_lang(), "zh-cn");
}
#[test]
fn test_with_default_lang() {
let i18n = I18n::with_default_lang("en-us");
assert_eq!(i18n.current_lang(), "en-us");
assert_eq!(i18n.default_lang(), "en-us");
}
#[test]
fn test_set_get_basic() {
let i18n = I18n::new();
i18n.set("zh-cn", "hello", "你好");
i18n.set("en-us", "hello", "Hello");
assert_eq!(
i18n.get_simple("hello", Some("zh-cn")),
Some("你好".to_string())
);
assert_eq!(
i18n.get_simple("hello", Some("en-us")),
Some("Hello".to_string())
);
}
#[test]
fn test_get_uses_current_lang() {
let i18n = I18n::with_default_lang("zh-cn");
i18n.set("zh-cn", "hello", "你好");
i18n.set("en-us", "hello", "Hello");
assert_eq!(i18n.get_simple("hello", None), Some("你好".to_string()));
i18n.set_current_lang("en-us");
assert_eq!(i18n.get_simple("hello", None), Some("Hello".to_string()));
}
#[test]
fn test_interpolate_colon_format() {
let i18n = I18n::new();
i18n.set("en-us", "greeting", "Hello, :name! Welcome to :place.");
let mut vars = HashMap::new();
vars.insert("name".to_string(), "John".to_string());
vars.insert("place".to_string(), "Rust".to_string());
let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
assert_eq!(result, "Hello, John! Welcome to Rust.");
}
#[test]
fn test_interpolate_brace_format() {
let i18n = I18n::new();
i18n.set("en-us", "greeting", "Hello, {name}! Welcome to {place}.");
let mut vars = HashMap::new();
vars.insert("name".to_string(), "John".to_string());
vars.insert("place".to_string(), "Rust".to_string());
let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
assert_eq!(result, "Hello, John! Welcome to Rust.");
}
#[test]
fn test_interpolate_mixed_format() {
let i18n = I18n::new();
i18n.set("en-us", "msg", ":name has {count} items in :place.");
let mut vars = HashMap::new();
vars.insert("name".to_string(), "Alice".to_string());
vars.insert("count".to_string(), "5".to_string());
vars.insert("place".to_string(), "basket".to_string());
let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
assert_eq!(result, "Alice has 5 items in basket.");
}
#[test]
fn test_interpolate_missing_var_kept() {
let i18n = I18n::new();
i18n.set("en-us", "msg", "Hello, :name! Count: {count}");
let mut vars = HashMap::new();
vars.insert("name".to_string(), "John".to_string());
let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
assert_eq!(result, "Hello, John! Count: {count}");
}
#[test]
fn test_fallback_to_default_lang() {
let i18n = I18n::with_default_lang("zh-cn");
i18n.set("zh-cn", "only_in_zh", "只有中文");
i18n.set_current_lang("en-us");
assert_eq!(
i18n.get_simple("only_in_zh", None),
Some("只有中文".to_string())
);
}
#[test]
fn test_has() {
let i18n = I18n::new();
i18n.set("zh-cn", "existing", "存在");
assert!(i18n.has("existing", Some("zh-cn")));
assert!(!i18n.has("nonexistent", Some("zh-cn")));
}
#[test]
fn test_has_fallback() {
let i18n = I18n::with_default_lang("zh-cn");
i18n.set("zh-cn", "fallback_key", "回退");
i18n.set_current_lang("en-us");
assert!(i18n.has("fallback_key", None));
}
#[test]
fn test_load_from_json_str() {
let i18n = I18n::new();
let json = r#"{"hello": "Hello, :name!", "bye": "Goodbye"}"#;
i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
assert_eq!(
i18n.get_simple("hello", Some("en-us")),
Some("Hello, :name!".to_string())
);
assert_eq!(
i18n.get_simple("bye", Some("en-us")),
Some("Goodbye".to_string())
);
}
#[test]
fn test_load_from_file() {
let temp_dir = std::env::temp_dir().join("sz_rust_i18n_test");
let _ = std::fs::create_dir_all(&temp_dir);
let lang_file = temp_dir.join("en-us.json");
let mut file = std::fs::File::create(&lang_file).unwrap();
writeln!(file, r#"{{"hello": "Hello!", "bye": "Goodbye"}}"#).unwrap();
drop(file);
let i18n = I18n::new();
i18n.load_from_file(&lang_file, "en-us").unwrap();
assert_eq!(
i18n.get_simple("hello", Some("en-us")),
Some("Hello!".to_string())
);
assert_eq!(
i18n.get_simple("bye", Some("en-us")),
Some("Goodbye".to_string())
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_load_nonexistent_file_errors() {
let i18n = I18n::new();
let result = i18n.load_from_file("/nonexistent/path/lang.json", "en-us");
assert!(result.is_err());
match result {
Err(I18nError::FileRead { .. }) => {}
_ => panic!("期望 FileRead 错误"),
}
}
#[test]
fn test_load_invalid_json_errors() {
let i18n = I18n::new();
let result = i18n.load_from_json_str("{invalid json}", "en-us", "<test>");
assert!(result.is_err());
match result {
Err(I18nError::Parse { .. }) => {}
_ => panic!("期望 Parse 错误"),
}
}
#[test]
fn test_load_json_non_string_values_skipped() {
let i18n = I18n::new();
let json = r#"{"valid": "string", "number": 123, "bool": true, "null_val": null}"#;
i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
assert_eq!(
i18n.get_simple("valid", Some("en-us")),
Some("string".to_string())
);
assert_eq!(i18n.get_simple("number", Some("en-us")), None);
assert_eq!(i18n.get_simple("bool", Some("en-us")), None);
assert_eq!(i18n.get_simple("null_val", Some("en-us")), None);
}
#[test]
fn test_set_and_get_current_lang() {
let i18n = I18n::new();
assert_eq!(i18n.current_lang(), "zh-cn");
i18n.set_current_lang("en-us");
assert_eq!(i18n.current_lang(), "en-us");
i18n.set_current_lang("ja-jp");
assert_eq!(i18n.current_lang(), "ja-jp");
}
#[test]
fn test_set_and_get_default_lang() {
let i18n = I18n::new();
assert_eq!(i18n.default_lang(), "zh-cn");
i18n.set_default_lang("en-us");
assert_eq!(i18n.default_lang(), "en-us");
}
#[test]
fn test_all_for_lang() {
let i18n = I18n::new();
i18n.set("zh-cn", "key1", "值1");
i18n.set("zh-cn", "key2", "值2");
i18n.set("en-us", "key1", "value1");
let zh_data = i18n.all_for_lang("zh-cn");
assert_eq!(zh_data.len(), 2);
assert_eq!(zh_data.get("key1"), Some(&"值1".to_string()));
assert_eq!(zh_data.get("key2"), Some(&"值2".to_string()));
let en_data = i18n.all_for_lang("en-us");
assert_eq!(en_data.len(), 1);
assert_eq!(en_data.get("key1"), Some(&"value1".to_string()));
let ja_data = i18n.all_for_lang("ja-jp");
assert!(ja_data.is_empty());
}
#[test]
fn test_available_langs() {
let i18n = I18n::new();
i18n.set("zh-cn", "key", "值");
i18n.set("en-us", "key", "value");
i18n.set("ja-jp", "key", "値");
let langs = i18n.available_langs();
assert_eq!(langs.len(), 3);
assert!(langs.contains(&"zh-cn".to_string()));
assert!(langs.contains(&"en-us".to_string()));
assert!(langs.contains(&"ja-jp".to_string()));
}
#[test]
fn test_multiple_load_accumulates() {
let i18n = I18n::new();
i18n.load_from_json_str(r#"{"key1": "value1"}"#, "en-us", "<test1>")
.unwrap();
i18n.load_from_json_str(r#"{"key2": "value2"}"#, "en-us", "<test2>")
.unwrap();
assert_eq!(
i18n.get_simple("key1", Some("en-us")),
Some("value1".to_string())
);
assert_eq!(
i18n.get_simple("key2", Some("en-us")),
Some("value2".to_string())
);
}
#[test]
fn test_get_nonexistent_returns_none() {
let i18n = I18n::new();
assert_eq!(i18n.get_simple("nonexistent", Some("zh-cn")), None);
assert_eq!(i18n.get_simple("nonexistent", None), None);
}
#[test]
fn test_interpolate_empty_vars() {
let i18n = I18n::new();
i18n.set("en-us", "msg", "Hello, :name!");
let empty_vars = HashMap::new();
let result = i18n.get("msg", &empty_vars, Some("en-us")).unwrap();
assert_eq!(result, "Hello, :name!");
}
}