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}