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
15/// The canonical locale registry, embedded from repo-root `locales.toml` at
16/// compile time. Parsed once into a `'static` list so callers (e.g. the RPC
17/// `locales/list` handler) get a long-lived reference with no runtime file I/O.
18static AVAILABLE_LOCALES: OnceLock<Vec<LocaleOption>> = OnceLock::new();
19
20const LOCALES_TOML: &str = include_str!("../../../locales.toml");
21
22/// One selectable locale: its `code` (e.g. `ja`) and display `label`
23/// (e.g. `日本語`).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct LocaleOption {
26    pub code: String,
27    pub label: String,
28}
29
30/// Locales the build knows about, from the embedded `locales.toml`. Cheap:
31/// parsed once, then returns a borrow of the cached `'static` vector.
32pub fn available_locales() -> &'static [LocaleOption] {
33    AVAILABLE_LOCALES
34        .get_or_init(|| {
35            let table: toml::Value =
36                toml::from_str(LOCALES_TOML).expect("embedded locales.toml is valid TOML");
37            table
38                .get("locale")
39                .and_then(|v| v.as_array())
40                .map(|arr| {
41                    arr.iter()
42                        .filter_map(|e| {
43                            let code = e.get("code").and_then(|v| v.as_str())?;
44                            let label = e.get("label").and_then(|v| v.as_str())?;
45                            Some(LocaleOption {
46                                code: code.to_string(),
47                                label: label.to_string(),
48                            })
49                        })
50                        .collect()
51                })
52                .unwrap_or_default()
53        })
54        .as_slice()
55}
56
57struct CliFtlSources {
58    locale: String,
59    disk: Option<String>,
60    builtin: Option<&'static str>,
61}
62
63/// Initialize with a specific locale. No-op after first call.
64pub fn init(locale: &str) {
65    let locale = LOCALE.get_or_init(|| normalize_locale(locale));
66    DESCRIPTIONS.get_or_init(|| load_descriptions(locale));
67    CLI_STRINGS.get_or_init(|| load_cli_strings(locale));
68    CLI_FTL_SOURCES.get_or_init(|| load_cli_ftl_sources(locale));
69}
70
71/// Get a tool description by tool name (e.g. "shell", "file_read").
72pub fn get_tool_description(tool_name: &str) -> Option<&'static str> {
73    let map = DESCRIPTIONS.get_or_init(|| load_descriptions(active_locale()));
74    let key = format!("tool-{}", tool_name.replace('_', "-"));
75    map.get(&key).map(String::as_str)
76}
77
78/// Get a CLI string by key (e.g. "cli-config-about").
79pub fn get_cli_string(key: &str) -> Option<String> {
80    let map = CLI_STRINGS.get_or_init(|| load_cli_strings(active_locale()));
81    map.get(key).cloned()
82}
83
84/// Get a CLI string by key and format it with Fluent external arguments.
85pub fn get_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> Option<String> {
86    format_cli_string_with_args(cli_ftl_sources(), key, args)
87}
88
89/// Get a required CLI string by key, reporting missing Fluent strings centrally.
90pub fn get_required_cli_string(key: &str) -> String {
91    get_cli_string(key).unwrap_or_else(|| missing_cli_string(key))
92}
93
94/// Get a required CLI string by key and format it with Fluent external arguments.
95pub fn get_required_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> String {
96    get_cli_string_with_args(key, args).unwrap_or_else(|| missing_cli_string(key))
97}
98
99fn active_locale() -> &'static str {
100    LOCALE.get_or_init(detect_locale).as_str()
101}
102
103fn cli_ftl_sources() -> &'static CliFtlSources {
104    CLI_FTL_SOURCES.get_or_init(|| load_cli_ftl_sources(active_locale()))
105}
106
107/// Resolve a CLI string against the embedded English catalogue only, ignoring
108/// the process locale and the filesystem. Used by tests that assert the
109/// canonical English wording without depending on the host's configured
110/// locale (the global `LOCALE` OnceLock would otherwise make them flaky).
111#[cfg(test)]
112pub(crate) fn get_english_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> String {
113    let english = CliFtlSources {
114        locale: "en".to_string(),
115        disk: None,
116        builtin: None,
117    };
118    format_cli_string_with_args(&english, key, args).unwrap_or_else(|| missing_cli_string(key))
119}
120
121fn missing_cli_string(key: &str) -> String {
122    ::zeroclaw_log::record!(
123        WARN,
124        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
125            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
126            .with_attrs(::serde_json::json!({"error_key": "i18n.missing_cli_string", "key": key})),
127        "missing CLI Fluent string"
128    );
129    format!("{{{key}}}")
130}
131
132fn load_descriptions(locale: &str) -> HashMap<String, String> {
133    let mut map = format_ftl_messages(include_str!("../locales/en/tools.ftl"), "en");
134    if locale != "en"
135        && let Some(locale_ftl) = load_ftl_from_disk(locale, "tools.ftl")
136    {
137        map.extend(format_ftl_messages(&locale_ftl, locale));
138    }
139    map
140}
141
142fn load_cli_strings(locale: &str) -> HashMap<String, String> {
143    let mut map = format_ftl_messages(include_str!("../locales/en/cli.ftl"), "en");
144    if locale != "en" {
145        if let Some(locale_ftl) = builtin_cli_ftl_source(locale) {
146            map.extend(format_ftl_messages(locale_ftl, locale));
147        }
148        if let Some(locale_ftl) = load_ftl_from_disk(locale, "cli.ftl") {
149            map.extend(format_ftl_messages(&locale_ftl, locale));
150        }
151    }
152    map
153}
154
155fn load_cli_ftl_sources(locale: &str) -> CliFtlSources {
156    CliFtlSources {
157        locale: locale.to_string(),
158        disk: (locale != "en")
159            .then(|| load_ftl_from_disk(locale, "cli.ftl"))
160            .flatten(),
161        builtin: (locale != "en")
162            .then(|| builtin_cli_ftl_source(locale))
163            .flatten(),
164    }
165}
166
167fn builtin_cli_ftl_source(locale: &str) -> Option<&'static str> {
168    match locale {
169        "zh-CN" => Some(include_str!("../locales/zh-CN/cli.ftl")),
170        _ => None,
171    }
172}
173
174fn format_cli_string_with_args(
175    sources: &CliFtlSources,
176    key: &str,
177    args: &[(&str, &str)],
178) -> Option<String> {
179    if let Some(locale_ftl) = sources.disk.as_deref()
180        && let Some(value) = format_ftl_message(locale_ftl, &sources.locale, key, args)
181    {
182        return Some(value);
183    }
184    if let Some(locale_ftl) = sources.builtin
185        && let Some(value) = format_ftl_message(locale_ftl, &sources.locale, key, args)
186    {
187        return Some(value);
188    }
189    format_ftl_message(include_str!("../locales/en/cli.ftl"), "en", key, args)
190}
191
192fn format_ftl_messages(ftl_source: &str, locale: &str) -> HashMap<String, String> {
193    let resource =
194        FluentResource::try_new(ftl_source.to_string()).unwrap_or_else(|(resource, _)| resource);
195    let language_identifier = locale.parse().unwrap_or_else(|_| "en".parse().unwrap());
196    let mut bundle = FluentBundle::new(vec![language_identifier]);
197    bundle.set_use_isolating(false);
198    let _ = bundle.add_resource(resource);
199
200    let mut map = HashMap::new();
201    for line in ftl_source.lines() {
202        let trimmed = line.trim();
203        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
204            continue;
205        }
206        if let Some(identifier) = trimmed.split(" =").next()
207            && let Some(message) = bundle.get_message(identifier)
208            && let Some(pattern) = message.value()
209        {
210            let mut errors = vec![];
211            let value = bundle.format_pattern(pattern, None, &mut errors);
212            if errors.is_empty() {
213                map.insert(identifier.to_string(), value.into_owned());
214            }
215        }
216    }
217    map
218}
219
220fn format_ftl_message(
221    ftl_source: &str,
222    locale: &str,
223    key: &str,
224    args: &[(&str, &str)],
225) -> Option<String> {
226    let resource =
227        FluentResource::try_new(ftl_source.to_string()).unwrap_or_else(|(resource, _)| resource);
228    let language_identifier = locale.parse().unwrap_or_else(|_| "en".parse().unwrap());
229    let mut bundle = FluentBundle::new(vec![language_identifier]);
230    bundle.set_use_isolating(false);
231    let _ = bundle.add_resource(resource);
232
233    let message = bundle.get_message(key)?;
234    let pattern = message.value()?;
235    let mut fluent_args = FluentArgs::new();
236    for (name, value) in args {
237        fluent_args.set(*name, *value);
238    }
239    let mut errors = vec![];
240    let value = bundle.format_pattern(pattern, Some(&fluent_args), &mut errors);
241    if errors.is_empty() {
242        Some(value.into_owned())
243    } else {
244        None
245    }
246}
247
248fn load_ftl_from_disk(locale: &str, filename: &str) -> Option<String> {
249    load_ftl_with_reader(locale, filename, |p| std::fs::read_to_string(p).ok())
250}
251
252/// Path-resolution + read wiring for locale FTL, with an injectable reader so
253/// tests can verify which path is consulted without touching the real
254/// filesystem. Production passes `std::fs::read_to_string`.
255fn load_ftl_with_reader(
256    locale: &str,
257    filename: &str,
258    read: impl Fn(&std::path::Path) -> Option<String>,
259) -> Option<String> {
260    let path = zeroclaw_config::schema::ftl_locale_dir(locale)
261        .ok()
262        .map(|d| d.join(filename));
263    let search_paths = [path];
264    for path in search_paths.into_iter().flatten() {
265        if let Some(content) = read(&path) {
266            ::zeroclaw_log::record!(
267                DEBUG,
268                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
269                    .with_attrs(::serde_json::json!({"path": path.display().to_string()})),
270                "loaded locale FTL from disk"
271            );
272            return Some(content);
273        }
274    }
275    None
276}
277
278/// Detect locale: config.toml → "en".
279pub fn detect_locale() -> String {
280    locale_from_config().unwrap_or_else(|| "en".to_string())
281}
282
283fn read_config_table() -> Option<toml::Table> {
284    // An explicit config dir is authoritative: when set, locale detection and
285    // FTL loading resolve only against it and never fall back to the home
286    // config. This keeps the lookup hermetic — tests (and sandboxed runs) point
287    // it at a known dir without the host's real ~/.zeroclaw/config.toml leaking
288    // in. Without this, locale detection reads the developer's own config and
289    // is non-deterministic across machines.
290    if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
291        let trimmed = custom.trim();
292        if !trimmed.is_empty() {
293            let path = std::path::PathBuf::from(trimmed).join("config.toml");
294            return std::fs::read_to_string(&path)
295                .ok()
296                .and_then(|c| c.parse().ok());
297        }
298    }
299
300    let mut candidates: Vec<std::path::PathBuf> = Vec::new();
301    if let Some(base) = directories::BaseDirs::new() {
302        candidates.push(base.home_dir().join(".zeroclaw/config.toml"));
303        candidates.push(base.config_dir().join("zeroclaw/config.toml"));
304    }
305    for path in &candidates {
306        if let Ok(contents) = std::fs::read_to_string(path) {
307            return contents.parse().ok();
308        }
309    }
310    None
311}
312
313fn locale_from_config() -> Option<String> {
314    locale_from_table(read_config_table())
315}
316
317/// Pure: extract a normalized locale from an already-parsed config table.
318/// Split out from `locale_from_config` so it is testable without filesystem or
319/// environment access — no test may touch the real FS to verify locale logic.
320fn locale_from_table(table: Option<toml::Table>) -> Option<String> {
321    let table = table?;
322    let locale = table.get("locale")?.as_str()?.trim().to_string();
323    if locale.is_empty() {
324        return None;
325    }
326    Some(normalize_locale(&locale))
327}
328
329/// Normalize "zh_CN.UTF-8" → "zh-CN".
330pub fn normalize_locale(raw: &str) -> String {
331    raw.split('.').next().unwrap_or(raw).replace('_', "-")
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn english_descriptions_are_embedded() {
340        let map = format_ftl_messages(include_str!("../locales/en/tools.ftl"), "en");
341        assert!(map.contains_key("tool-shell"));
342        assert!(map.contains_key("tool-file-read"));
343        assert!(!map.contains_key("tool-nonexistent"));
344    }
345
346    #[test]
347    fn unknown_locale_falls_back_to_english() {
348        let map = load_descriptions("xx-FAKE");
349        assert!(map.contains_key("tool-shell"));
350    }
351
352    #[test]
353    fn cli_string_formats_external_args() {
354        let value = format_ftl_message(
355            "cli-test = Value { $value }",
356            "en",
357            "cli-test",
358            &[("value", "42")],
359        );
360        assert_eq!(value.as_deref(), Some("Value 42"));
361    }
362
363    #[test]
364    fn zh_cn_wechat_translations_preserve_machine_facing_tokens() {
365        let zh_cn = include_str!("../locales/zh-CN/cli.ftl");
366        let bind = format_ftl_message(
367            zh_cn,
368            "zh-CN",
369            "cli-wechat-send-bind-command",
370            &[("command", "/bind")],
371        )
372        .expect("zh-CN bind command should format");
373        assert!(bind.contains("WeChat"));
374        assert!(bind.contains("/bind"));
375        assert!(bind.contains("<code>"));
376
377        let success = format_ftl_message(zh_cn, "zh-CN", "cli-wechat-bound-success", &[])
378            .expect("zh-CN bind success should format");
379        assert!(success.contains("WeChat"));
380        assert!(success.contains("ZeroClaw"));
381    }
382
383    #[test]
384    fn zh_cn_cli_strings_load_from_builtin_source() {
385        let map = load_cli_strings("zh-CN");
386        assert_eq!(
387            map.get("cli-wechat-connected").map(String::as_str),
388            Some("✅ WeChat 已连接!")
389        );
390
391        let sources = load_cli_ftl_sources("zh-CN");
392        let value = format_cli_string_with_args(
393            &sources,
394            "cli-wechat-pairing-required",
395            &[("code", "123456")],
396        )
397        .expect("zh-CN built-in CLI source should format args");
398        assert!(value.contains("WeChat"));
399        assert!(value.contains("123456"));
400        assert!(value.contains("需要绑定"));
401    }
402
403    #[test]
404    fn argumented_cli_strings_fall_back_from_disk_to_builtin_locale() {
405        let sources = CliFtlSources {
406            locale: "zh-CN".to_string(),
407            disk: Some("cli-wechat-connected = stale workspace override".to_string()),
408            builtin: builtin_cli_ftl_source("zh-CN"),
409        };
410
411        let overridden = format_cli_string_with_args(&sources, "cli-wechat-connected", &[])
412            .expect("disk override should still win when present");
413        assert_eq!(overridden, "stale workspace override");
414
415        let built_in = format_cli_string_with_args(
416            &sources,
417            "cli-wechat-pairing-required",
418            &[("code", "123456")],
419        )
420        .expect("missing disk key should fall back to built-in zh-CN");
421        assert!(built_in.contains("123456"));
422        assert!(built_in.contains("需要绑定"));
423    }
424
425    #[test]
426    fn wechat_cli_strings_format_from_fluent() {
427        let keys = [
428            (
429                "cli-wechat-pairing-required",
430                &[("code", "123456")][..],
431                ["123456"].as_slice(),
432            ),
433            (
434                "cli-wechat-send-bind-command",
435                &[("command", "/bind")][..],
436                ["WeChat", "/bind", "<code>"].as_slice(),
437            ),
438            (
439                "cli-wechat-qr-login",
440                &[("attempt", "1"), ("max", "3")][..],
441                ["1", "3"].as_slice(),
442            ),
443            ("cli-wechat-scan-to-connect", &[][..], ["WeChat"].as_slice()),
444            (
445                "cli-wechat-qr-url",
446                &[("url", "https://example.test/qr")][..],
447                ["https://example.test/qr"].as_slice(),
448            ),
449            (
450                "cli-wechat-qr-expired-giving-up",
451                &[("max", "3")][..],
452                ["3"].as_slice(),
453            ),
454            ("cli-wechat-qr-fetch-failed", &[][..], ["WeChat"].as_slice()),
455            (
456                "cli-wechat-qr-fetch-status-failed",
457                &[("status", "500"), ("body", "server error")][..],
458                ["WeChat", "500", "server error"].as_slice(),
459            ),
460            (
461                "cli-wechat-missing-response-field",
462                &[("field", "qrcode")][..],
463                ["WeChat", "qrcode"].as_slice(),
464            ),
465            ("cli-wechat-scanned-confirm", &[][..], [].as_slice()),
466            ("cli-wechat-qr-expired-refreshing", &[][..], [].as_slice()),
467            (
468                "cli-wechat-login-confirmed-missing-field",
469                &[("field", "bot_token")][..],
470                ["bot_token"].as_slice(),
471            ),
472            ("cli-wechat-connected", &[][..], ["WeChat"].as_slice()),
473            (
474                "cli-wechat-bound-success",
475                &[][..],
476                ["WeChat", "ZeroClaw"].as_slice(),
477            ),
478            ("cli-wechat-invalid-bind-code", &[][..], [].as_slice()),
479        ];
480        for source in [
481            (include_str!("../locales/en/cli.ftl"), "en"),
482            (include_str!("../locales/zh-CN/cli.ftl"), "zh-CN"),
483        ] {
484            for (key, args, expected_parts) in keys {
485                let value = format_ftl_message(source.0, source.1, key, args)
486                    .unwrap_or_else(|| panic!("{key} should format in {}", source.1));
487                for expected in expected_parts {
488                    assert!(
489                        value.contains(expected),
490                        "{key} in {} should preserve {expected}",
491                        source.1
492                    );
493                }
494            }
495        }
496    }
497
498    #[test]
499    fn skills_install_cli_strings_format_from_fluent() {
500        type FormatCase<'a> = (&'a str, &'a [(&'a str, &'a str)], &'a [&'a str]);
501
502        let en_cases: &[FormatCase<'_>] = &[
503            (
504                "cli-skills-install-start",
505                &[("source", "example-skill")][..],
506                &["Installing skill from", "example-skill"],
507            ),
508            (
509                "cli-skills-install-resolving-registry",
510                &[("source", "example-skill")][..],
511                &["  Resolving", "example-skill", "skills registry"],
512            ),
513            (
514                "cli-skills-install-installed-audited",
515                &[("status", "OK"), ("path", "/tmp/example"), ("files", "3")][..],
516                &["  OK", "/tmp/example", "3 files scanned"],
517            ),
518            (
519                "cli-skills-install-security-audit-completed",
520                &[][..],
521                &["  Security audit completed successfully"],
522            ),
523            (
524                "cli-skills-install-tier-official",
525                &[("name", "example-skill"), ("version", "1.2.3")][..],
526                &["example-skill", "1.2.3", "Official"],
527            ),
528            (
529                "cli-skills-install-tier-community",
530                &[("name", "example-skill"), ("version", "1.2.3")][..],
531                &[
532                    "example-skill",
533                    "1.2.3",
534                    "Community submission",
535                    "zeroclaw skills audit example-skill",
536                ],
537            ),
538        ];
539        let zh_cn_cases: &[FormatCase<'_>] = &[
540            (
541                "cli-skills-install-start",
542                &[("source", "example-skill")][..],
543                &["正在安装技能来源", "example-skill"],
544            ),
545            (
546                "cli-skills-install-resolving-registry",
547                &[("source", "example-skill")][..],
548                &["  正在从技能注册表解析", "example-skill"],
549            ),
550            (
551                "cli-skills-install-installed-audited",
552                &[("status", "OK"), ("path", "/tmp/example"), ("files", "3")][..],
553                &["  OK", "/tmp/example", "已扫描 3 个文件"],
554            ),
555            (
556                "cli-skills-install-security-audit-completed",
557                &[][..],
558                &["  安全审计已成功完成"],
559            ),
560            (
561                "cli-skills-install-tier-official",
562                &[("name", "example-skill"), ("version", "1.2.3")][..],
563                &["example-skill", "1.2.3", "官方"],
564            ),
565            (
566                "cli-skills-install-tier-community",
567                &[("name", "example-skill"), ("version", "1.2.3")][..],
568                &[
569                    "example-skill",
570                    "1.2.3",
571                    "社区提交",
572                    "zeroclaw skills audit example-skill",
573                ],
574            ),
575        ];
576
577        for (source, locale, cases) in [
578            (include_str!("../locales/en/cli.ftl"), "en", en_cases),
579            (
580                include_str!("../locales/zh-CN/cli.ftl"),
581                "zh-CN",
582                zh_cn_cases,
583            ),
584        ] {
585            for (key, args, expected_parts) in cases {
586                let value = format_ftl_message(source, locale, key, args)
587                    .unwrap_or_else(|| panic!("{key} should format in {locale}"));
588                for expected in *expected_parts {
589                    assert!(
590                        value.contains(expected),
591                        "{key} in {locale} should preserve {expected:?}"
592                    );
593                }
594            }
595        }
596    }
597
598    #[test]
599    fn normalize_locale_strips_encoding() {
600        assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
601        assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
602        assert_eq!(normalize_locale("fr"), "fr");
603    }
604
605    #[test]
606    fn detect_locale_defaults_to_en_without_config() {
607        // Locale is config-only. read_config_table() is pure parsing over a
608        // string; verify the fallback contract without touching the real
609        // filesystem or env. An absent/locale-less table must yield "en".
610        assert_eq!(locale_from_table(None), None);
611        let no_locale: toml::Table = "model = \"x\"".parse().unwrap();
612        assert_eq!(locale_from_table(Some(no_locale)), None);
613        let empty_locale: toml::Table = "locale = \"\"".parse().unwrap();
614        assert_eq!(locale_from_table(Some(empty_locale)), None);
615        // detect_locale layers the "en" fallback over locale_from_table.
616        assert_eq!(
617            locale_from_table(None).unwrap_or_else(|| "en".to_string()),
618            "en"
619        );
620    }
621
622    #[test]
623    fn load_ftl_from_disk_reads_config_dir_data_ftl() {
624        // Verify the loader resolves a locale's FTL path and returns the
625        // reader's content — using an in-memory reader so no real filesystem
626        // or environment is touched. The path must carry the locale and
627        // filename so a fetched catalogue at <dir>/.../<locale>/<file> is found.
628        let seen = std::cell::RefCell::new(Vec::<std::path::PathBuf>::new());
629        let loaded = load_ftl_with_reader("xx", "cli.ftl", |p| {
630            seen.borrow_mut().push(p.to_path_buf());
631            Some("cli-probe = hit\n".to_string())
632        });
633        assert_eq!(loaded.as_deref(), Some("cli-probe = hit\n"));
634
635        let paths = seen.borrow();
636        assert!(!paths.is_empty(), "reader must be consulted with a path");
637        let p = paths[0].to_string_lossy();
638        assert!(p.contains("xx"), "path must carry the locale: {p}");
639        assert!(p.ends_with("cli.ftl"), "path must target the file: {p}");
640    }
641}