1use std::collections::BTreeMap;
6use std::env;
7
8pub struct CliI18n {
10 catalog: BTreeMap<String, String>,
11 fallback: BTreeMap<String, String>,
12}
13
14impl CliI18n {
15 pub fn from_request(requested: Option<&str>) -> Result<Self, String> {
19 let resolved = resolve_locale(requested);
20 let fallback = load_catalog("en")?;
21 let catalog =
22 load_catalog_with_base_fallback(&resolved).unwrap_or_else(|_| fallback.clone());
23 Ok(Self { catalog, fallback })
24 }
25
26 pub fn t(&self, key: &str) -> String {
28 if let Some(v) = self.catalog.get(key) {
29 return v.clone();
30 }
31 if let Some(v) = self.fallback.get(key) {
32 return v.clone();
33 }
34 key.to_string()
35 }
36
37 pub fn tf(&self, key: &str, args: &[&str]) -> String {
41 format_template(&self.t(key), args)
42 }
43
44 pub fn t_or(&self, key: &str, default: &str) -> String {
49 self.catalog
50 .get(key)
51 .or_else(|| self.fallback.get(key))
52 .cloned()
53 .unwrap_or_else(|| default.to_string())
54 }
55
56 pub fn tf_or(&self, key: &str, default: &str, args: &[&str]) -> String {
58 format_template(&self.t_or(key, default), args)
59 }
60
61 pub fn keys_with_prefix(&self, prefix: &str) -> BTreeMap<String, String> {
65 let mut result = BTreeMap::new();
66 for (k, v) in &self.fallback {
68 if k.starts_with(prefix) {
69 result.insert(k.clone(), v.clone());
70 }
71 }
72 for (k, v) in &self.catalog {
73 if k.starts_with(prefix) {
74 result.insert(k.clone(), v.clone());
75 }
76 }
77 result
78 }
79}
80
81fn resolve_locale(requested: Option<&str>) -> String {
82 if let Some(locale) = requested.and_then(normalize_locale) {
83 return locale;
84 }
85 if let Some(locale) = env::var("LC_ALL")
86 .ok()
87 .as_deref()
88 .and_then(normalize_locale)
89 {
90 return locale;
91 }
92 if let Some(locale) = env::var("LANG").ok().as_deref().and_then(normalize_locale) {
93 return locale;
94 }
95 "en".to_string()
96}
97
98fn normalize_locale(value: &str) -> Option<String> {
99 let trimmed = value.trim();
100 if trimmed.is_empty() {
101 return None;
102 }
103 let pre_dot = trimmed.split('.').next().unwrap_or(trimmed);
104 let normalized = pre_dot.replace('_', "-");
105 if normalized.is_empty() {
106 return None;
107 }
108 Some(normalized)
109}
110
111fn load_catalog(locale: &str) -> Result<BTreeMap<String, String>, String> {
112 let raw = match locale {
113 "ar" => include_str!("../i18n/ar.json"),
114 "ar-AE" => include_str!("../i18n/ar-AE.json"),
115 "ar-DZ" => include_str!("../i18n/ar-DZ.json"),
116 "ar-EG" => include_str!("../i18n/ar-EG.json"),
117 "ar-IQ" => include_str!("../i18n/ar-IQ.json"),
118 "ar-MA" => include_str!("../i18n/ar-MA.json"),
119 "ar-SA" => include_str!("../i18n/ar-SA.json"),
120 "ar-SD" => include_str!("../i18n/ar-SD.json"),
121 "ar-SY" => include_str!("../i18n/ar-SY.json"),
122 "ar-TN" => include_str!("../i18n/ar-TN.json"),
123 "ay" => include_str!("../i18n/ay.json"),
124 "bg" => include_str!("../i18n/bg.json"),
125 "bn" => include_str!("../i18n/bn.json"),
126 "cs" => include_str!("../i18n/cs.json"),
127 "da" => include_str!("../i18n/da.json"),
128 "de" => include_str!("../i18n/de.json"),
129 "el" => include_str!("../i18n/el.json"),
130 "en" => include_str!("../i18n/en.json"),
131 "en-GB" => include_str!("../i18n/en-GB.json"),
132 "es" => include_str!("../i18n/es.json"),
133 "et" => include_str!("../i18n/et.json"),
134 "fa" => include_str!("../i18n/fa.json"),
135 "fi" => include_str!("../i18n/fi.json"),
136 "fr" => include_str!("../i18n/fr.json"),
137 "gn" => include_str!("../i18n/gn.json"),
138 "gu" => include_str!("../i18n/gu.json"),
139 "hi" => include_str!("../i18n/hi.json"),
140 "hr" => include_str!("../i18n/hr.json"),
141 "ht" => include_str!("../i18n/ht.json"),
142 "hu" => include_str!("../i18n/hu.json"),
143 "id" => include_str!("../i18n/id.json"),
144 "it" => include_str!("../i18n/it.json"),
145 "ja" => include_str!("../i18n/ja.json"),
146 "km" => include_str!("../i18n/km.json"),
147 "kn" => include_str!("../i18n/kn.json"),
148 "ko" => include_str!("../i18n/ko.json"),
149 "lo" => include_str!("../i18n/lo.json"),
150 "lt" => include_str!("../i18n/lt.json"),
151 "lv" => include_str!("../i18n/lv.json"),
152 "ml" => include_str!("../i18n/ml.json"),
153 "mr" => include_str!("../i18n/mr.json"),
154 "ms" => include_str!("../i18n/ms.json"),
155 "my" => include_str!("../i18n/my.json"),
156 "nah" => include_str!("../i18n/nah.json"),
157 "ne" => include_str!("../i18n/ne.json"),
158 "nl" => include_str!("../i18n/nl.json"),
159 "no" => include_str!("../i18n/no.json"),
160 "pa" => include_str!("../i18n/pa.json"),
161 "pl" => include_str!("../i18n/pl.json"),
162 "pt" => include_str!("../i18n/pt.json"),
163 "qu" => include_str!("../i18n/qu.json"),
164 "ro" => include_str!("../i18n/ro.json"),
165 "ru" => include_str!("../i18n/ru.json"),
166 "si" => include_str!("../i18n/si.json"),
167 "sk" => include_str!("../i18n/sk.json"),
168 "sr" => include_str!("../i18n/sr.json"),
169 "sv" => include_str!("../i18n/sv.json"),
170 "ta" => include_str!("../i18n/ta.json"),
171 "te" => include_str!("../i18n/te.json"),
172 "th" => include_str!("../i18n/th.json"),
173 "tl" => include_str!("../i18n/tl.json"),
174 "tr" => include_str!("../i18n/tr.json"),
175 "uk" => include_str!("../i18n/uk.json"),
176 "ur" => include_str!("../i18n/ur.json"),
177 "vi" => include_str!("../i18n/vi.json"),
178 "zh" => include_str!("../i18n/zh.json"),
179 _ => return Err(format!("unsupported locale `{locale}`")),
180 };
181 let value: serde_json::Value = serde_json::from_str(raw)
182 .map_err(|err| format!("invalid locale JSON `{locale}`: {err}"))?;
183 let obj = value
184 .as_object()
185 .ok_or_else(|| format!("locale catalog `{locale}` must be an object"))?;
186 let mut map = BTreeMap::new();
187 for (k, v) in obj {
188 let s = v
189 .as_str()
190 .ok_or_else(|| format!("locale catalog `{locale}` key `{k}` must be a string"))?;
191 map.insert(k.to_string(), s.to_string());
192 }
193 Ok(map)
194}
195
196fn load_catalog_with_base_fallback(locale: &str) -> Result<BTreeMap<String, String>, String> {
203 if let Ok(catalog) = load_catalog(locale) {
204 return Ok(catalog);
205 }
206 if let Some((base, _region)) = locale.split_once('-') {
207 return load_catalog(&base.to_ascii_lowercase());
208 }
209 Err(format!("unsupported locale `{locale}`"))
210}
211
212pub(crate) fn format_template(template: &str, args: &[&str]) -> String {
213 let mut out = String::new();
214 let mut idx = 0usize;
215 let mut i = 0usize;
216 while let Some(pos) = template[i..].find("{}") {
217 let abs = i + pos;
218 out.push_str(&template[i..abs]);
219 if idx < args.len() {
220 out.push_str(args[idx]);
221 idx += 1;
222 } else {
223 out.push_str("{}");
224 }
225 i = abs + 2;
226 }
227 out.push_str(&template[i..]);
228 out
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_load_english_catalog() {
237 let catalog = load_catalog("en").expect("should load English catalog");
238 assert!(catalog.contains_key("cli.bundle.init.creating"));
239 }
240
241 #[test]
242 fn test_format_template() {
243 assert_eq!(format_template("Hello {}", &["World"]), "Hello World");
244 assert_eq!(
245 format_template("{} + {} = {}", &["1", "2", "3"]),
246 "1 + 2 = 3"
247 );
248 assert_eq!(format_template("No args", &[]), "No args");
249 }
250
251 #[test]
252 fn test_cli_i18n_translation() {
253 let i18n = CliI18n::from_request(Some("en")).expect("should create i18n");
254 let msg = i18n.tf("cli.bundle.init.creating", &["/path/to/bundle"]);
255 assert!(msg.contains("/path/to/bundle"));
256 }
257
258 #[test]
259 fn t_or_uses_catalog_then_falls_back_to_default() {
260 let i18n = CliI18n::from_request(Some("en")).expect("should create i18n");
261 assert_eq!(
263 i18n.t_or("env_wizard.q.bundles.title", "IGNORED DEFAULT"),
264 "Bundles"
265 );
266 assert_eq!(
268 i18n.t_or("env_wizard.q.__nope__.title", "Fallback title"),
269 "Fallback title"
270 );
271 }
272
273 #[test]
274 fn tf_or_substitutes_into_default_when_key_missing() {
275 let i18n = CliI18n::from_request(Some("en")).expect("should create i18n");
276 assert_eq!(
277 i18n.tf_or("env_wizard.__nope__", "Need {} secret(s).", &["3"]),
278 "Need 3 secret(s)."
279 );
280 }
281
282 #[test]
283 fn t_or_returns_localized_value_for_dutch() {
284 let i18n = CliI18n::from_request(Some("nl")).expect("should create i18n");
285 assert_eq!(
286 i18n.t_or("env_wizard.q.bundles.title", "Bundles"),
287 "Bundels"
288 );
289 }
290
291 #[test]
292 fn regional_system_locale_falls_back_to_base_language_catalog() {
293 let key = "env_wizard.q.bundles.title";
294 let nl = CliI18n::from_request(Some("nl_NL.UTF-8")).expect("should create i18n");
296 assert_eq!(nl.t(key), "Bundels");
297 for regional in ["de_DE.UTF-8", "pt_BR.UTF-8", "fr-FR"] {
299 let base = regional.split(['_', '-']).next().unwrap();
300 let from_regional = CliI18n::from_request(Some(regional)).expect("should create i18n");
301 let from_base = CliI18n::from_request(Some(base)).expect("should create i18n");
302 assert_eq!(
303 from_regional.t(key),
304 from_base.t(key),
305 "{regional} should resolve to the `{base}` catalog"
306 );
307 }
308 }
309}