1use 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
15static AVAILABLE_LOCALES: OnceLock<Vec<LocaleOption>> = OnceLock::new();
19
20const LOCALES_TOML: &str = include_str!("../../../locales.toml");
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct LocaleOption {
26 pub code: String,
27 pub label: String,
28}
29
30pub 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
63pub 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
71pub 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
78pub 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
84pub 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
89pub fn get_required_cli_string(key: &str) -> String {
91 get_cli_string(key).unwrap_or_else(|| missing_cli_string(key))
92}
93
94pub 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#[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
252fn 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
278pub fn detect_locale() -> String {
280 locale_from_config().unwrap_or_else(|| "en".to_string())
281}
282
283fn read_config_table() -> Option<toml::Table> {
284 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
317fn 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
329pub 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 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 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 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}