Skip to main content

greentic_setup/
cli_i18n.rs

1//! CLI i18n support for greentic-setup.
2//!
3//! Provides locale-aware message translation for CLI output.
4
5use std::collections::BTreeMap;
6use std::env;
7
8/// CLI internationalization support.
9pub struct CliI18n {
10    catalog: BTreeMap<String, String>,
11    fallback: BTreeMap<String, String>,
12}
13
14impl CliI18n {
15    /// Create a new CliI18n instance from a requested locale.
16    ///
17    /// If no locale is specified, the system locale (LC_ALL, LANG) is used.
18    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    /// Translate a key to the current locale.
27    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    /// Translate a key with format arguments.
38    ///
39    /// Arguments replace `{}` placeholders in order.
40    pub fn tf(&self, key: &str, args: &[&str]) -> String {
41        format_template(&self.t(key), args)
42    }
43
44    /// Translate `key`, falling back to `default` when the key is absent from
45    /// both the active-locale catalog and the English fallback. Lets a caller
46    /// keep an inline English literal as the canonical source string while
47    /// still picking up a localized override when the catalog carries one.
48    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    /// [`Self::t_or`] with `{}` placeholders substituted from `args`.
57    pub fn tf_or(&self, key: &str, default: &str, args: &[&str]) -> String {
58        format_template(&self.t_or(key, default), args)
59    }
60
61    /// Export all keys matching a prefix as a flat map.
62    ///
63    /// Used by the web UI to send translated strings to the frontend.
64    pub fn keys_with_prefix(&self, prefix: &str) -> BTreeMap<String, String> {
65        let mut result = BTreeMap::new();
66        // Prefer catalog (current locale), fall back to fallback (en)
67        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
196/// Load `locale`'s catalog, retrying with its base language subtag before
197/// giving up. System locales (`LANG`/`LC_ALL`) and explicit `--locale` values
198/// like `nl_NL.UTF-8` normalize to a region tag (`nl-NL`) that has no exact
199/// catalog; the shipped catalogs are mostly base-language files (`nl.json`,
200/// `de.json`, …), so fall back to the lowercased base subtag (`nl-NL` -> `nl`)
201/// instead of dropping straight to English.
202fn 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        // Present in en.json → catalog value wins over the inline default.
262        assert_eq!(
263            i18n.t_or("env_wizard.q.bundles.title", "IGNORED DEFAULT"),
264            "Bundles"
265        );
266        // Absent from every catalog → the inline default is returned verbatim.
267        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        // `nl_NL.UTF-8` normalizes to `nl-NL` (no exact catalog) -> base `nl`.
295        let nl = CliI18n::from_request(Some("nl_NL.UTF-8")).expect("should create i18n");
296        assert_eq!(nl.t(key), "Bundels");
297        // Other region tags resolve to their base-language catalog, not English.
298        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}