greentic_operator/
operator_i18n.rs1use anyhow::Context;
2use include_dir::{Dir, include_dir};
3use once_cell::sync::Lazy;
4use std::collections::BTreeMap;
5use std::sync::RwLock;
6use unic_langid::LanguageIdentifier;
7
8pub type Map = BTreeMap<String, String>;
9
10static OPERATOR_CLI_I18N: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/i18n/operator_cli");
11static CURRENT_LOCALE: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(select_locale(None)));
12
13pub fn select_locale(cli_locale: Option<&str>) -> String {
14 let supported = supported_locales();
15
16 if let Some(cli) = cli_locale
17 && let Some(found) = resolve_supported(cli, &supported)
18 {
19 return found;
20 }
21
22 for env_key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
23 if let Ok(raw) = std::env::var(env_key)
24 && let Some(found) = resolve_supported(&raw, &supported)
25 {
26 return found;
27 }
28 }
29
30 if let Some(raw) = sys_locale::get_locale()
31 && let Some(found) = resolve_supported(&raw, &supported)
32 {
33 return found;
34 }
35
36 "en".to_string()
37}
38
39pub fn set_locale(locale: impl Into<String>) {
40 let normalized = greentic_i18n::normalize_locale(&locale.into());
41 if let Ok(mut guard) = CURRENT_LOCALE.write() {
42 *guard = normalized;
43 }
44}
45
46pub fn current_locale() -> String {
47 CURRENT_LOCALE
48 .read()
49 .map(|value| value.clone())
50 .unwrap_or_else(|_| select_locale(None))
51}
52
53pub fn tr(key: &str, fallback: &str) -> String {
54 tr_for_locale(key, fallback, ¤t_locale())
55}
56
57pub fn trf(key: &str, fallback: &str, args: &[&str]) -> String {
58 let mut rendered = tr(key, fallback);
59 for value in args {
60 rendered = rendered.replacen("{}", value, 1);
61 }
62 rendered
63}
64
65pub fn tr_for_locale(key: &str, fallback: &str, locale: &str) -> String {
66 match load_cli(locale) {
67 Ok(map) => map
68 .get(key)
69 .cloned()
70 .unwrap_or_else(|| fallback.to_string()),
71 Err(_) => fallback.to_string(),
72 }
73}
74
75pub fn load_cli(locale: &str) -> anyhow::Result<Map> {
76 for candidate in locale_candidates(locale) {
77 if let Some(file) = OPERATOR_CLI_I18N.get_file(&candidate) {
78 let raw = file.contents_utf8().ok_or_else(|| {
79 anyhow::anyhow!("operator cli i18n file is not valid UTF-8: {candidate}")
80 })?;
81 return serde_json::from_str(raw)
82 .with_context(|| format!("parse embedded operator cli i18n map {candidate}"));
83 }
84 }
85 Ok(Map::new())
86}
87
88fn locale_candidates(locale: &str) -> Vec<String> {
89 let mut out = Vec::new();
90 let mut push_candidate = |candidate: String| {
91 if !out.iter().any(|existing| existing == &candidate) {
92 out.push(candidate);
93 }
94 };
95 let trimmed = locale.trim();
96 if !trimmed.is_empty() {
97 push_candidate(format!("{}.json", trimmed));
98 let primary = greentic_i18n::normalize_locale(trimmed);
99 push_candidate(format!("{}.json", primary));
100 }
101 push_candidate("en.json".to_string());
102 out
103}
104
105fn normalize_locale_tag(raw: &str) -> Option<String> {
106 let mut cleaned = raw.trim();
107 if cleaned.is_empty() {
108 return None;
109 }
110 if cleaned.eq_ignore_ascii_case("c") || cleaned.eq_ignore_ascii_case("posix") {
111 return None;
112 }
113 if let Some((head, _)) = cleaned.split_once('.') {
114 cleaned = head;
115 }
116 if let Some((head, _)) = cleaned.split_once('@') {
117 cleaned = head;
118 }
119 if cleaned.eq_ignore_ascii_case("c") || cleaned.eq_ignore_ascii_case("posix") {
120 return None;
121 }
122 let normalized = cleaned.replace('_', "-");
123 normalized
124 .parse::<LanguageIdentifier>()
125 .ok()
126 .map(|value| value.to_string())
127}
128
129fn base_language(tag: &str) -> Option<String> {
130 tag.split('-')
131 .next()
132 .map(|value| value.to_ascii_lowercase())
133}
134
135fn resolve_supported(candidate: &str, supported: &[String]) -> Option<String> {
136 let normalized = normalize_locale_tag(candidate)?;
137 if supported.iter().any(|value| value == &normalized) {
138 return Some(normalized);
139 }
140 let base = base_language(&normalized)?;
141 if supported.iter().any(|value| value == &base) {
142 return Some(base);
143 }
144 None
145}
146
147fn supported_locales() -> Vec<String> {
148 let mut out = OPERATOR_CLI_I18N
149 .files()
150 .filter_map(|file| {
151 file.path()
152 .file_name()
153 .and_then(|name| name.to_str())
154 .and_then(|name| name.strip_suffix(".json"))
155 .map(|name| name.to_string())
156 })
157 .collect::<Vec<_>>();
158 out.sort();
159 out.dedup();
160 out
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn prefers_requested_locale_before_english() {
169 let map = load_cli("de-DE").expect("load de locale");
170 assert_eq!(
171 map.get("cli.common.answer_yes_no").map(String::as_str),
172 Some("bitte mit y oder n antworten")
173 );
174 }
175
176 #[test]
177 fn normalize_locale_tag_handles_common_system_forms() {
178 assert_eq!(
179 normalize_locale_tag("en_US.UTF-8").as_deref(),
180 Some("en-US")
181 );
182 assert_eq!(normalize_locale_tag("de_DE@euro").as_deref(), Some("de-DE"));
183 assert_eq!(normalize_locale_tag("es").as_deref(), Some("es"));
184 }
185
186 #[test]
187 fn normalize_locale_tag_rejects_c_posix() {
188 assert_eq!(normalize_locale_tag("C"), None);
189 assert_eq!(normalize_locale_tag("POSIX"), None);
190 assert_eq!(normalize_locale_tag("C.UTF-8"), None);
191 }
192}