1use parking_lot::RwLock;
42use serde_json::Value;
43use std::collections::HashMap;
44use std::path::Path;
45use std::sync::Arc;
46use thiserror::Error;
47use tokio::fs;
48
49#[derive(Debug, Error)]
55pub enum I18nError {
56 #[error("语言包文件读取失败: {path} — {source}")]
58 FileRead {
59 path: String,
61 #[source]
63 source: std::io::Error,
64 },
65 #[error("语言包文件解析失败: {path} — {source}")]
67 Parse {
68 path: String,
70 #[source]
72 source: serde_json::Error,
73 },
74}
75
76#[derive(Debug, Clone)]
99pub struct I18n {
100 data: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
102 current_lang: Arc<RwLock<String>>,
104 default_lang: Arc<RwLock<String>>,
106}
107
108impl Default for I18n {
109 fn default() -> Self {
111 Self {
112 data: Arc::new(RwLock::new(HashMap::new())),
113 current_lang: Arc::new(RwLock::new("zh-cn".to_string())),
114 default_lang: Arc::new(RwLock::new("zh-cn".to_string())),
115 }
116 }
117}
118
119impl I18n {
120 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn with_default_lang(default_lang: &str) -> Self {
133 let i18n = Self::default();
134 *i18n.default_lang.write() = default_lang.to_string();
135 *i18n.current_lang.write() = default_lang.to_string();
136 i18n
137 }
138
139 pub fn set(&self, lang: &str, key: &str, value: &str) {
153 let mut data = self.data.write();
154 data.entry(lang.to_string())
155 .or_default()
156 .insert(key.to_string(), value.to_string());
157 }
158
159 pub fn get(
189 &self,
190 key: &str,
191 vars: &HashMap<String, String>,
192 lang: Option<&str>,
193 ) -> Option<String> {
194 let data = self.data.read();
195
196 let current_lang = self.current_lang.read().clone();
198 let default_lang = self.default_lang.read().clone();
199
200 let lookup_langs: Vec<&str> = match lang {
201 Some(l) => vec![l, &default_lang],
202 None => vec![¤t_lang, &default_lang],
203 };
204
205 for lookup_lang in lookup_langs {
207 if let Some(lang_data) = data.get(lookup_lang) {
208 if let Some(raw_value) = lang_data.get(key) {
209 return Some(self.interpolate(raw_value, vars));
210 }
211 }
212 }
213
214 None
215 }
216
217 pub fn get_simple(&self, key: &str, lang: Option<&str>) -> Option<String> {
228 let empty_vars = HashMap::new();
229 self.get(key, &empty_vars, lang)
230 }
231
232 pub fn has(&self, key: &str, lang: Option<&str>) -> bool {
253 let data = self.data.read();
254
255 let current_lang = self.current_lang.read().clone();
256 let default_lang = self.default_lang.read().clone();
257
258 let lookup_langs: Vec<&str> = match lang {
259 Some(l) => vec![l, &default_lang],
260 None => vec![¤t_lang, &default_lang],
261 };
262
263 for lookup_lang in lookup_langs {
264 if let Some(lang_data) = data.get(lookup_lang) {
265 if lang_data.contains_key(key) {
266 return true;
267 }
268 }
269 }
270
271 false
272 }
273
274 pub async fn load_from_file(
300 &self,
301 path: impl AsRef<Path>,
302 lang: &str,
303 ) -> Result<(), I18nError> {
304 let path_ref = path.as_ref();
305 let content = fs::read_to_string(path_ref)
306 .await
307 .map_err(|e| I18nError::FileRead {
308 path: path_ref.display().to_string(),
309 source: e,
310 })?;
311
312 self.load_from_json_str(&content, lang, &path_ref.display().to_string())
313 }
314
315 pub fn load_from_json_str(
327 &self,
328 json_str: &str,
329 lang: &str,
330 path_for_error: &str,
331 ) -> Result<(), I18nError> {
332 let parsed: HashMap<String, Value> =
333 serde_json::from_str(json_str).map_err(|e| I18nError::Parse {
334 path: path_for_error.to_string(),
335 source: e,
336 })?;
337
338 let mut data = self.data.write();
339 let lang_data = data.entry(lang.to_string()).or_default();
340
341 for (key, value) in parsed {
342 if let Some(s) = value.as_str() {
344 lang_data.insert(key, s.to_string());
345 }
346 }
347
348 Ok(())
349 }
350
351 pub fn current_lang(&self) -> String {
359 self.current_lang.read().clone()
360 }
361
362 pub fn set_current_lang(&self, lang: &str) {
374 *self.current_lang.write() = lang.to_string();
375 }
376
377 pub fn set_default_lang(&self, lang: &str) {
389 *self.default_lang.write() = lang.to_string();
390 }
391
392 pub fn default_lang(&self) -> String {
394 self.default_lang.read().clone()
395 }
396
397 fn interpolate(&self, template: &str, vars: &HashMap<String, String>) -> String {
412 let mut result = template.to_string();
413
414 for (key, value) in vars {
415 let colon_placeholder = format!(":{}", key);
417 result = result.replace(&colon_placeholder, value);
418
419 let brace_placeholder = format!("{{{}}}", key);
421 result = result.replace(&brace_placeholder, value);
422 }
423
424 result
425 }
426
427 pub fn all_for_lang(&self, lang: &str) -> HashMap<String, String> {
437 let data = self.data.read();
438 data.get(lang).cloned().unwrap_or_default()
439 }
440
441 pub fn available_langs(&self) -> Vec<String> {
447 let data = self.data.read();
448 data.keys().cloned().collect()
449 }
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::io::Write;
460
461 #[test]
463 fn test_new_i18n_default_lang() {
464 let i18n = I18n::new();
465 assert_eq!(i18n.current_lang(), "zh-cn");
466 assert_eq!(i18n.default_lang(), "zh-cn");
467 }
468
469 #[test]
471 fn test_with_default_lang() {
472 let i18n = I18n::with_default_lang("en-us");
473 assert_eq!(i18n.current_lang(), "en-us");
474 assert_eq!(i18n.default_lang(), "en-us");
475 }
476
477 #[test]
479 fn test_set_get_basic() {
480 let i18n = I18n::new();
481 i18n.set("zh-cn", "hello", "你好");
482 i18n.set("en-us", "hello", "Hello");
483
484 assert_eq!(
485 i18n.get_simple("hello", Some("zh-cn")),
486 Some("你好".to_string())
487 );
488 assert_eq!(
489 i18n.get_simple("hello", Some("en-us")),
490 Some("Hello".to_string())
491 );
492 }
493
494 #[test]
496 fn test_get_uses_current_lang() {
497 let i18n = I18n::with_default_lang("zh-cn");
498 i18n.set("zh-cn", "hello", "你好");
499 i18n.set("en-us", "hello", "Hello");
500
501 assert_eq!(i18n.get_simple("hello", None), Some("你好".to_string()));
503
504 i18n.set_current_lang("en-us");
506 assert_eq!(i18n.get_simple("hello", None), Some("Hello".to_string()));
507 }
508
509 #[test]
511 fn test_interpolate_colon_format() {
512 let i18n = I18n::new();
513 i18n.set("en-us", "greeting", "Hello, :name! Welcome to :place.");
514
515 let mut vars = HashMap::new();
516 vars.insert("name".to_string(), "John".to_string());
517 vars.insert("place".to_string(), "Rust".to_string());
518
519 let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
520 assert_eq!(result, "Hello, John! Welcome to Rust.");
521 }
522
523 #[test]
525 fn test_interpolate_brace_format() {
526 let i18n = I18n::new();
527 i18n.set("en-us", "greeting", "Hello, {name}! Welcome to {place}.");
528
529 let mut vars = HashMap::new();
530 vars.insert("name".to_string(), "John".to_string());
531 vars.insert("place".to_string(), "Rust".to_string());
532
533 let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
534 assert_eq!(result, "Hello, John! Welcome to Rust.");
535 }
536
537 #[test]
539 fn test_interpolate_mixed_format() {
540 let i18n = I18n::new();
541 i18n.set("en-us", "msg", ":name has {count} items in :place.");
542
543 let mut vars = HashMap::new();
544 vars.insert("name".to_string(), "Alice".to_string());
545 vars.insert("count".to_string(), "5".to_string());
546 vars.insert("place".to_string(), "basket".to_string());
547
548 let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
549 assert_eq!(result, "Alice has 5 items in basket.");
550 }
551
552 #[test]
554 fn test_interpolate_missing_var_kept() {
555 let i18n = I18n::new();
556 i18n.set("en-us", "msg", "Hello, :name! Count: {count}");
557
558 let mut vars = HashMap::new();
559 vars.insert("name".to_string(), "John".to_string());
560 let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
563 assert_eq!(result, "Hello, John! Count: {count}");
564 }
565
566 #[test]
568 fn test_fallback_to_default_lang() {
569 let i18n = I18n::with_default_lang("zh-cn");
570 i18n.set("zh-cn", "only_in_zh", "只有中文");
571 i18n.set_current_lang("en-us");
575 assert_eq!(
576 i18n.get_simple("only_in_zh", None),
577 Some("只有中文".to_string())
578 );
579 }
580
581 #[test]
583 fn test_has() {
584 let i18n = I18n::new();
585 i18n.set("zh-cn", "existing", "存在");
586
587 assert!(i18n.has("existing", Some("zh-cn")));
588 assert!(!i18n.has("nonexistent", Some("zh-cn")));
589 }
590
591 #[test]
593 fn test_has_fallback() {
594 let i18n = I18n::with_default_lang("zh-cn");
595 i18n.set("zh-cn", "fallback_key", "回退");
596
597 i18n.set_current_lang("en-us");
598 assert!(i18n.has("fallback_key", None));
599 }
600
601 #[test]
603 fn test_load_from_json_str() {
604 let i18n = I18n::new();
605 let json = r#"{"hello": "Hello, :name!", "bye": "Goodbye"}"#;
606 i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
607
608 assert_eq!(
609 i18n.get_simple("hello", Some("en-us")),
610 Some("Hello, :name!".to_string())
611 );
612 assert_eq!(
613 i18n.get_simple("bye", Some("en-us")),
614 Some("Goodbye".to_string())
615 );
616 }
617
618 #[tokio::test]
620 async fn test_load_from_file() {
621 let temp_dir = std::env::temp_dir().join("sz_rust_i18n_test");
622 let _ = std::fs::create_dir_all(&temp_dir);
623 let lang_file = temp_dir.join("en-us.json");
624
625 let mut file = std::fs::File::create(&lang_file).unwrap();
626 writeln!(file, r#"{{"hello": "Hello!", "bye": "Goodbye"}}"#).unwrap();
627 drop(file);
628
629 let i18n = I18n::new();
630 i18n.load_from_file(&lang_file, "en-us").await.unwrap();
631
632 assert_eq!(
633 i18n.get_simple("hello", Some("en-us")),
634 Some("Hello!".to_string())
635 );
636 assert_eq!(
637 i18n.get_simple("bye", Some("en-us")),
638 Some("Goodbye".to_string())
639 );
640
641 let _ = std::fs::remove_dir_all(&temp_dir);
642 }
643
644 #[tokio::test]
646 async fn test_load_nonexistent_file_errors() {
647 let i18n = I18n::new();
648 let result = i18n
649 .load_from_file("/nonexistent/path/lang.json", "en-us")
650 .await;
651 assert!(result.is_err());
652 match result {
653 Err(I18nError::FileRead { .. }) => {}
654 _ => panic!("期望 FileRead 错误"),
655 }
656 }
657
658 #[test]
660 fn test_load_invalid_json_errors() {
661 let i18n = I18n::new();
662 let result = i18n.load_from_json_str("{invalid json}", "en-us", "<test>");
663 assert!(result.is_err());
664 match result {
665 Err(I18nError::Parse { .. }) => {}
666 _ => panic!("期望 Parse 错误"),
667 }
668 }
669
670 #[test]
672 fn test_load_json_non_string_values_skipped() {
673 let i18n = I18n::new();
674 let json = r#"{"valid": "string", "number": 123, "bool": true, "null_val": null}"#;
675 i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
676
677 assert_eq!(
678 i18n.get_simple("valid", Some("en-us")),
679 Some("string".to_string())
680 );
681 assert_eq!(i18n.get_simple("number", Some("en-us")), None);
682 assert_eq!(i18n.get_simple("bool", Some("en-us")), None);
683 assert_eq!(i18n.get_simple("null_val", Some("en-us")), None);
684 }
685
686 #[test]
688 fn test_set_and_get_current_lang() {
689 let i18n = I18n::new();
690 assert_eq!(i18n.current_lang(), "zh-cn");
691
692 i18n.set_current_lang("en-us");
693 assert_eq!(i18n.current_lang(), "en-us");
694
695 i18n.set_current_lang("ja-jp");
696 assert_eq!(i18n.current_lang(), "ja-jp");
697 }
698
699 #[test]
701 fn test_set_and_get_default_lang() {
702 let i18n = I18n::new();
703 assert_eq!(i18n.default_lang(), "zh-cn");
704
705 i18n.set_default_lang("en-us");
706 assert_eq!(i18n.default_lang(), "en-us");
707 }
708
709 #[test]
711 fn test_all_for_lang() {
712 let i18n = I18n::new();
713 i18n.set("zh-cn", "key1", "值1");
714 i18n.set("zh-cn", "key2", "值2");
715 i18n.set("en-us", "key1", "value1");
716
717 let zh_data = i18n.all_for_lang("zh-cn");
718 assert_eq!(zh_data.len(), 2);
719 assert_eq!(zh_data.get("key1"), Some(&"值1".to_string()));
720 assert_eq!(zh_data.get("key2"), Some(&"值2".to_string()));
721
722 let en_data = i18n.all_for_lang("en-us");
723 assert_eq!(en_data.len(), 1);
724 assert_eq!(en_data.get("key1"), Some(&"value1".to_string()));
725
726 let ja_data = i18n.all_for_lang("ja-jp");
728 assert!(ja_data.is_empty());
729 }
730
731 #[test]
733 fn test_available_langs() {
734 let i18n = I18n::new();
735 i18n.set("zh-cn", "key", "值");
736 i18n.set("en-us", "key", "value");
737 i18n.set("ja-jp", "key", "値");
738
739 let langs = i18n.available_langs();
740 assert_eq!(langs.len(), 3);
741 assert!(langs.contains(&"zh-cn".to_string()));
742 assert!(langs.contains(&"en-us".to_string()));
743 assert!(langs.contains(&"ja-jp".to_string()));
744 }
745
746 #[test]
748 fn test_multiple_load_accumulates() {
749 let i18n = I18n::new();
750 i18n.load_from_json_str(r#"{"key1": "value1"}"#, "en-us", "<test1>")
751 .unwrap();
752 i18n.load_from_json_str(r#"{"key2": "value2"}"#, "en-us", "<test2>")
753 .unwrap();
754
755 assert_eq!(
756 i18n.get_simple("key1", Some("en-us")),
757 Some("value1".to_string())
758 );
759 assert_eq!(
760 i18n.get_simple("key2", Some("en-us")),
761 Some("value2".to_string())
762 );
763 }
764
765 #[test]
767 fn test_get_nonexistent_returns_none() {
768 let i18n = I18n::new();
769 assert_eq!(i18n.get_simple("nonexistent", Some("zh-cn")), None);
770 assert_eq!(i18n.get_simple("nonexistent", None), None);
771 }
772
773 #[test]
775 fn test_interpolate_empty_vars() {
776 let i18n = I18n::new();
777 i18n.set("en-us", "msg", "Hello, :name!");
778
779 let empty_vars = HashMap::new();
780 let result = i18n.get("msg", &empty_vars, Some("en-us")).unwrap();
781 assert_eq!(result, "Hello, :name!");
783 }
784}