Skip to main content

dev_prune/i18n/
mod.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Internationalisation: the fixed chrome of dev-prune's own output, in the language the
5// user asked for.
6//
7// Deliberately bounded, and the boundary is the whole design. Translated: section
8// headings, the summary lines, and the group titles in the configurator — the words that
9// repeat on every run and carry no information a script would parse. Never translated:
10// `--json`, exit codes, flag names, config keys, adapter names, and the sentence a
11// lockfile refusal prints. Those are a contract or a diagnosis; a bug report quoting a
12// translated refusal is a bug report nobody upstream can read, and a `--json` document
13// whose strings move with `LANG` is not a document.
14//
15// English is the source of truth. Every other catalogue is overlaid on top of it, so a
16// key a translator has not reached yet prints in English rather than printing its own
17// name — a half-finished translation degrades to the original instead of to gibberish.
18//
19// Adding a language is one JSON file and one line in [`CATALOGUES`]. See
20// `docs/TRANSLATIONS.md`.
21
22use std::collections::BTreeMap;
23use std::sync::OnceLock;
24
25use serde::Deserialize;
26
27use crate::constants;
28
29/// Every catalogue compiled into the binary.
30///
31/// The only list of languages there is: the code, the names and the review status all
32/// come out of the file's own `_meta` block, so adding a language cannot half-happen by
33/// updating one list and not the other.
34const CATALOGUES: &[&str] = &[
35    include_str!("locales/en.json"),
36    include_str!("locales/hi.json"),
37    include_str!("locales/te.json"),
38    include_str!("locales/ta.json"),
39    include_str!("locales/kn.json"),
40    include_str!("locales/ml.json"),
41    include_str!("locales/bn.json"),
42    include_str!("locales/mr.json"),
43    include_str!("locales/gu.json"),
44    include_str!("locales/pa.json"),
45    include_str!("locales/sa.json"),
46    include_str!("locales/zh.json"),
47];
48
49/// What a catalogue says about itself.
50#[derive(Debug, Clone, Deserialize)]
51pub struct Meta {
52    /// The IETF subtag the config key and `DEV_PRUNE_LANG` take.
53    pub code: String,
54    /// The language's name in English, for the moment the choice is confirmed.
55    pub english_name: String,
56    /// The language's name in itself, for the person making the choice.
57    pub native_name: String,
58    /// Whether a native speaker has read this file through.
59    ///
60    /// Recorded rather than assumed, and said out loud where the language is chosen. A
61    /// translation nobody has checked is still worth shipping — it is how the first
62    /// speaker of that language finds the mistakes — but saying so is the difference
63    /// between an invitation and a claim.
64    pub reviewed: bool,
65}
66
67/// One language's strings, plus what it says about itself.
68#[derive(Debug, Clone, Deserialize)]
69struct Catalogue {
70    #[serde(rename = "_meta")]
71    meta: Meta,
72    #[serde(flatten)]
73    strings: BTreeMap<String, String>,
74}
75
76/// The catalogues that parsed, in the order of [`CATALOGUES`].
77///
78/// A file that does not parse is dropped rather than panicked on: a malformed
79/// translation should cost that translation, not the run. `catalogues_all_parse` is what
80/// stops one reaching a release.
81fn parsed() -> &'static Vec<Catalogue> {
82    static PARSED: OnceLock<Vec<Catalogue>> = OnceLock::new();
83    PARSED.get_or_init(|| {
84        CATALOGUES
85            .iter()
86            .filter_map(|raw| serde_json::from_str::<Catalogue>(raw).ok())
87            .collect()
88    })
89}
90
91/// The strings actually in use: English, with the chosen language laid over the top.
92static ACTIVE: OnceLock<BTreeMap<String, String>> = OnceLock::new();
93
94/// Build the merged table for one language code.
95fn merge(code: &str) -> BTreeMap<String, String> {
96    let mut merged = parsed()
97        .iter()
98        .find(|c| c.meta.code == constants::DEFAULT_LANGUAGE)
99        .map(|c| c.strings.clone())
100        .unwrap_or_default();
101    if code != constants::DEFAULT_LANGUAGE
102        && let Some(chosen) = parsed().iter().find(|c| c.meta.code == code)
103    {
104        // Only keys the translator actually filled in. An empty string is a key they
105        // opened and left, and falling back is better than printing nothing at all.
106        for (key, value) in &chosen.strings {
107            if !value.trim().is_empty() {
108                merged.insert(key.clone(), value.clone());
109            }
110        }
111    }
112    merged
113}
114
115/// Choose the language for this process.
116///
117/// Resolution order, highest first:
118///
119/// 1. `DEV_PRUNE_LANG`, which governs one invocation and is what a script or a CI job
120///    sets when it wants a known language whatever the machine is configured for.
121/// 2. the `language` setting, which is the durable answer for this user.
122/// 3. English.
123///
124/// The operating system's own locale is deliberately *not* consulted. A machine set to
125/// Hindi has said nothing about what language it wants its build tools in, and a user
126/// who has never asked for a translation should not be given a partial one by a
127/// variable they did not set.
128///
129/// Idempotent, and the first call wins — later calls are ignored, so a command that
130/// re-reads the registry cannot change the language halfway through its own output.
131pub fn init(configured: Option<&str>) {
132    let requested = std::env::var(constants::ENV_LANGUAGE)
133        .ok()
134        .map(|v| v.trim().to_string())
135        .filter(|v| !v.is_empty())
136        .or_else(|| configured.map(str::to_string))
137        .unwrap_or_else(|| constants::DEFAULT_LANGUAGE.to_string());
138
139    // An unknown code is English rather than an error. This runs before the command
140    // does, and refusing to start because of a typo in a cosmetic setting would be a
141    // worse failure than the one it reports.
142    let code = if language(&requested).is_some() {
143        requested
144    } else {
145        constants::DEFAULT_LANGUAGE.to_string()
146    };
147
148    let _ = ACTIVE.set(merge(&code));
149}
150
151/// One translated string.
152///
153/// Falls back to the key itself, which is why every call site passes a literal: a
154/// missing key then prints something a maintainer can grep for rather than an empty
155/// line. In practice `catalogues_cover_english` makes that unreachable.
156pub fn t(key: &'static str) -> &'static str {
157    ACTIVE
158        .get_or_init(|| merge(constants::DEFAULT_LANGUAGE))
159        .get(key)
160        .map(String::as_str)
161        .unwrap_or(key)
162}
163
164/// A translated string with `{name}` placeholders filled in.
165///
166/// Runtime substitution rather than `format!` because the template is chosen at runtime
167/// and `format!` needs a literal. Placeholders are named, not positional, so a
168/// translator may reorder them — which some of these languages require.
169pub fn tf(key: &'static str, args: &[(&str, &str)]) -> String {
170    let mut out = t(key).to_string();
171    for (name, value) in args {
172        out = out.replace(&format!("{{{name}}}"), value);
173    }
174    out
175}
176
177/// What a language code resolves to, or `None` if this binary has no catalogue for it.
178pub fn language(code: &str) -> Option<&'static Meta> {
179    parsed()
180        .iter()
181        .find(|c| c.meta.code == code)
182        .map(|c| &c.meta)
183}
184
185/// Every language as `(code, native name)`, for the configurator's picker.
186///
187/// Pairs rather than bare codes because `te` is not a word anybody reads: the row has to
188/// be legible to the person the translation is *for*, who may well not know the subtag.
189/// `&'static` so the picker can hold it in a `Copy` control without knowing where it came
190/// from.
191pub fn choices() -> &'static [(&'static str, &'static str)] {
192    static CHOICES: OnceLock<Vec<(&'static str, &'static str)>> = OnceLock::new();
193    CHOICES.get_or_init(|| {
194        parsed()
195            .iter()
196            .map(|c| (c.meta.code.as_str(), c.meta.native_name.as_str()))
197            .collect()
198    })
199}
200
201/// `en English · hi हिन्दी · te తెలుగు · …`, for the error an unknown code earns.
202pub fn catalogue_line() -> String {
203    choices()
204        .iter()
205        .map(|(code, native)| format!("{code} {native}"))
206        .collect::<Vec<_>>()
207        .join(" · ")
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    /// A catalogue that does not parse is silently dropped at runtime, so this is the
215    /// only thing standing between a stray comma and a release that quietly speaks
216    /// English to everyone who asked for Telugu.
217    #[test]
218    fn catalogues_all_parse() {
219        assert_eq!(
220            parsed().len(),
221            CATALOGUES.len(),
222            "a locale file failed to parse; every entry in CATALOGUES must be valid JSON \
223             with a _meta block"
224        );
225    }
226
227    /// English defines the key set. A translation with a key English does not have is a
228    /// string nothing prints — usually a typo in the key, which is invisible at runtime
229    /// because the merge simply ignores it.
230    #[test]
231    fn catalogues_cover_english() {
232        let english: Vec<&String> = parsed()
233            .iter()
234            .find(|c| c.meta.code == constants::DEFAULT_LANGUAGE)
235            .expect("en.json must exist")
236            .strings
237            .keys()
238            .collect();
239
240        for catalogue in parsed() {
241            for key in catalogue.strings.keys() {
242                assert!(
243                    english.contains(&key),
244                    "{}.json has key `{key}`, which en.json does not",
245                    catalogue.meta.code
246                );
247            }
248        }
249    }
250
251    /// Codes are the value `devp config set language` takes, so two catalogues claiming
252    /// one code would make the setting ambiguous.
253    #[test]
254    fn codes_are_unique_and_start_with_english() {
255        let mut seen = std::collections::BTreeSet::new();
256        for (code, _) in choices() {
257            assert!(seen.insert(*code), "duplicate language code `{code}`");
258        }
259        assert_eq!(
260            choices().first().map(|(code, _)| *code),
261            Some(constants::DEFAULT_LANGUAGE)
262        );
263    }
264
265    /// Every catalogue names itself in its own language, which is the only string the
266    /// picker can show somebody who cannot read the English name.
267    #[test]
268    fn every_catalogue_names_itself() {
269        for (code, _) in choices() {
270            let meta = language(code).expect("choices() only lists catalogues that parsed");
271            assert!(!meta.english_name.trim().is_empty(), "{code}");
272            assert!(!meta.native_name.trim().is_empty(), "{code}");
273        }
274    }
275
276    #[test]
277    fn unknown_language_is_not_supported() {
278        assert!(language("en").is_some());
279        assert!(language("te").is_some());
280        assert!(language("xx").is_none());
281        assert!(language("EN").is_none());
282    }
283
284    #[test]
285    fn placeholders_are_filled_by_name() {
286        // The English template is the one under test; a translation may reorder them.
287        let filled = tf("run.freed", &[("size", "1.2 GB"), ("count", "7")]);
288        assert!(filled.contains("1.2 GB"), "{filled}");
289        assert!(filled.contains('7'), "{filled}");
290        assert!(!filled.contains('{'), "{filled}");
291    }
292
293    /// The reason the catalogues are merged rather than swapped: an unfinished
294    /// translation prints English, never the raw key.
295    #[test]
296    fn an_untranslated_key_falls_back_to_english() {
297        let english = merge(constants::DEFAULT_LANGUAGE);
298        for (code, _) in choices() {
299            for (key, value) in merge(code) {
300                assert!(!value.trim().is_empty(), "`{code}` has an empty `{key}`");
301                assert!(
302                    english.contains_key(&key),
303                    "`{code}` invented the key `{key}`"
304                );
305            }
306        }
307    }
308}