Skip to main content

construct/
i18n.rs

1//! Internationalization support for tool descriptions.
2//!
3//! Loads tool descriptions from TOML locale files in `tool_descriptions/`.
4//! Falls back to English when a locale file or specific key is missing,
5//! and ultimately falls back to the hardcoded `tool.description()` value
6//! if no file-based description exists.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12/// Container for locale-specific tool descriptions loaded from TOML files.
13#[derive(Debug, Clone)]
14pub struct ToolDescriptions {
15    /// Descriptions from the requested locale (may be empty if file missing).
16    locale_descriptions: HashMap<String, String>,
17    /// English fallback descriptions (always loaded when locale != "en").
18    english_fallback: HashMap<String, String>,
19    /// The resolved locale tag (e.g. "en", "zh-CN").
20    locale: String,
21}
22
23/// TOML structure: `[tools]` table mapping tool name -> description string.
24#[derive(Debug, serde::Deserialize)]
25struct DescriptionFile {
26    #[serde(default)]
27    tools: HashMap<String, String>,
28}
29
30impl ToolDescriptions {
31    /// Load descriptions for the given locale.
32    ///
33    /// `search_dirs` lists directories to probe for `tool_descriptions/<locale>.toml`.
34    /// The first directory containing a matching file wins.
35    ///
36    /// Resolution:
37    /// 1. Look up tool name in the locale file.
38    /// 2. If missing (or locale file absent), look up in `en.toml`.
39    /// 3. If still missing, callers fall back to `tool.description()`.
40    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    /// Get the description for a tool by name.
64    ///
65    /// Returns `Some(description)` if found in the locale file or English fallback.
66    /// Returns `None` if neither file contains the key (caller should use hardcoded).
67    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    /// The resolved locale tag.
75    pub fn locale(&self) -> &str {
76        &self.locale
77    }
78
79    /// Create an empty instance that always returns `None` (hardcoded fallback).
80    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
89/// Detect the user's preferred locale from environment variables.
90///
91/// Checks `CONSTRUCT_LOCALE`, then `LANG`, then `LC_ALL`.
92/// Returns "en" if none are set or parseable.
93pub 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
111/// Normalize a raw locale string (e.g. "zh_CN.UTF-8") to a tag we use
112/// for file lookup (e.g. "zh-CN").
113fn normalize_locale(raw: &str) -> String {
114    // Strip encoding suffix (.UTF-8, .utf8, etc.)
115    let base = raw.split('.').next().unwrap_or(raw);
116    // Replace underscores with hyphens for BCP-47-ish consistency
117    base.replace('_', "-")
118}
119
120/// Build the default set of search directories for locale files.
121///
122/// 1. The workspace directory itself (for project-local overrides).
123/// 2. The binary's parent directory (for installed distributions).
124/// 3. The compile-time `CARGO_MANIFEST_DIR` as a final fallback during dev.
125pub 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    // During development, also check the project root (where Cargo.toml lives).
135    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
143/// Try to load and parse a locale TOML file from the first matching search dir.
144fn 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                // File not found in this directory, try next.
161            }
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    /// Helper: create a temp dir with a `tool_descriptions/<locale>.toml` file.
178    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        // Translated key returns Chinese.
222        assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
223        // Missing key falls back to English.
224        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        // Request a locale that has no file.
239        let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
240        // Falls back to English.
241        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        // Save and restore env.
262        let saved = std::env::var("CONSTRUCT_LOCALE").ok();
263        let saved_lang = std::env::var("LANG").ok();
264
265        // SAFETY: test-only, single-threaded test runner.
266        unsafe { std::env::set_var("CONSTRUCT_LOCALE", "ja-JP") };
267        assert_eq!(detect_locale(), "ja-JP");
268
269        // SAFETY: test-only, single-threaded test runner.
270        unsafe { std::env::remove_var("CONSTRUCT_LOCALE") };
271        // SAFETY: test-only, single-threaded test runner.
272        unsafe { std::env::set_var("LANG", "zh_CN.UTF-8") };
273        assert_eq!(detect_locale(), "zh-CN");
274
275        // Restore.
276        match saved {
277            // SAFETY: test-only, single-threaded test runner.
278            Some(v) => unsafe { std::env::set_var("CONSTRUCT_LOCALE", v) },
279            // SAFETY: test-only, single-threaded test runner.
280            None => unsafe { std::env::remove_var("CONSTRUCT_LOCALE") },
281        }
282        match saved_lang {
283            // SAFETY: test-only, single-threaded test runner.
284            Some(v) => unsafe { std::env::set_var("LANG", v) },
285            // SAFETY: test-only, single-threaded test runner.
286            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        // This tests the precedence logic: if config provides a locale,
301        // it should be used instead of detect_locale().
302        // The actual override happens at the call site in prompt.rs / loop_.rs,
303        // so here we just verify ToolDescriptions works with an explicit locale.
304        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}