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
15struct CliFtlSources {
16 locale: String,
17 disk: Option<String>,
18 builtin: Option<&'static str>,
19}
20
21pub 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
29pub 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
36pub 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
42pub 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
47pub fn get_required_cli_string(key: &str) -> String {
49 get_cli_string(key).unwrap_or_else(|| missing_cli_string(key))
50}
51
52pub 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
210pub 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
253pub 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 assert_eq!(detect_locale(), "en");
533 }
534}