Skip to main content

greentic_dev/
i18n.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::ffi::OsString;
4use std::sync::OnceLock;
5
6use serde_json::Value;
7use unic_langid::LanguageIdentifier;
8
9include!(concat!(env!("OUT_DIR"), "/i18n_bundle.rs"));
10
11static CATALOGS: OnceLock<BTreeMap<&'static str, BTreeMap<String, String>>> = OnceLock::new();
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_locale(cli, &supported)
18    {
19        return found;
20    }
21
22    if let Some(env_loc) = detect_env_locale()
23        && let Some(found) = resolve_locale(&env_loc, &supported)
24    {
25        return found;
26    }
27
28    if let Some(sys_loc) = sys_locale::get_locale()
29        && let Some(found) = resolve_locale(&sys_loc, &supported)
30    {
31        return found;
32    }
33
34    "en".to_string()
35}
36
37pub fn t(locale: &str, key: &str) -> String {
38    let catalogs = catalogs();
39    if let Some(value) = lookup(catalogs, locale, key) {
40        return value.to_string();
41    }
42    if let Some(value) = lookup(catalogs, "en", key) {
43        return value.to_string();
44    }
45    key.to_string()
46}
47
48pub fn cli_locale_from_argv(argv: &[OsString]) -> Option<String> {
49    let mut args = argv.iter().skip(1);
50    while let Some(arg) = args.next() {
51        let text = arg.to_string_lossy();
52        if let Some(value) = text.strip_prefix("--locale=") {
53            return Some(value.to_string());
54        }
55        if text == "--locale" {
56            return args.next().map(|next| next.to_string_lossy().to_string());
57        }
58    }
59    None
60}
61
62pub fn tf(locale: &str, key: &str, args: &[(&str, String)]) -> String {
63    let mut rendered = t(locale, key);
64    for (name, value) in args {
65        let token = format!("{{{name}}}");
66        rendered = rendered.replace(&token, value);
67    }
68    rendered
69}
70
71fn lookup<'a>(
72    catalogs: &'a BTreeMap<&'static str, BTreeMap<String, String>>,
73    locale: &str,
74    key: &str,
75) -> Option<&'a str> {
76    if let Some(cat) = catalogs.get(locale)
77        && let Some(value) = cat.get(key)
78    {
79        return Some(value.as_str());
80    }
81    let base = base_language(locale)?;
82    catalogs
83        .get(base.as_str())
84        .and_then(|cat| cat.get(key))
85        .map(String::as_str)
86}
87
88fn catalogs() -> &'static BTreeMap<&'static str, BTreeMap<String, String>> {
89    CATALOGS.get_or_init(|| {
90        let mut catalogs = BTreeMap::new();
91        for (locale, raw) in BUNDLE {
92            let parsed: Value = serde_json::from_str(raw)
93                .unwrap_or_else(|e| panic!("failed to parse embedded locale {locale}: {e}"));
94            let obj = parsed
95                .as_object()
96                .unwrap_or_else(|| panic!("embedded locale {locale} must be a JSON object"));
97            let mut flat = BTreeMap::new();
98            for (key, value) in obj {
99                flat.insert(
100                    key.clone(),
101                    value
102                        .as_str()
103                        .unwrap_or_else(|| {
104                            panic!("embedded locale {locale} key {key} must map to a string")
105                        })
106                        .to_string(),
107                );
108            }
109            catalogs.insert(*locale, flat);
110        }
111        catalogs
112    })
113}
114
115fn supported_locales() -> Vec<&'static str> {
116    BUNDLE.iter().map(|(locale, _)| *locale).collect()
117}
118
119fn detect_env_locale() -> Option<String> {
120    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
121        if let Ok(val) = env::var(key) {
122            let trimmed = val.trim();
123            if !trimmed.is_empty() {
124                return Some(trimmed.to_string());
125            }
126        }
127    }
128    None
129}
130
131fn resolve_locale(candidate: &str, supported: &[&str]) -> Option<String> {
132    let norm = normalize_locale(candidate)?;
133    if supported.iter().any(|s| *s == norm) {
134        return Some(norm);
135    }
136    let base = base_language(&norm)?;
137    if supported.iter().any(|s| *s == base) {
138        return Some(base);
139    }
140    None
141}
142
143fn normalize_locale(raw: &str) -> Option<String> {
144    let mut cleaned = raw.trim();
145    if cleaned.is_empty() {
146        return None;
147    }
148    if let Some((head, _)) = cleaned.split_once('.') {
149        cleaned = head;
150    }
151    if let Some((head, _)) = cleaned.split_once('@') {
152        cleaned = head;
153    }
154    let cleaned = cleaned.replace('_', "-");
155    cleaned
156        .parse::<LanguageIdentifier>()
157        .ok()
158        .map(|lid| lid.to_string())
159}
160
161fn base_language(tag: &str) -> Option<String> {
162    tag.split('-').next().map(|s| s.to_ascii_lowercase())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::select_locale;
168
169    #[test]
170    fn locale_exact_match_wins() {
171        assert_eq!(select_locale(Some("en-GB")), "en-GB");
172    }
173
174    #[test]
175    fn locale_base_language_falls_back() {
176        assert_eq!(select_locale(Some("nl-NL")), "nl");
177    }
178}