1#![forbid(unsafe_code)]
2
3use std::collections::HashMap;
4use std::env;
5use std::path::PathBuf;
6use std::sync::OnceLock;
7use unic_langid::LanguageIdentifier;
8
9static REQUESTED_LOCALE: OnceLock<String> = OnceLock::new();
10static I18N: OnceLock<CliI18n> = OnceLock::new();
11
12pub fn init_locale(cli_locale: Option<&str>) {
13 let supported = supported_locales();
14 let selected = select_locale(cli_locale, &supported);
15 let _ = REQUESTED_LOCALE.set(selected);
16}
17
18pub fn t(key: &str) -> String {
19 i18n().t(key)
20}
21
22pub fn tf(key: &str, args: &[&str]) -> String {
23 i18n().tf(key, args)
24}
25
26pub fn has(key: &str) -> bool {
27 i18n().has(key)
28}
29
30fn i18n() -> &'static CliI18n {
31 I18N.get_or_init(|| {
32 let locale = REQUESTED_LOCALE.get().map_or("en", String::as_str);
33 CliI18n::from_request(locale)
34 })
35}
36
37struct CliI18n {
38 catalog: HashMap<String, String>,
39 fallback: HashMap<String, String>,
40}
41
42impl CliI18n {
43 fn from_request(requested: &str) -> Self {
44 let fallback = parse_map(include_str!("../i18n/en.json"));
45 if requested == "en" {
46 return Self {
47 catalog: fallback.clone(),
48 fallback,
49 };
50 }
51 let catalog = load_locale_file(requested).unwrap_or_else(|| fallback.clone());
52 Self { catalog, fallback }
53 }
54
55 fn t(&self, key: &str) -> String {
56 if let Some(v) = self.catalog.get(key) {
57 return v.clone();
58 }
59 if let Some(v) = self.fallback.get(key) {
60 return v.clone();
61 }
62 key.to_string()
63 }
64
65 fn tf(&self, key: &str, args: &[&str]) -> String {
66 format_template(&self.t(key), args)
67 }
68
69 fn has(&self, key: &str) -> bool {
70 self.catalog.contains_key(key) || self.fallback.contains_key(key)
71 }
72}
73
74fn load_locale_file(locale: &str) -> Option<HashMap<String, String>> {
75 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
76 path.push("i18n");
77 path.push(format!("{locale}.json"));
78 let raw = std::fs::read_to_string(path).ok()?;
79 Some(parse_map(&raw))
80}
81
82fn supported_locales() -> Vec<String> {
83 let mut out = vec!["en".to_string()];
84 let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
85 dir.push("i18n");
86 let Ok(entries) = std::fs::read_dir(dir) else {
87 return out;
88 };
89 for entry in entries.flatten() {
90 let path = entry.path();
91 if path.extension().and_then(|x| x.to_str()) != Some("json") {
92 continue;
93 }
94 let Some(stem) = path.file_stem().and_then(|x| x.to_str()) else {
95 continue;
96 };
97 if stem != "en" {
98 out.push(stem.to_string());
99 }
100 }
101 out.sort();
102 out.dedup();
103 out
104}
105
106fn detect_env_locale() -> Option<String> {
107 for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
108 if let Ok(val) = env::var(key) {
109 let trimmed = val.trim();
110 if !trimmed.is_empty() {
111 return Some(trimmed.to_string());
112 }
113 }
114 }
115 None
116}
117
118fn detect_system_locale() -> Option<String> {
119 sys_locale::get_locale()
120}
121
122fn normalize_locale(raw: &str) -> Option<String> {
123 let mut cleaned = raw.trim();
124 if cleaned.is_empty() {
125 return None;
126 }
127 if let Some((head, _)) = cleaned.split_once('.') {
128 cleaned = head;
129 }
130 if let Some((head, _)) = cleaned.split_once('@') {
131 cleaned = head;
132 }
133 let cleaned = cleaned.replace('_', "-");
134 cleaned
135 .parse::<LanguageIdentifier>()
136 .ok()
137 .map(|lid| lid.to_string())
138}
139
140fn base_language(tag: &str) -> Option<String> {
141 tag.split('-').next().map(|s| s.to_ascii_lowercase())
142}
143
144fn resolve_supported(candidate: &str, supported: &[String]) -> Option<String> {
145 let norm = normalize_locale(candidate)?;
146 if supported.iter().any(|s| s == &norm) {
147 return Some(norm);
148 }
149 let base = base_language(&norm)?;
150 if supported.iter().any(|s| s == &base) {
151 return Some(base);
152 }
153 None
154}
155
156fn select_locale(cli_locale: Option<&str>, supported: &[String]) -> String {
157 if let Some(cli) = cli_locale
158 && let Some(found) = resolve_supported(cli, supported)
159 {
160 return found;
161 }
162
163 if let Some(env_loc) = detect_env_locale()
164 && let Some(found) = resolve_supported(&env_loc, supported)
165 {
166 return found;
167 }
168
169 if let Some(sys_loc) = detect_system_locale()
170 && let Some(found) = resolve_supported(&sys_loc, supported)
171 {
172 return found;
173 }
174
175 "en".to_string()
176}
177
178fn parse_map(raw: &str) -> HashMap<String, String> {
179 let mut map = HashMap::new();
180 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
181 return map;
182 };
183 let Some(obj) = value.as_object() else {
184 return map;
185 };
186 for (key, value) in obj {
187 if let Some(text) = value.as_str() {
188 map.insert(key.to_string(), text.to_string());
189 }
190 }
191 map
192}
193
194fn format_template(template: &str, args: &[&str]) -> String {
195 let mut out = String::with_capacity(template.len());
196 let mut chars = template.chars().peekable();
197 let mut idx = 0usize;
198
199 while let Some(ch) = chars.next() {
200 if ch == '{' && chars.peek() == Some(&'}') {
201 let _ = chars.next();
202 if let Some(val) = args.get(idx) {
203 out.push_str(val);
204 } else {
205 out.push_str("{}");
206 }
207 idx += 1;
208 continue;
209 }
210 out.push(ch);
211 }
212 out
213}