Skip to main content

zeroclaw_runtime/
i18n.rs

1//! Fluent-based i18n for tool descriptions.
2//!
3//! English descriptions are embedded via `include_str!` at compile time.
4//! Non-English locales are loaded from disk and override English per-key.
5
6use fluent::{FluentArgs, FluentBundle, FluentResource};
7use std::collections::HashMap;
8use std::sync::OnceLock;
9
10static DESCRIPTIONS: OnceLock<HashMap<String, String>> = OnceLock::new();
11static CLI_STRINGS: OnceLock<HashMap<String, String>> = OnceLock::new();
12static CLI_FTL_SOURCES: OnceLock<CliFtlSources> = OnceLock::new();
13static LOCALE: OnceLock<String> = OnceLock::new();
14
15struct CliFtlSources {
16    locale: String,
17    disk: Option<String>,
18    builtin: Option<&'static str>,
19}
20
21/// Initialize with a specific locale. No-op after first call.
22pub fn init(locale: &str) {
23    let locale = LOCALE.get_or_init(|| normalize_locale(locale));
24    DESCRIPTIONS.get_or_init(|| load_descriptions(locale));
25    CLI_STRINGS.get_or_init(|| load_cli_strings(locale));
26    CLI_FTL_SOURCES.get_or_init(|| load_cli_ftl_sources(locale));
27}
28
29/// Get a tool description by tool name (e.g. "shell", "file_read").
30pub fn get_tool_description(tool_name: &str) -> Option<&'static str> {
31    let map = DESCRIPTIONS.get_or_init(|| load_descriptions(active_locale()));
32    let key = format!("tool-{}", tool_name.replace('_', "-"));
33    map.get(&key).map(String::as_str)
34}
35
36/// Get a CLI string by key (e.g. "cli-config-about").
37pub fn get_cli_string(key: &str) -> Option<String> {
38    let map = CLI_STRINGS.get_or_init(|| load_cli_strings(active_locale()));
39    map.get(key).cloned()
40}
41
42/// Get a CLI string by key and format it with Fluent external arguments.
43pub fn get_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> Option<String> {
44    format_cli_string_with_args(cli_ftl_sources(), key, args)
45}
46
47/// Get a required CLI string by key, reporting missing Fluent strings centrally.
48pub fn get_required_cli_string(key: &str) -> String {
49    get_cli_string(key).unwrap_or_else(|| missing_cli_string(key))
50}
51
52/// Get a required CLI string by key and format it with Fluent external arguments.
53pub fn get_required_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> String {
54    get_cli_string_with_args(key, args).unwrap_or_else(|| missing_cli_string(key))
55}
56
57fn active_locale() -> &'static str {
58    LOCALE.get_or_init(detect_locale).as_str()
59}
60
61fn cli_ftl_sources() -> &'static CliFtlSources {
62    CLI_FTL_SOURCES.get_or_init(|| load_cli_ftl_sources(active_locale()))
63}
64
65fn missing_cli_string(key: &str) -> String {
66    ::zeroclaw_log::record!(
67        WARN,
68        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
69            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
70            .with_attrs(::serde_json::json!({"error_key": "i18n.missing_cli_string", "key": key})),
71        "missing CLI Fluent string"
72    );
73    format!("{{{key}}}")
74}
75
76fn load_descriptions(locale: &str) -> HashMap<String, String> {
77    let mut map = format_ftl_messages(include_str!("../locales/en/tools.ftl"), "en");
78    if locale != "en"
79        && let Some(locale_ftl) = load_ftl_from_disk(locale, "tools.ftl")
80    {
81        map.extend(format_ftl_messages(&locale_ftl, locale));
82    }
83    map
84}
85
86fn load_cli_strings(locale: &str) -> HashMap<String, String> {
87    let mut map = format_ftl_messages(include_str!("../locales/en/cli.ftl"), "en");
88    if locale != "en" {
89        if let Some(locale_ftl) = builtin_cli_ftl_source(locale) {
90            map.extend(format_ftl_messages(locale_ftl, locale));
91        }
92        if let Some(locale_ftl) = load_ftl_from_disk(locale, "cli.ftl") {
93            map.extend(format_ftl_messages(&locale_ftl, locale));
94        }
95    }
96    map
97}
98
99fn load_cli_ftl_sources(locale: &str) -> CliFtlSources {
100    CliFtlSources {
101        locale: locale.to_string(),
102        disk: (locale != "en")
103            .then(|| load_ftl_from_disk(locale, "cli.ftl"))
104            .flatten(),
105        builtin: (locale != "en")
106            .then(|| builtin_cli_ftl_source(locale))
107            .flatten(),
108    }
109}
110
111fn builtin_cli_ftl_source(locale: &str) -> Option<&'static str> {
112    match locale {
113        "zh-CN" => Some(include_str!("../locales/zh-CN/cli.ftl")),
114        _ => None,
115    }
116}
117
118fn format_cli_string_with_args(
119    sources: &CliFtlSources,
120    key: &str,
121    args: &[(&str, &str)],
122) -> Option<String> {
123    if let Some(locale_ftl) = sources.disk.as_deref()
124        && let Some(value) = format_ftl_message(locale_ftl, &sources.locale, key, args)
125    {
126        return Some(value);
127    }
128    if let Some(locale_ftl) = sources.builtin
129        && let Some(value) = format_ftl_message(locale_ftl, &sources.locale, key, args)
130    {
131        return Some(value);
132    }
133    format_ftl_message(include_str!("../locales/en/cli.ftl"), "en", key, args)
134}
135
136fn format_ftl_messages(ftl_source: &str, locale: &str) -> HashMap<String, String> {
137    let resource =
138        FluentResource::try_new(ftl_source.to_string()).unwrap_or_else(|(resource, _)| resource);
139    let language_identifier = locale.parse().unwrap_or_else(|_| "en".parse().unwrap());
140    let mut bundle = FluentBundle::new(vec![language_identifier]);
141    bundle.set_use_isolating(false);
142    let _ = bundle.add_resource(resource);
143
144    let mut map = HashMap::new();
145    for line in ftl_source.lines() {
146        let trimmed = line.trim();
147        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
148            continue;
149        }
150        if let Some(identifier) = trimmed.split(" =").next()
151            && let Some(message) = bundle.get_message(identifier)
152            && let Some(pattern) = message.value()
153        {
154            let mut errors = vec![];
155            let value = bundle.format_pattern(pattern, None, &mut errors);
156            if errors.is_empty() {
157                map.insert(identifier.to_string(), value.into_owned());
158            }
159        }
160    }
161    map
162}
163
164fn format_ftl_message(
165    ftl_source: &str,
166    locale: &str,
167    key: &str,
168    args: &[(&str, &str)],
169) -> Option<String> {
170    let resource =
171        FluentResource::try_new(ftl_source.to_string()).unwrap_or_else(|(resource, _)| resource);
172    let language_identifier = locale.parse().unwrap_or_else(|_| "en".parse().unwrap());
173    let mut bundle = FluentBundle::new(vec![language_identifier]);
174    bundle.set_use_isolating(false);
175    let _ = bundle.add_resource(resource);
176
177    let message = bundle.get_message(key)?;
178    let pattern = message.value()?;
179    let mut fluent_args = FluentArgs::new();
180    for (name, value) in args {
181        fluent_args.set(*name, *value);
182    }
183    let mut errors = vec![];
184    let value = bundle.format_pattern(pattern, Some(&fluent_args), &mut errors);
185    if errors.is_empty() {
186        Some(value.into_owned())
187    } else {
188        None
189    }
190}
191
192fn load_ftl_from_disk(locale: &str, filename: &str) -> Option<String> {
193    let workspace_path =
194        workspace_dir_from_config().map(|d| d.join("locales").join(locale).join(filename));
195    let search_paths = [workspace_path];
196    for path in search_paths.into_iter().flatten() {
197        if let Ok(content) = std::fs::read_to_string(&path) {
198            ::zeroclaw_log::record!(
199                DEBUG,
200                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
201                    .with_attrs(::serde_json::json!({"path": path.display().to_string()})),
202                "loaded locale FTL from disk"
203            );
204            return Some(content);
205        }
206    }
207    None
208}
209
210/// Detect locale: config.toml → "en".
211pub fn detect_locale() -> String {
212    locale_from_config().unwrap_or_else(|| "en".to_string())
213}
214
215fn read_config_table() -> Option<toml::Table> {
216    let base = directories::BaseDirs::new()?;
217    let candidates = [
218        base.home_dir().join(".zeroclaw/config.toml"),
219        base.config_dir().join("zeroclaw/config.toml"),
220    ];
221    for path in &candidates {
222        if let Ok(contents) = std::fs::read_to_string(path) {
223            return contents.parse().ok();
224        }
225    }
226    None
227}
228
229fn locale_from_config() -> Option<String> {
230    let table = read_config_table()?;
231    let locale = table.get("locale")?.as_str()?.trim().to_string();
232    if locale.is_empty() {
233        return None;
234    }
235    Some(normalize_locale(&locale))
236}
237
238fn workspace_dir_from_config() -> Option<std::path::PathBuf> {
239    if let Some(dir) = read_config_table()
240        .as_ref()
241        .and_then(|t| t.get("workspace_dir"))
242        .and_then(|v| v.as_str())
243    {
244        return Some(std::path::PathBuf::from(dir));
245    }
246    Some(
247        directories::BaseDirs::new()?
248            .home_dir()
249            .join(".zeroclaw/workspace"),
250    )
251}
252
253/// Normalize "zh_CN.UTF-8" → "zh-CN".
254pub fn normalize_locale(raw: &str) -> String {
255    raw.split('.').next().unwrap_or(raw).replace('_', "-")
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn english_descriptions_are_embedded() {
264        let map = format_ftl_messages(include_str!("../locales/en/tools.ftl"), "en");
265        assert!(map.contains_key("tool-shell"));
266        assert!(map.contains_key("tool-file-read"));
267        assert!(!map.contains_key("tool-nonexistent"));
268    }
269
270    #[test]
271    fn unknown_locale_falls_back_to_english() {
272        let map = load_descriptions("xx-FAKE");
273        assert!(map.contains_key("tool-shell"));
274    }
275
276    #[test]
277    fn cli_string_formats_external_args() {
278        let value = format_ftl_message(
279            "cli-test = Value { $value }",
280            "en",
281            "cli-test",
282            &[("value", "42")],
283        );
284        assert_eq!(value.as_deref(), Some("Value 42"));
285    }
286
287    #[test]
288    fn zh_cn_wechat_translations_preserve_machine_facing_tokens() {
289        let zh_cn = include_str!("../locales/zh-CN/cli.ftl");
290        let bind = format_ftl_message(
291            zh_cn,
292            "zh-CN",
293            "cli-wechat-send-bind-command",
294            &[("command", "/bind")],
295        )
296        .expect("zh-CN bind command should format");
297        assert!(bind.contains("WeChat"));
298        assert!(bind.contains("/bind"));
299        assert!(bind.contains("<code>"));
300
301        let success = format_ftl_message(zh_cn, "zh-CN", "cli-wechat-bound-success", &[])
302            .expect("zh-CN bind success should format");
303        assert!(success.contains("WeChat"));
304        assert!(success.contains("ZeroClaw"));
305    }
306
307    #[test]
308    fn zh_cn_cli_strings_load_from_builtin_source() {
309        let map = load_cli_strings("zh-CN");
310        assert_eq!(
311            map.get("cli-wechat-connected").map(String::as_str),
312            Some("✅ WeChat 已连接!")
313        );
314
315        let sources = load_cli_ftl_sources("zh-CN");
316        let value = format_cli_string_with_args(
317            &sources,
318            "cli-wechat-pairing-required",
319            &[("code", "123456")],
320        )
321        .expect("zh-CN built-in CLI source should format args");
322        assert!(value.contains("WeChat"));
323        assert!(value.contains("123456"));
324        assert!(value.contains("需要绑定"));
325    }
326
327    #[test]
328    fn argumented_cli_strings_fall_back_from_disk_to_builtin_locale() {
329        let sources = CliFtlSources {
330            locale: "zh-CN".to_string(),
331            disk: Some("cli-wechat-connected = stale workspace override".to_string()),
332            builtin: builtin_cli_ftl_source("zh-CN"),
333        };
334
335        let overridden = format_cli_string_with_args(&sources, "cli-wechat-connected", &[])
336            .expect("disk override should still win when present");
337        assert_eq!(overridden, "stale workspace override");
338
339        let built_in = format_cli_string_with_args(
340            &sources,
341            "cli-wechat-pairing-required",
342            &[("code", "123456")],
343        )
344        .expect("missing disk key should fall back to built-in zh-CN");
345        assert!(built_in.contains("123456"));
346        assert!(built_in.contains("需要绑定"));
347    }
348
349    #[test]
350    fn wechat_cli_strings_format_from_fluent() {
351        let keys = [
352            (
353                "cli-wechat-pairing-required",
354                &[("code", "123456")][..],
355                ["123456"].as_slice(),
356            ),
357            (
358                "cli-wechat-send-bind-command",
359                &[("command", "/bind")][..],
360                ["WeChat", "/bind", "<code>"].as_slice(),
361            ),
362            (
363                "cli-wechat-qr-login",
364                &[("attempt", "1"), ("max", "3")][..],
365                ["1", "3"].as_slice(),
366            ),
367            ("cli-wechat-scan-to-connect", &[][..], ["WeChat"].as_slice()),
368            (
369                "cli-wechat-qr-url",
370                &[("url", "https://example.test/qr")][..],
371                ["https://example.test/qr"].as_slice(),
372            ),
373            (
374                "cli-wechat-qr-expired-giving-up",
375                &[("max", "3")][..],
376                ["3"].as_slice(),
377            ),
378            ("cli-wechat-qr-fetch-failed", &[][..], ["WeChat"].as_slice()),
379            (
380                "cli-wechat-qr-fetch-status-failed",
381                &[("status", "500"), ("body", "server error")][..],
382                ["WeChat", "500", "server error"].as_slice(),
383            ),
384            (
385                "cli-wechat-missing-response-field",
386                &[("field", "qrcode")][..],
387                ["WeChat", "qrcode"].as_slice(),
388            ),
389            ("cli-wechat-scanned-confirm", &[][..], [].as_slice()),
390            ("cli-wechat-qr-expired-refreshing", &[][..], [].as_slice()),
391            (
392                "cli-wechat-login-confirmed-missing-field",
393                &[("field", "bot_token")][..],
394                ["bot_token"].as_slice(),
395            ),
396            ("cli-wechat-connected", &[][..], ["WeChat"].as_slice()),
397            (
398                "cli-wechat-bound-success",
399                &[][..],
400                ["WeChat", "ZeroClaw"].as_slice(),
401            ),
402            ("cli-wechat-invalid-bind-code", &[][..], [].as_slice()),
403        ];
404        for source in [
405            (include_str!("../locales/en/cli.ftl"), "en"),
406            (include_str!("../locales/zh-CN/cli.ftl"), "zh-CN"),
407        ] {
408            for (key, args, expected_parts) in keys {
409                let value = format_ftl_message(source.0, source.1, key, args)
410                    .unwrap_or_else(|| panic!("{key} should format in {}", source.1));
411                for expected in expected_parts {
412                    assert!(
413                        value.contains(expected),
414                        "{key} in {} should preserve {expected}",
415                        source.1
416                    );
417                }
418            }
419        }
420    }
421
422    #[test]
423    fn skills_install_cli_strings_format_from_fluent() {
424        type FormatCase<'a> = (&'a str, &'a [(&'a str, &'a str)], &'a [&'a str]);
425
426        let en_cases: &[FormatCase<'_>] = &[
427            (
428                "cli-skills-install-start",
429                &[("source", "example-skill")][..],
430                &["Installing skill from", "example-skill"],
431            ),
432            (
433                "cli-skills-install-resolving-registry",
434                &[("source", "example-skill")][..],
435                &["  Resolving", "example-skill", "skills registry"],
436            ),
437            (
438                "cli-skills-install-installed-audited",
439                &[("status", "OK"), ("path", "/tmp/example"), ("files", "3")][..],
440                &["  OK", "/tmp/example", "3 files scanned"],
441            ),
442            (
443                "cli-skills-install-security-audit-completed",
444                &[][..],
445                &["  Security audit completed successfully"],
446            ),
447            (
448                "cli-skills-install-tier-official",
449                &[("name", "example-skill"), ("version", "1.2.3")][..],
450                &["example-skill", "1.2.3", "Official"],
451            ),
452            (
453                "cli-skills-install-tier-community",
454                &[("name", "example-skill"), ("version", "1.2.3")][..],
455                &[
456                    "example-skill",
457                    "1.2.3",
458                    "Community submission",
459                    "zeroclaw skills audit example-skill",
460                ],
461            ),
462        ];
463        let zh_cn_cases: &[FormatCase<'_>] = &[
464            (
465                "cli-skills-install-start",
466                &[("source", "example-skill")][..],
467                &["正在安装技能来源", "example-skill"],
468            ),
469            (
470                "cli-skills-install-resolving-registry",
471                &[("source", "example-skill")][..],
472                &["  正在从技能注册表解析", "example-skill"],
473            ),
474            (
475                "cli-skills-install-installed-audited",
476                &[("status", "OK"), ("path", "/tmp/example"), ("files", "3")][..],
477                &["  OK", "/tmp/example", "已扫描 3 个文件"],
478            ),
479            (
480                "cli-skills-install-security-audit-completed",
481                &[][..],
482                &["  安全审计已成功完成"],
483            ),
484            (
485                "cli-skills-install-tier-official",
486                &[("name", "example-skill"), ("version", "1.2.3")][..],
487                &["example-skill", "1.2.3", "官方"],
488            ),
489            (
490                "cli-skills-install-tier-community",
491                &[("name", "example-skill"), ("version", "1.2.3")][..],
492                &[
493                    "example-skill",
494                    "1.2.3",
495                    "社区提交",
496                    "zeroclaw skills audit example-skill",
497                ],
498            ),
499        ];
500
501        for (source, locale, cases) in [
502            (include_str!("../locales/en/cli.ftl"), "en", en_cases),
503            (
504                include_str!("../locales/zh-CN/cli.ftl"),
505                "zh-CN",
506                zh_cn_cases,
507            ),
508        ] {
509            for (key, args, expected_parts) in cases {
510                let value = format_ftl_message(source, locale, key, args)
511                    .unwrap_or_else(|| panic!("{key} should format in {locale}"));
512                for expected in *expected_parts {
513                    assert!(
514                        value.contains(expected),
515                        "{key} in {locale} should preserve {expected:?}"
516                    );
517                }
518            }
519        }
520    }
521
522    #[test]
523    fn normalize_locale_strips_encoding() {
524        assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
525        assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
526        assert_eq!(normalize_locale("fr"), "fr");
527    }
528
529    #[test]
530    fn detect_locale_defaults_to_en_without_config() {
531        // Locale is config-only. Without a config.toml present, must return "en".
532        assert_eq!(detect_locale(), "en");
533    }
534}