use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use sys_locale::get_locale;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Locale {
strings: HashMap<String, String>,
}
#[derive(Clone)]
pub struct I18n {
current_locale: String,
locales: HashMap<String, Locale>,
}
impl I18n {
pub fn new() -> Result<Self> {
let mut i18n = Self {
current_locale: String::new(),
locales: HashMap::new(),
};
i18n.load_locales()?;
i18n.set_locale(&Self::get_system_locale());
Ok(i18n)
}
pub fn with_locale(locale: &str) -> Result<Self> {
let mut i18n = Self::new()?;
i18n.set_locale(locale);
Ok(i18n)
}
fn load_locales(&mut self) -> Result<()> {
let locale_dir = Path::new("locales");
if !locale_dir.exists() {
return Err(anyhow::anyhow!("Locales directory not found"));
}
for entry in fs::read_dir(locale_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
if let Some(locale_name) = path.file_stem().and_then(|s| s.to_str()) {
let content = fs::read_to_string(&path).with_context(|| {
format!("Failed to read locale file: {}", path.display())
})?;
let strings: HashMap<String, String> = serde_yaml_ng::from_str(&content)
.with_context(|| {
format!("Failed to parse locale file: {}", path.display())
})?;
self.locales
.insert(locale_name.to_string(), Locale { strings });
}
}
}
Ok(())
}
pub fn set_locale(&mut self, locale: &str) {
if self.locales.contains_key(locale) {
self.current_locale = locale.to_string();
}
}
pub fn get_current_locale(&self) -> &str {
&self.current_locale
}
pub fn available_locales(&self) -> Vec<&str> {
self.locales.keys().map(|s| s.as_str()).collect()
}
pub fn t(&self, key: &str) -> String {
if let Some(locale) = self.locales.get(&self.current_locale) {
locale
.strings
.get(key)
.map(|s| s.clone())
.unwrap_or_else(|| key.to_string())
} else {
key.to_string()
}
}
pub fn tf(&self, key: &str, args: &[&str]) -> String {
let template = self.t(key);
let mut result = template;
for (i, arg) in args.iter().enumerate() {
result = result.replace(&format!("{{{}}}", i), arg);
}
result
}
fn get_system_locale() -> String {
if let Ok(lang) = std::env::var("LANG") {
if let Some(locale) = Self::parse_locale(&lang) {
return locale;
}
}
if let Some(locale) = get_locale() {
if let Some(parsed) = Self::parse_locale(&locale) {
return parsed;
}
}
"en".to_string()
}
fn parse_locale(locale_str: &str) -> Option<String> {
let locale_lower = locale_str.to_lowercase();
if locale_lower.starts_with("zh")
&& (locale_lower.contains("cn") || locale_lower.contains("hans"))
{
Some("zh-cn".to_string())
} else if locale_lower.starts_with("en") {
Some("en".to_string())
} else if locale_lower.starts_with("fr") {
Some("en".to_string())
} else {
None
}
}
pub fn is_locale_supported(&self, locale: &str) -> bool {
self.locales.contains_key(locale)
}
}
use std::sync::{Mutex, OnceLock};
static I18N: OnceLock<Mutex<I18n>> = OnceLock::new();
pub fn init_i18n() -> Result<()> {
let i18n = I18n::new()?;
I18N.set(Mutex::new(i18n))
.map_err(|_| anyhow::anyhow!("Failed to initialize i18n"))?;
Ok(())
}
pub fn init_i18n_with_locale(locale: &str) -> Result<()> {
let i18n = I18n::with_locale(locale)?;
I18N.set(Mutex::new(i18n))
.map_err(|_| anyhow::anyhow!("Failed to initialize i18n"))?;
Ok(())
}
pub fn set_locale(locale: &str) {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(mut i18n) = i18n_mutex.lock() {
i18n.set_locale(locale);
}
}
}
pub fn get_current_locale() -> String {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(i18n) = i18n_mutex.lock() {
return i18n.get_current_locale().to_string();
}
}
"en".to_string()
}
pub fn available_locales() -> Vec<String> {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(i18n) = i18n_mutex.lock() {
return i18n
.available_locales()
.iter()
.map(|s| s.to_string())
.collect();
}
}
vec!["en".to_string()]
}
pub fn is_locale_supported(locale: &str) -> bool {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(i18n) = i18n_mutex.lock() {
return i18n.is_locale_supported(locale);
}
}
false
}
pub fn t(key: &str) -> String {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(i18n) = i18n_mutex.lock() {
return i18n.t(key).to_string();
}
}
key.to_string()
}
pub fn tf(key: &str, args: &[&str]) -> String {
if let Some(i18n_mutex) = I18N.get() {
if let Ok(i18n) = i18n_mutex.lock() {
let template = i18n.t(key);
let mut result = template;
for (i, arg) in args.iter().enumerate() {
result = result.replace(&format!("{{{}}}", i), arg);
}
return result;
}
}
let mut result = key.to_string();
for (i, arg) in args.iter().enumerate() {
result = result.replace(&format!("{{{}}}", i), arg);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_test_locale_files(temp_dir: &TempDir) -> PathBuf {
let locales_dir = temp_dir.path().join("locales");
fs::create_dir_all(&locales_dir).unwrap();
let en_content = r#"
test_key: "Test message"
test_with_param: "Hello {0}"
test_multiple_params: "User {0} has {1} items"
greeting: "Hello"
"#;
fs::write(locales_dir.join("en.yaml"), en_content).unwrap();
let zh_cn_content = r#"
test_key: "测试消息"
test_with_param: "你好 {0}"
test_multiple_params: "用户 {0} 有 {1} 个项目"
greeting: "你好"
"#;
fs::write(locales_dir.join("zh-cn.yaml"), zh_cn_content).unwrap();
locales_dir
}
#[test]
fn test_locale_struct() {
let mut strings = HashMap::new();
strings.insert("key1".to_string(), "value1".to_string());
strings.insert("key2".to_string(), "value2".to_string());
let locale = Locale { strings };
assert_eq!(locale.strings.len(), 2);
assert_eq!(locale.strings.get("key1"), Some(&"value1".to_string()));
}
#[test]
fn test_i18n_new() {
let temp_dir = TempDir::new().unwrap();
let _locales_dir = create_test_locale_files(&temp_dir);
let original_dir = env::current_dir().unwrap();
if env::set_current_dir(temp_dir.path()).is_ok() {
let result = I18n::new();
let _ = env::set_current_dir(original_dir);
match result {
Ok(i18n) => {
assert!(!i18n.current_locale.is_empty());
}
Err(_) => {
}
}
} else {
let _ = env::set_current_dir(original_dir);
}
}
#[test]
fn test_get_system_locale() {
let original_lang = env::var("LANG").ok();
unsafe {
env::remove_var("LANG");
}
let locale = I18n::get_system_locale();
assert!(locale == "en" || locale == "zh-cn");
unsafe {
env::set_var("LANG", "zh_CN.UTF-8");
}
let locale = I18n::get_system_locale();
assert_eq!(locale, "zh-cn");
unsafe {
env::set_var("LANG", "zh_TW.UTF-8");
}
let locale = I18n::get_system_locale();
assert_eq!(locale, "zh-cn");
unsafe {
env::set_var("LANG", "en_US.UTF-8");
}
let locale = I18n::get_system_locale();
assert_eq!(locale, "en");
unsafe {
env::set_var("LANG", "fr_FR.UTF-8");
}
let locale = I18n::get_system_locale();
assert_eq!(locale, "en");
match original_lang {
Some(lang) => unsafe {
env::set_var("LANG", lang);
},
None => unsafe {
env::remove_var("LANG");
},
}
}
#[test]
fn test_set_locale() {
let mut i18n = I18n {
current_locale: "en".to_string(),
locales: HashMap::new(),
};
let mut en_strings = HashMap::new();
en_strings.insert("test".to_string(), "Test".to_string());
i18n.locales.insert(
"en".to_string(),
Locale {
strings: en_strings,
},
);
let mut zh_strings = HashMap::new();
zh_strings.insert("test".to_string(), "测试".to_string());
i18n.locales.insert(
"zh-cn".to_string(),
Locale {
strings: zh_strings,
},
);
let initial_locale = i18n.current_locale.clone();
i18n.set_locale("zh-cn");
assert_eq!(i18n.current_locale, "zh-cn");
i18n.set_locale("invalid");
assert_eq!(i18n.current_locale, "zh-cn");
i18n.set_locale(&initial_locale);
assert_eq!(i18n.current_locale, initial_locale);
}
#[test]
fn test_get_current_locale() {
let i18n = I18n {
current_locale: "zh-cn".to_string(),
locales: HashMap::new(),
};
assert_eq!(i18n.get_current_locale(), "zh-cn");
}
#[test]
fn test_t() {
let mut i18n = I18n {
current_locale: "en".to_string(),
locales: HashMap::new(),
};
let mut strings = HashMap::new();
strings.insert("existing_key".to_string(), "Existing Value".to_string());
i18n.locales.insert("en".to_string(), Locale { strings });
assert_eq!(i18n.t("existing_key"), "Existing Value");
assert_eq!(i18n.t("non_existing_key"), "non_existing_key");
}
#[test]
fn test_tf() {
let mut i18n = I18n {
current_locale: "en".to_string(),
locales: HashMap::new(),
};
let mut strings = HashMap::new();
strings.insert("hello".to_string(), "Hello {0}".to_string());
strings.insert("multiple".to_string(), "User {0} has {1} items".to_string());
i18n.locales.insert("en".to_string(), Locale { strings });
assert_eq!(i18n.tf("hello", &["World"]), "Hello World");
assert_eq!(
i18n.tf("multiple", &["Alice", "5"]),
"User Alice has 5 items"
);
assert_eq!(i18n.tf("non_existing", &["test"]), "non_existing");
}
#[test]
fn test_available_locales() {
let locales = available_locales();
assert!(!locales.is_empty());
assert!(locales.contains(&"en".to_string()));
}
#[test]
fn test_is_locale_supported() {
let mut i18n = I18n {
current_locale: "en".to_string(),
locales: HashMap::new(),
};
i18n.locales.insert(
"en".to_string(),
Locale {
strings: HashMap::new(),
},
);
i18n.locales.insert(
"zh-cn".to_string(),
Locale {
strings: HashMap::new(),
},
);
assert!(i18n.is_locale_supported("en"));
assert!(i18n.is_locale_supported("zh-cn"));
assert!(!i18n.is_locale_supported("fr"));
assert!(!i18n.is_locale_supported("invalid"));
assert!(!i18n.is_locale_supported(""));
}
#[test]
fn test_global_functions_without_init() {
let result = t("test_key");
assert_eq!(result, "test_key");
let result = tf("test_key", &["param1", "param2"]);
assert_eq!(result, "test_key"); }
#[test]
fn test_set_global_locale() {
set_locale("zh-cn");
set_locale("en");
}
#[test]
fn test_init_i18n_with_locale() {
let temp_dir = TempDir::new().unwrap();
let _locales_dir = create_test_locale_files(&temp_dir);
let original_dir = env::current_dir().unwrap();
if env::set_current_dir(temp_dir.path()).is_ok() {
let result = init_i18n_with_locale("en");
let _ = env::set_current_dir(original_dir);
if result.is_ok() {
let message = t("test_key");
assert!(!message.is_empty());
}
}
}
#[test]
fn test_parameter_replacement() {
let i18n = I18n {
current_locale: "nonexistent".to_string(),
locales: HashMap::new(),
};
let result = i18n.tf("Hello {0}, you have {1} messages", &["Alice", "5"]);
assert_eq!(result, "Hello Alice, you have 5 messages");
}
#[test]
fn test_edge_cases() {
let i18n = I18n {
current_locale: "en".to_string(),
locales: HashMap::new(),
};
assert_eq!(i18n.t(""), "");
assert_eq!(i18n.tf("", &[]), "");
assert_eq!(i18n.tf("test", &[]), "test");
let mut i18n_empty = i18n.clone();
i18n_empty.current_locale = String::new();
assert_eq!(i18n_empty.t("test"), "test");
}
}