browser_automation_cli/i18n/
mod.rs1mod detect;
27mod en;
28mod ftl;
29mod idioma;
30mod mensagem;
31mod pt_br;
32
33pub use detect::{
34 detect_system_langid, negotiate, parse_langid, resolve, LocaleSource, ResolvedLocale, LANG_ENV,
35};
36pub use ftl::{format_ftl, ftl_keys, ftl_source};
37pub use idioma::{Direcao, Idioma, ScriptEscrita};
38pub use mensagem::{ftl_id, Mensagem};
39
40use std::sync::OnceLock;
41
42static EFFECTIVE: OnceLock<ResolvedLocale> = OnceLock::new();
49
50pub fn configure_console_utf8() {
54 crate::platform::configure_console();
55}
56
57pub fn resolve_locale(cli_lang: Option<&str>) -> ResolvedLocale {
59 let xdg = crate::xdg::load_config()
60 .ok()
61 .and_then(|c| c.lang)
62 .filter(|s| !s.trim().is_empty());
63 resolve(cli_lang, xdg.as_deref())
64}
65
66pub fn set_effective_idioma(resolved: ResolvedLocale) {
68 let idioma = resolved.idioma;
70 let source = resolved.source;
71 let system = resolved.system_raw.clone();
72 let _ = EFFECTIVE.set(resolved);
73 if source == LocaleSource::Default && system.is_none() {
74 tracing::debug!(
76 idioma = idioma.bcp47(),
77 source = source.as_str(),
78 "UI locale defaulted to en"
79 );
80 } else {
81 tracing::debug!(
82 idioma = idioma.bcp47(),
83 source = source.as_str(),
84 system = system.as_deref().unwrap_or(""),
85 "UI locale resolved"
86 );
87 }
88}
89
90pub fn effective_idioma() -> Idioma {
92 EFFECTIVE
93 .get()
94 .map(|r| r.idioma)
95 .unwrap_or(Idioma::En)
96}
97
98pub fn effective_resolved() -> ResolvedLocale {
100 EFFECTIVE.get().cloned().unwrap_or(ResolvedLocale {
101 idioma: Idioma::En,
102 source: LocaleSource::Default,
103 system_raw: None,
104 })
105}
106
107pub fn normalize_lang(lang: Option<&str>) -> &'static str {
111 match lang.and_then(Idioma::parse_token) {
112 Some(Idioma::PtBr) => "pt",
113 _ => "en",
114 }
115}
116
117pub fn resolve_lang(cli_lang: Option<&str>) -> &'static str {
121 resolve_locale(cli_lang).idioma.legacy_token()
122}
123
124pub fn set_effective_lang(lang: &'static str) {
128 let idioma = Idioma::parse_token(lang).unwrap_or(Idioma::En);
129 set_effective_idioma(ResolvedLocale {
130 idioma,
131 source: LocaleSource::Flag,
132 system_raw: None,
133 });
134}
135
136pub fn effective_lang() -> &'static str {
138 effective_idioma().legacy_token()
139}
140
141pub fn suggestion_for(kind: &str, lang: Option<&str>) -> Option<&'static str> {
143 let idioma = lang
144 .and_then(Idioma::parse_token)
145 .unwrap_or_else(effective_idioma);
146 Mensagem::from_error_kind(kind).map(|m| m.texto(idioma))
147}
148
149pub fn suggestion_key(key: &str, lang: Option<&str>) -> &'static str {
151 let idioma = lang
152 .and_then(Idioma::parse_token)
153 .unwrap_or_else(effective_idioma);
154 Mensagem::from_suggestion_key(key).texto(idioma)
155}
156
157pub fn localize_error_suggestion(err: &crate::error::CliError) -> crate::error::CliError {
159 let idioma = effective_idioma();
160 if idioma == Idioma::En {
161 return err.clone();
162 }
163 if err.suggestion().is_none() {
165 if let Some(s) = suggestion_for(err.kind().as_str(), Some(idioma.legacy_token())) {
166 let mut out = crate::error::CliError::with_suggestion(err.kind(), err.message(), s);
167 if let Some(d) = err.data() {
168 out = out.with_data(d.clone());
169 }
170 return out;
171 }
172 return err.clone();
173 }
174 let s = err.suggestion().unwrap_or("");
175 let mapped = match s {
177 "Pass --experimental-vision on the same invocation" => {
178 Mensagem::VisionRequired.texto(idioma)
179 }
180 "Pass both flags together when you intentionally skip robots.txt" => {
181 Mensagem::RobotsDual.texto(idioma)
182 }
183 "Pass --category-memory (heap take/summary/close work without deep graph ops)" => {
184 Mensagem::CategoryMemory.texto(idioma)
185 }
186 "Pass --category-extensions on the same invocation" => {
187 Mensagem::CategoryExtensions.texto(idioma)
188 }
189 "Pass --experimental-screencast on the same invocation" => {
190 Mensagem::ScreencastFlag.texto(idioma)
191 }
192 "Pass --category-webmcp on the same invocation" => Mensagem::WebmcpFlag.texto(idioma),
193 "Pass --category-third-party on the same invocation" => {
194 Mensagem::ThirdPartyFlag.texto(idioma)
195 }
196 "Pass --capture-network before run/net" => Mensagem::CaptureNetwork.texto(idioma),
197 "Pass --capture-console before run/console" => Mensagem::CaptureConsole.texto(idioma),
198 "Fix the failing step; subsequent steps were not executed" => {
199 Mensagem::RunFailFast.texto(idioma)
200 }
201 other if other.contains("lighthouse") && other.contains("npm") => {
202 Mensagem::LighthouseMissing.texto(idioma)
203 }
204 other if other.contains("lighthouse") && other.contains("config set") => {
205 Mensagem::LighthouseMissing.texto(idioma)
206 }
207 _ => {
208 return err.clone();
209 }
210 };
211 let mut out = crate::error::CliError::with_suggestion(err.kind(), err.message(), mapped);
212 if let Some(d) = err.data() {
213 out = out.with_data(d.clone());
214 }
215 out
216}
217
218pub fn locale_diagnostics() -> serde_json::Value {
220 let r = effective_resolved();
221 let sys = sys_locale::get_locale();
222 serde_json::json!({
223 "resolved": r.idioma.bcp47(),
224 "legacy": r.idioma.legacy_token(),
225 "source": r.source.as_str(),
226 "direction": match r.idioma.direcao() {
227 Direcao::Ltr => "ltr",
228 Direcao::Rtl => "rtl",
229 },
230 "script": format!("{:?}", r.idioma.script()).to_ascii_lowercase(),
231 "available": Idioma::DISPONIVEIS.iter().map(|i| i.bcp47()).collect::<Vec<_>>(),
232 "system_locale": sys,
233 "env_override": std::env::var(LANG_ENV).ok(),
234 "lang_env_key": LANG_ENV,
235 "product_note": "error.message and stdout JSON stay English; suggestions localize",
236 })
237}
238
239pub fn truncate_graphemes(s: &str, max: usize) -> String {
241 use unicode_segmentation::UnicodeSegmentation;
242 if max == 0 {
243 return String::new();
244 }
245 let mut out = String::new();
246 for (i, g) in s.graphemes(true).enumerate() {
247 if i >= max {
248 break;
249 }
250 out.push_str(g);
251 }
252 out
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn all_mensagem_non_empty_both_locales() {
261 for m in Mensagem::ALL {
262 let en = m.texto(Idioma::En);
263 let pt = m.texto(Idioma::PtBr);
264 assert!(!en.is_empty(), "empty en for {m:?}");
265 assert!(!pt.is_empty(), "empty pt-BR for {m:?}");
266 assert_ne!(en, "", "{m:?}");
267 }
268 }
269
270 #[test]
271 fn pt_br_has_accents_on_critical_keys() {
272 assert!(Mensagem::VisionRequired.texto(Idioma::PtBr).contains("invocação"));
273 assert!(Mensagem::RobotsDual.texto(Idioma::PtBr).contains("propósito"));
274 assert!(Mensagem::RunFailFast.texto(Idioma::PtBr).contains("não"));
275 assert!(Mensagem::UsageSuggestion.texto(Idioma::PtBr).contains("obrigatórios"));
276 }
277
278 #[test]
279 fn texto_api_no_global_required() {
280 assert_eq!(
282 Mensagem::UsageSuggestion.texto(Idioma::En),
283 "Check --help and required arguments"
284 );
285 }
286
287 #[test]
288 fn truncate_respects_graphemes() {
289 let s = "ação";
290 assert_eq!(truncate_graphemes(s, 2), "aç");
291 assert_eq!(truncate_graphemes(s, 10), "ação");
292 }
293}