1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone)]
14pub struct ToolDescriptions {
15 locale_descriptions: HashMap<String, String>,
17 english_fallback: HashMap<String, String>,
19 locale: String,
21}
22
23#[derive(Debug, serde::Deserialize)]
25struct DescriptionFile {
26 #[serde(default)]
27 tools: HashMap<String, String>,
28}
29
30impl ToolDescriptions {
31 pub fn load(locale: &str, search_dirs: &[PathBuf]) -> Self {
41 let locale_descriptions = load_locale_file(locale, search_dirs);
42
43 let english_fallback = if locale == "en" {
44 HashMap::new()
45 } else {
46 load_locale_file("en", search_dirs)
47 };
48
49 debug!(
50 locale = locale,
51 locale_keys = locale_descriptions.len(),
52 english_keys = english_fallback.len(),
53 "tool descriptions loaded"
54 );
55
56 Self {
57 locale_descriptions,
58 english_fallback,
59 locale: locale.to_string(),
60 }
61 }
62
63 pub fn get(&self, tool_name: &str) -> Option<&str> {
68 self.locale_descriptions
69 .get(tool_name)
70 .or_else(|| self.english_fallback.get(tool_name))
71 .map(String::as_str)
72 }
73
74 pub fn locale(&self) -> &str {
76 &self.locale
77 }
78
79 pub fn empty() -> Self {
81 Self {
82 locale_descriptions: HashMap::new(),
83 english_fallback: HashMap::new(),
84 locale: "en".to_string(),
85 }
86 }
87}
88
89pub fn detect_locale() -> String {
94 if let Ok(val) = std::env::var("CONSTRUCT_LOCALE") {
95 let val = val.trim().to_string();
96 if !val.is_empty() {
97 return normalize_locale(&val);
98 }
99 }
100 for var in &["LANG", "LC_ALL"] {
101 if let Ok(val) = std::env::var(var) {
102 let locale = normalize_locale(&val);
103 if locale != "C" && locale != "POSIX" && !locale.is_empty() {
104 return locale;
105 }
106 }
107 }
108 "en".to_string()
109}
110
111fn normalize_locale(raw: &str) -> String {
114 let base = raw.split('.').next().unwrap_or(raw);
116 base.replace('_', "-")
118}
119
120pub fn default_search_dirs(workspace_dir: &Path) -> Vec<PathBuf> {
126 let mut dirs = vec![workspace_dir.to_path_buf()];
127
128 if let Ok(exe) = std::env::current_exe() {
129 if let Some(parent) = exe.parent() {
130 dirs.push(parent.to_path_buf());
131 }
132 }
133
134 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
136 if !dirs.contains(&manifest_dir) {
137 dirs.push(manifest_dir);
138 }
139
140 dirs
141}
142
143fn load_locale_file(locale: &str, search_dirs: &[PathBuf]) -> HashMap<String, String> {
145 let filename = format!("tool_descriptions/{locale}.toml");
146
147 for dir in search_dirs {
148 let path = dir.join(&filename);
149 match std::fs::read_to_string(&path) {
150 Ok(contents) => match toml::from_str::<DescriptionFile>(&contents) {
151 Ok(parsed) => {
152 debug!(path = %path.display(), keys = parsed.tools.len(), "loaded locale file");
153 return parsed.tools;
154 }
155 Err(e) => {
156 debug!(path = %path.display(), error = %e, "failed to parse locale file");
157 }
158 },
159 Err(_) => {
160 }
162 }
163 }
164
165 debug!(
166 locale = locale,
167 "no locale file found in any search directory"
168 );
169 HashMap::new()
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::fs;
176
177 fn write_locale_file(dir: &Path, locale: &str, content: &str) {
179 let td = dir.join("tool_descriptions");
180 fs::create_dir_all(&td).unwrap();
181 fs::write(td.join(format!("{locale}.toml")), content).unwrap();
182 }
183
184 #[test]
185 fn load_english_descriptions() {
186 let tmp = tempfile::tempdir().unwrap();
187 write_locale_file(
188 tmp.path(),
189 "en",
190 r#"[tools]
191shell = "Execute a shell command"
192file_read = "Read file contents"
193"#,
194 );
195 let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
196 assert_eq!(descs.get("shell"), Some("Execute a shell command"));
197 assert_eq!(descs.get("file_read"), Some("Read file contents"));
198 assert_eq!(descs.get("nonexistent"), None);
199 assert_eq!(descs.locale(), "en");
200 }
201
202 #[test]
203 fn fallback_to_english_when_locale_key_missing() {
204 let tmp = tempfile::tempdir().unwrap();
205 write_locale_file(
206 tmp.path(),
207 "en",
208 r#"[tools]
209shell = "Execute a shell command"
210file_read = "Read file contents"
211"#,
212 );
213 write_locale_file(
214 tmp.path(),
215 "zh-CN",
216 r#"[tools]
217shell = "在工作区目录中执行 shell 命令"
218"#,
219 );
220 let descs = ToolDescriptions::load("zh-CN", &[tmp.path().to_path_buf()]);
221 assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
223 assert_eq!(descs.get("file_read"), Some("Read file contents"));
225 assert_eq!(descs.locale(), "zh-CN");
226 }
227
228 #[test]
229 fn fallback_when_locale_file_missing() {
230 let tmp = tempfile::tempdir().unwrap();
231 write_locale_file(
232 tmp.path(),
233 "en",
234 r#"[tools]
235shell = "Execute a shell command"
236"#,
237 );
238 let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
240 assert_eq!(descs.get("shell"), Some("Execute a shell command"));
242 assert_eq!(descs.locale(), "fr");
243 }
244
245 #[test]
246 fn fallback_when_no_files_exist() {
247 let tmp = tempfile::tempdir().unwrap();
248 let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
249 assert_eq!(descs.get("shell"), None);
250 }
251
252 #[test]
253 fn empty_always_returns_none() {
254 let descs = ToolDescriptions::empty();
255 assert_eq!(descs.get("shell"), None);
256 assert_eq!(descs.locale(), "en");
257 }
258
259 #[test]
260 fn detect_locale_from_env() {
261 let saved = std::env::var("CONSTRUCT_LOCALE").ok();
263 let saved_lang = std::env::var("LANG").ok();
264
265 unsafe { std::env::set_var("CONSTRUCT_LOCALE", "ja-JP") };
267 assert_eq!(detect_locale(), "ja-JP");
268
269 unsafe { std::env::remove_var("CONSTRUCT_LOCALE") };
271 unsafe { std::env::set_var("LANG", "zh_CN.UTF-8") };
273 assert_eq!(detect_locale(), "zh-CN");
274
275 match saved {
277 Some(v) => unsafe { std::env::set_var("CONSTRUCT_LOCALE", v) },
279 None => unsafe { std::env::remove_var("CONSTRUCT_LOCALE") },
281 }
282 match saved_lang {
283 Some(v) => unsafe { std::env::set_var("LANG", v) },
285 None => unsafe { std::env::remove_var("LANG") },
287 }
288 }
289
290 #[test]
291 fn normalize_locale_strips_encoding() {
292 assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
293 assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
294 assert_eq!(normalize_locale("fr"), "fr");
295 assert_eq!(normalize_locale("pt_BR"), "pt-BR");
296 }
297
298 #[test]
299 fn config_locale_overrides_env() {
300 let tmp = tempfile::tempdir().unwrap();
305 write_locale_file(
306 tmp.path(),
307 "de",
308 r#"[tools]
309shell = "Einen Shell-Befehl im Arbeitsverzeichnis ausführen"
310"#,
311 );
312 let descs = ToolDescriptions::load("de", &[tmp.path().to_path_buf()]);
313 assert_eq!(
314 descs.get("shell"),
315 Some("Einen Shell-Befehl im Arbeitsverzeichnis ausführen")
316 );
317 }
318}