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}