Skip to main content

llm_manager/tui/
i18n.rs

1use std::collections::HashMap;
2use std::fs;
3use std::sync::LazyLock;
4
5pub static TRANSLATIONS: LazyLock<HashMap<String, HashMap<&'static str, &'static str>>> =
6    LazyLock::new(|| {
7        let mut translations = HashMap::new();
8
9        // 1. Load embedded translations at compile-time as default fallback
10        let embedded_locales: &[(&str, &str)] = &[
11            ("en", include_str!("../../locales/en.json")),
12            ("fr", include_str!("../../locales/fr.json")),
13            ("it", include_str!("../../locales/it.json")),
14            ("de", include_str!("../../locales/de.json")),
15        ];
16
17        for (lang, json_content) in embedded_locales {
18            if let Ok(parsed) = serde_json::from_str::<HashMap<String, String>>(json_content) {
19                let mut static_map = HashMap::new();
20                for (k, v) in parsed {
21                    let k_str: &'static str = String::leak(k);
22                    let v_str: &'static str = String::leak(v);
23                    static_map.insert(k_str, v_str);
24                }
25                translations.insert(lang.to_string(), static_map);
26            }
27        }
28
29        // 2. Load filesystem translations to override or add custom languages
30        let locale_dir = locale_dir();
31        if let Ok(entries) = fs::read_dir(&locale_dir) {
32            for entry in entries.flatten() {
33                let path = entry.path();
34                if let Some(filename) = path.file_name().and_then(|n| n.to_str())
35                    && let Some(lang) = filename.strip_suffix(".json")
36                    && let Ok(contents) = fs::read_to_string(&path)
37                    && let Ok(parsed) = serde_json::from_str::<HashMap<String, String>>(&contents)
38                {
39                    let mut static_map = HashMap::new();
40                    for (k, v) in parsed {
41                        let k_str: &'static str = String::leak(k);
42                        let v_str: &'static str = String::leak(v);
43                        static_map.insert(k_str, v_str);
44                    }
45                    translations.insert(lang.to_string(), static_map);
46                }
47            }
48        }
49
50        translations
51    });
52
53static CURRENT_LANG: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
54
55fn locale_dir() -> std::path::PathBuf {
56    if let Ok(exe) = std::env::current_exe()
57        && let Some(dir) = exe.parent()
58    {
59        let candidate = dir.join("locales");
60        if candidate.is_dir() {
61            return candidate;
62        }
63    }
64
65    if let Ok(p) = std::env::var("LLM_MANAGER_LOCALES") {
66        let path = std::path::Path::new(&p);
67        if path.is_dir() {
68            return path.to_path_buf();
69        }
70    }
71
72    std::path::Path::new("locales").to_path_buf()
73}
74
75pub fn set_language(lang: &str) {
76    let mut current = CURRENT_LANG.lock().unwrap();
77    *current = Some(lang.to_string());
78}
79
80#[allow(dead_code)]
81pub fn reset_language() {
82    let mut current = CURRENT_LANG.lock().unwrap();
83    *current = None;
84}
85
86#[allow(dead_code)]
87pub fn get_language() -> String {
88    let current = CURRENT_LANG.lock().unwrap();
89    current.clone().unwrap_or_else(|| "en".to_string())
90}
91
92pub fn t(key: &str) -> &'static str {
93    let lang = get_language();
94
95    if let Some(lang_map) = TRANSLATIONS.get(&lang)
96        && let Some(&value) = lang_map.get(key)
97    {
98        return value;
99    }
100
101    if let Some(en_map) = TRANSLATIONS.get("en")
102        && let Some(&value) = en_map.get(key)
103    {
104        return value;
105    }
106
107    Box::leak(key.to_string().into_boxed_str())
108}
109
110#[macro_export]
111macro_rules! t {
112    ($key:expr) => {
113        $crate::tui::i18n::t($key)
114    };
115}
116
117pub fn field_help(field_id: &str) -> String {
118    let key = format!("field.help.{}", field_id);
119    t(&key).to_string()
120}
121
122pub fn t_fmt(key: &str, args: &[String]) -> String {
123    let template = t(key);
124    let mut result = template.to_string();
125    for arg in args {
126        if let Some(pos) = result.find("{}") {
127            result.replace_range(pos..pos + 2, arg);
128        }
129    }
130    result
131}
132
133#[macro_export]
134macro_rules! t_fmt {
135    ($key:expr $(,)?) => {
136        $crate::tui::i18n::t($key).to_string()
137    };
138    ($key:expr, $arg1:expr $(,)?) => {
139        $crate::tui::i18n::t_fmt($key, &[$arg1.to_string()])
140    };
141    ($key:expr, $arg1:expr, $arg2:expr $(,)?) => {
142        $crate::tui::i18n::t_fmt($key, &[$arg1.to_string(), $arg2.to_string()])
143    };
144    ($key:expr, $arg1:expr, $arg2:expr, $arg3:expr $(,)?) => {
145        $crate::tui::i18n::t_fmt(
146            $key,
147            &[$arg1.to_string(), $arg2.to_string(), $arg3.to_string()],
148        )
149    };
150    ($key:expr, $arg1:expr, $arg2:expr, $arg3:expr, $arg4:expr $(,)?) => {
151        $crate::tui::i18n::t_fmt(
152            $key,
153            &[
154                $arg1.to_string(),
155                $arg2.to_string(),
156                $arg3.to_string(),
157                $arg4.to_string(),
158            ],
159        )
160    };
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_t_falls_back_to_key() {
169        let result = t("nonexistent.key.xyz");
170        assert_eq!(result, "nonexistent.key.xyz");
171    }
172
173    #[test]
174    fn test_set_language() {
175        set_language("fr");
176        assert_eq!(get_language(), "fr");
177        set_language("en");
178        assert_eq!(get_language(), "en");
179    }
180
181    #[test]
182    fn test_embedded_translations_exist() {
183        // Ensure that English translations are correctly embedded and retrieved
184        let en_map = TRANSLATIONS
185            .get("en")
186            .expect("English translations not found");
187        assert_eq!(
188            *en_map.get("panel.title.models_active").unwrap(),
189            " MODELS (F1) "
190        );
191
192        // Ensure French translations are loaded and distinct
193        let fr_map = TRANSLATIONS
194            .get("fr")
195            .expect("French translations not found");
196        let fr_val = *fr_map.get("panel.title.models_active").unwrap();
197        assert_ne!(fr_val, " MODELS (F1) ");
198
199        // Ensure Italian translations are loaded and distinct
200        let it_map = TRANSLATIONS
201            .get("it")
202            .expect("Italian translations not found");
203        let it_val = *it_map.get("panel.title.models_active").unwrap();
204        assert_ne!(it_val, " MODELS (F1) ");
205    }
206}