Skip to main content

browser_automation_cli/i18n/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Automatic multi-language UI for human-facing suggestions.
3//!
4//! # Language isolation (crates.io / agent contract)
5//!
6//! - **All identifiers, comments, and technical `message` fields are English.**
7//! - **Portuguese appears only as catalog string literals** for human `suggestion`
8//!   UI text when locale resolves to `pt-BR` (not in identifiers or logs).
9//! - Agent-stable JSON `error.message` remains English regardless of locale.
10//! - Prefer [`Mensagem`] / [`suggestion_key`] over hardcoding UI strings at call sites.
11//! - Stdout JSON envelopes are **not** translated (machine contract).
12//!
13//! # Boot order (rules multi-idioma)
14//!
15//! 1. Windows console UTF-8 ([`configure_console_utf8`])
16//! 2. TTY / plain / screen-reader hints (see [`crate::color`])
17//! 3. OS locale via `sys-locale` inside [`resolve_locale`]
18//! 4. Parse → `LanguageIdentifier` (`unic-langid`)
19//! 5. Negotiate against compiled packs (`fluent-langneg`)
20//! 6. Publish in [`OnceLock`] via [`set_effective_idioma`]
21//!
22//! # Precedence (5 layers)
23//!
24//! `--lang` → `BROWSER_AUTOMATION_CLI_LANG` → XDG `lang` → system → default `en`
25
26mod 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
42/// Process-wide effective UI locale, set once at CLI boot.
43///
44/// # Concurrency
45///
46/// `OnceLock` is `Sync`; first successful `set` wins. Concurrent readers see
47/// either the initialized value or the default [`Idioma::En`] via [`effective_idioma`].
48static EFFECTIVE: OnceLock<ResolvedLocale> = OnceLock::new();
49
50/// Configure Windows console to UTF-8 (and VT) before any user-facing I/O.
51///
52/// Delegates to [`crate::platform::configure_console`] (single multiplatform entry).
53pub fn configure_console_utf8() {
54    crate::platform::configure_console();
55}
56
57/// Resolve language from CLI flag, then env, XDG, OS locale (see [`resolve`]).
58pub 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
66/// Store effective locale for the process (call once from `run()`).
67pub fn set_effective_idioma(resolved: ResolvedLocale) {
68    // Clone fields needed for tracing before move into OnceLock (owned system_raw).
69    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        // Detection failed or empty chain — local observability only.
75        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
90/// Current effective idioma (defaults to `en` if unset).
91pub fn effective_idioma() -> Idioma {
92    EFFECTIVE
93        .get()
94        .map(|r| r.idioma)
95        .unwrap_or(Idioma::En)
96}
97
98/// Full resolved snapshot (for `locale` subcommand).
99pub 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
107// ── Compatibility API (legacy `&'static str` tokens) ─────────────────────
108
109/// Normalize lang token to legacy `"en"` or `"pt"`.
110pub 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
117/// Resolve language from CLI flag, then XDG config, then OS locale hints.
118///
119/// Returns legacy `"en"` / `"pt"` tokens for older call sites.
120pub fn resolve_lang(cli_lang: Option<&str>) -> &'static str {
121    resolve_locale(cli_lang).idioma.legacy_token()
122}
123
124/// Store effective language for the process (call once from `run()`).
125///
126/// Accepts legacy `"en"` / `"pt"` / BCP47 tokens.
127pub 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
136/// Current effective language legacy token (defaults to `en` if unset).
137pub fn effective_lang() -> &'static str {
138    effective_idioma().legacy_token()
139}
140
141/// Localized suggestion for a known kind key.
142pub 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
149/// Catalog of stable suggestion keys (preferred over hard-coded English).
150pub 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
157/// Apply kind-based localized suggestion when none is set, or re-map known EN strings.
158pub 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    // Prefer catalog by kind when suggestion is missing.
164    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    // Map common English suggestions to Portuguese via enum catalog.
176    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
218/// JSON-ready diagnostics for `locale` subcommand (machine keys English).
219pub 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
239/// Grapheme-aware truncation for terminal width (CJK-safe boundary).
240pub 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        // Tests must not depend on process OnceLock.
281        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}