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 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 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 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 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 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}