Skip to main content

osdk_core/i18n/
mod.rs

1//! Lightweight internationalization (i18n) for osdk.
2//!
3//! Design: a small key -> {en, zh} catalog, a process-global active language
4//! set once at startup, and `tr()` / `trf()` lookups with `{placeholder}`
5//! substitution. This is intentionally dependency-free (no fluent) since the
6//! surface is a couple hundred short strings across two languages; adding a
7//! language is just another column in the catalog.
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU8, Ordering};
11
12use once_cell::sync::Lazy;
13
14mod catalog;
15
16/// Supported languages. `En` is the ultimate fallback.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Lang {
19    En,
20    Zh,
21}
22
23impl Lang {
24    /// BCP-47-ish short code.
25    pub fn code(self) -> &'static str {
26        match self {
27            Lang::En => "en",
28            Lang::Zh => "zh",
29        }
30    }
31
32    /// Parse an explicit language selector (from `--lang`, `OSDK_LANG`, or the
33    /// config). Accepts `en`, `zh`, `zh-CN`, `zh_CN`, `english`, `中文`, etc.
34    pub fn parse(s: &str) -> Option<Lang> {
35        let s = s.trim().to_ascii_lowercase();
36        if s.is_empty() {
37            return None;
38        }
39        if s == "中文" {
40            return Some(Lang::Zh);
41        }
42        let head = s
43            .split(['.', '_', '-', '@', ' '])
44            .next()
45            .unwrap_or(s.as_str());
46        match head {
47            "en" | "english" | "c" | "posix" => Some(Lang::En),
48            "zh" | "chinese" | "cn" => Some(Lang::Zh),
49            _ => None,
50        }
51    }
52
53    /// Detect from a locale env value like `zh_CN.UTF-8` / `en_US.UTF-8`.
54    fn from_locale(s: &str) -> Option<Lang> {
55        Lang::parse(s)
56    }
57}
58
59// Active language, stored as a u8 for cheap atomic access. 0 = En, 1 = Zh.
60static ACTIVE: AtomicU8 = AtomicU8::new(0);
61
62fn lang_from_u8(v: u8) -> Lang {
63    match v {
64        1 => Lang::Zh,
65        _ => Lang::En,
66    }
67}
68
69/// Set the process-global active language. Called once by the CLI at startup.
70pub fn set_lang(lang: Lang) {
71    let v = match lang {
72        Lang::En => 0,
73        Lang::Zh => 1,
74    };
75    ACTIVE.store(v, Ordering::Relaxed);
76}
77
78/// The current active language.
79pub fn current() -> Lang {
80    lang_from_u8(ACTIVE.load(Ordering::Relaxed))
81}
82
83/// Resolve the language from explicit selectors + environment.
84///
85/// Precedence (highest first): `explicit` (from `--lang`/config) → `OSDK_LANG`
86/// → `LC_ALL` → `LC_MESSAGES` → `LANG` → default `En`.
87pub fn detect(explicit: Option<&str>, getenv: impl Fn(&str) -> Option<String>) -> Lang {
88    if let Some(sel) = explicit {
89        if let Some(l) = Lang::parse(sel) {
90            return l;
91        }
92    }
93    if let Some(v) = getenv("OSDK_LANG") {
94        if let Some(l) = Lang::parse(&v) {
95            return l;
96        }
97    }
98    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
99        if let Some(v) = getenv(key) {
100            if let Some(l) = Lang::from_locale(&v) {
101                return l;
102            }
103        }
104    }
105    Lang::En
106}
107
108// Catalog: key -> (en, zh). Built once.
109type Row = (&'static str, &'static str);
110static CATALOG: Lazy<HashMap<&'static str, Row>> = Lazy::new(catalog::build);
111
112/// Look up a message by key in the active language. Falls back to English, then
113/// to the key itself (so a missing key is visible, not empty).
114pub fn tr(key: &str) -> String {
115    trl(current(), key)
116}
117
118/// Look up a message by key in a specific language.
119pub fn trl(lang: Lang, key: &str) -> String {
120    match CATALOG.get(key) {
121        Some((en, zh)) => match lang {
122            Lang::En => en.to_string(),
123            Lang::Zh => {
124                if zh.is_empty() {
125                    en.to_string()
126                } else {
127                    zh.to_string()
128                }
129            }
130        },
131        None => key.to_string(),
132    }
133}
134
135/// Look up + interpolate `{name}` placeholders with the given args.
136pub fn trf(key: &str, args: &[(&str, &str)]) -> String {
137    interpolate(&tr(key), args)
138}
139
140/// Replace `{k}` occurrences in `template` with the provided values.
141pub fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
142    let mut out = template.to_string();
143    for (k, v) in args {
144        out = out.replace(&format!("{{{k}}}"), v);
145    }
146    out
147}
148
149/// Convenience macro: `t!("key")` or `t!("key", name = value, ...)`.
150/// Values may be any `Display` type; they're stringified.
151#[macro_export]
152macro_rules! t {
153    ($key:expr) => {
154        $crate::i18n::tr($key)
155    };
156    ($key:expr, $($name:ident = $val:expr),+ $(,)?) => {{
157        let args: &[(&str, &str)] = &[$((stringify!($name), &format!("{}", $val))),+];
158        $crate::i18n::trf($key, args)
159    }};
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn parse_langs() {
168        assert_eq!(Lang::parse("zh_CN.UTF-8"), Some(Lang::Zh));
169        assert_eq!(Lang::parse("en_US.UTF-8"), Some(Lang::En));
170        assert_eq!(Lang::parse("zh-Hans"), Some(Lang::Zh));
171        assert_eq!(Lang::parse("中文"), Some(Lang::Zh));
172        assert_eq!(Lang::parse("C"), Some(Lang::En));
173        assert_eq!(Lang::parse("fr"), None);
174    }
175
176    #[test]
177    fn detect_precedence() {
178        // explicit beats env
179        let l = detect(Some("zh"), |k| {
180            if k == "LANG" {
181                Some("en_US.UTF-8".into())
182            } else {
183                None
184            }
185        });
186        assert_eq!(l, Lang::Zh);
187        // OSDK_LANG beats LANG
188        let l = detect(None, |k| match k {
189            "OSDK_LANG" => Some("en".into()),
190            "LANG" => Some("zh_CN.UTF-8".into()),
191            _ => None,
192        });
193        assert_eq!(l, Lang::En);
194        // LANG locale used when nothing explicit
195        let l = detect(None, |k| {
196            if k == "LANG" {
197                Some("zh_CN.UTF-8".into())
198            } else {
199                None
200            }
201        });
202        assert_eq!(l, Lang::Zh);
203        // default en
204        assert_eq!(detect(None, |_| None), Lang::En);
205    }
206
207    #[test]
208    fn interpolation() {
209        assert_eq!(
210            interpolate("hello {name}!", &[("name", "world")]),
211            "hello world!"
212        );
213    }
214
215    #[test]
216    fn tr_falls_back_to_key() {
217        assert_eq!(
218            trl(Lang::En, "definitely.missing.key"),
219            "definitely.missing.key"
220        );
221    }
222
223    #[test]
224    fn log_keys_localized_both_langs() {
225        // User-visible log messages must exist in both languages and differ
226        // (i.e. actually translated, not just falling back to the key).
227        for key in [
228            "log.checksum_verified",
229            "log.download_failover",
230            "log.stale_python_cache",
231        ] {
232            let en = trl(Lang::En, key);
233            let zh = trl(Lang::Zh, key);
234            assert_ne!(en, key, "missing en for {key}");
235            assert_ne!(zh, key, "missing zh for {key}");
236            assert_ne!(en, zh, "zh not translated for {key}");
237        }
238        // Interpolation carries into the localized log message.
239        let msg = trl(Lang::Zh, "log.download_failover");
240        assert!(msg.contains("{err}"));
241        assert_eq!(
242            interpolate(&msg, &[("err", "boom")]),
243            msg.replace("{err}", "boom")
244        );
245    }
246
247    #[test]
248    fn dynamic_npm_keys_are_bilingual_with_matching_placeholders() {
249        for key in [
250            "label.npm_lock_graph",
251            "err.npm_managed_node_dependency_required",
252            "err.npm_package_backend_invalid",
253            "err.shim_generation_conflict",
254            "err.shim_managed_node_required",
255            "err.shim_dynamic_route_conflict",
256            "err.npm_allow_builds_invalid",
257            "err.npm_lock_graph_option_missing",
258            "err.npm_lock_graph_identity_mismatch",
259            "err.npm_lock_graph_format_unsupported",
260            "err.npm_lock_graph_digest_invalid",
261            "err.npm_lock_graph_tool_mismatch",
262            "err.npm_lock_graph_not_produced",
263            "err.npm_install_package_missing",
264            "err.npm_install_bin_dir_missing",
265            "err.npm_offline_lock_graph_required",
266            "err.npm_package_sri_missing",
267            "err.npm_dynamic_no_validated_executables",
268            "err.npm_dynamic_managed_node_required",
269            "err.managed_node_bin_dir_missing",
270            "err.npm_bin_outside_install_root",
271            "err.npm_bins_not_discovered",
272            "err.npm_bin_target_unresolved",
273            "err.npm_lock_payload_read",
274            "err.npm_lock_payload_not_utf8",
275            "err.npm_project_manifest_identity_mismatch",
276            "err.npm_project_manifest_build_policy_mismatch",
277            "err.npm_graph_parse_invalid",
278            "err.npm_graph_root_missing",
279            "err.npm_graph_root_version_mismatch",
280            "err.npm_graph_resolved_root_missing",
281            "err.npm_graph_root_integrity_missing",
282            "err.npm_graph_root_integrity_invalid",
283            "err.npm_graph_root_source_mismatch",
284            "err.lock_npm_legacy_inline_regenerate",
285            "err.lockfile_not_utf8",
286            "err.lock_schema2_npm_artifact_forbidden",
287            "err.lock_npm_package_mismatch",
288            "err.lock_npm_graph_format_unsupported",
289            "err.lock_npm_graph_path_unsafe_platform",
290            "err.lock_npm_node_entry_required",
291            "err.lock_npm_node_version_mismatch",
292            "err.lock_npm_graph_missing",
293            "err.lock_schema2_npm_legacy_inline",
294            "err.lock_non_npm_graph_metadata",
295            "err.lock_npm_graph_sha256_invalid",
296            "err.lock_npm_graph_path_unsafe",
297            "err.lock_npm_sidecar_read",
298            "err.lock_npm_sidecar_checksum_mismatch",
299            "err.lock_npm_sidecar_not_utf8",
300            "err.lock_npm_graph_path_symlink",
301            "err.lock_npm_sidecar_symlink",
302            "err.file_size_limit_exceeded",
303            "err.lock_backend_id_unsafe",
304            "err.lock_version_unsafe",
305            "err.lock_artifact_filename_unsafe",
306            "err.lock_artifact_subdir_unsafe",
307            "err.lock_schema1_npm_migration_requires_graph",
308            "err.lock_npm_resolved_node_required",
309            "err.lockfile_size_limit_exceeded",
310            "err.lock_atomic_path_filename_missing",
311            "err.fs_directory_create",
312            "err.fs_file_create",
313            "err.fs_file_write",
314            "err.fs_file_sync",
315            "err.fs_file_replace",
316        ] {
317            let en = trl(Lang::En, key);
318            let zh = trl(Lang::Zh, key);
319            assert_ne!(en, key, "missing en for {key}");
320            assert_ne!(zh, key, "missing zh for {key}");
321            assert_ne!(en, zh, "zh not translated for {key}");
322            assert_eq!(
323                placeholders(&en),
324                placeholders(&zh),
325                "placeholder mismatch for {key}"
326            );
327        }
328    }
329
330    fn placeholders(message: &str) -> Vec<&str> {
331        let mut placeholders = Vec::new();
332        let mut rest = message;
333        while let Some(open) = rest.find('{') {
334            rest = &rest[open + 1..];
335            let Some(close) = rest.find('}') else {
336                break;
337            };
338            placeholders.push(&rest[..close]);
339            rest = &rest[close + 1..];
340        }
341        placeholders.sort_unstable();
342        placeholders
343    }
344
345    #[test]
346    fn zh_falls_back_to_en_when_empty() {
347        // 'pinned' has both; sanity that a known key differs by lang or falls back
348        let en = trl(Lang::En, "msg.installed");
349        assert!(!en.is_empty());
350    }
351}