Skip to main content

greentic_operator/
operator_i18n.rs

1use 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, &current_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}