Skip to main content

browser_automation_cli/i18n/
detect.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cross-platform locale detection + negotiation (single boot path).
3
4use fluent_langneg::negotiate::NegotiationStrategy;
5use fluent_langneg::negotiate_languages;
6use unic_langid::LanguageIdentifier;
7
8use super::idioma::Idioma;
9
10/// Product env override: `BROWSER_AUTOMATION_CLI_LANG` (rules: `<CRATE_UPPER>_LANG`).
11pub const LANG_ENV: &str = "BROWSER_AUTOMATION_CLI_LANG";
12
13/// Where the effective UI locale came from (diagnostics / `locale` subcommand).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum LocaleSource {
17    /// Global `--lang` argv flag.
18    Flag,
19    /// `BROWSER_AUTOMATION_CLI_LANG` process environment.
20    Env,
21    /// XDG config `lang` key.
22    Xdg,
23    /// OS locale via `sys-locale` + negotiation.
24    System,
25    /// Hard fallback (`en`) when nothing else matched.
26    Default,
27}
28
29impl LocaleSource {
30    /// Stable machine token for JSON diagnostics (`flag` / `env` / `xdg` / `system` / `default`).
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            LocaleSource::Flag => "flag",
34            LocaleSource::Env => "env",
35            LocaleSource::Xdg => "xdg",
36            LocaleSource::System => "system",
37            LocaleSource::Default => "default",
38        }
39    }
40}
41
42/// Result of the 5-layer resolution chain.
43///
44/// # Ownership
45///
46/// Owned fields only — never `Box::leak` / artificial `'static` for diagnostics
47/// (rules_rust_ownership: `'static` only for true program-lifetime data).
48/// `idioma` / `source` are `Copy`; `system_raw` is an owned `String` when present.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedLocale {
51    /// Compiled UI pack selected for human suggestions.
52    pub idioma: Idioma,
53    /// Which precedence layer produced `idioma`.
54    pub source: LocaleSource,
55    /// Raw OS string from `sys-locale` when consulted (owned; not always set).
56    pub system_raw: Option<String>,
57}
58
59/// Available language identifiers for negotiation (static MVP packs).
60fn available_langids() -> Vec<LanguageIdentifier> {
61    Idioma::DISPONIVEIS
62        .iter()
63        .map(|i| i.language_identifier())
64        .collect()
65}
66
67/// Normalize raw OS / user locale strings into a [`LanguageIdentifier`].
68///
69/// Accepts `pt_BR.UTF-8`, `pt-BR`, `en_US.utf8`, etc.
70pub fn parse_langid(raw: &str) -> Option<LanguageIdentifier> {
71    let mut s = raw.trim().replace('_', "-");
72    if s.is_empty() {
73        return None;
74    }
75    // Drop encoding / modifier suffixes: `pt-BR.UTF-8@euro` → `pt-BR`
76    if let Some(idx) = s.find(['.', '@']) {
77        s.truncate(idx);
78    }
79    // Reject C / POSIX as user preference (rules: never treat as en-US synonym).
80    let lower = s.to_ascii_lowercase();
81    if lower == "c" || lower == "posix" {
82        return None;
83    }
84    s.parse().ok()
85}
86
87/// Negotiate requested identifiers against compiled packs; always returns a pack.
88pub fn negotiate(requested: &[LanguageIdentifier]) -> Idioma {
89    let available = available_langids();
90    let default = Idioma::En.language_identifier();
91    let matched = negotiate_languages(
92        requested,
93        &available,
94        Some(&default),
95        NegotiationStrategy::Filtering,
96    );
97    matched
98        .first()
99        .and_then(|id| Idioma::from_langid(id))
100        .or_else(|| {
101            // Language-only fallback (e.g. requested pt-PT → no pack → try language pt → None
102            // then default en; requested pt-BR → pack).
103            requested.iter().find_map(Idioma::from_langid)
104        })
105        .unwrap_or(Idioma::En)
106}
107
108/// Read OS locale once via `sys-locale` (never direct `LANG` reads in portable code).
109pub fn detect_system_langid() -> Option<LanguageIdentifier> {
110    let raw = sys_locale::get_locale()?;
111    parse_langid(&raw)
112}
113
114/// Full 5-layer resolution:
115/// 1. `--lang` flag
116/// 2. `BROWSER_AUTOMATION_CLI_LANG`
117/// 3. XDG `lang`
118/// 4. OS via `sys-locale` + fluent-langneg
119/// 5. default `en`
120///
121/// When the system layer is consulted, `system_raw` holds an **owned** copy of the
122/// OS locale string for `locale` diagnostics (no process-lifetime leak).
123pub fn resolve(
124    cli_lang: Option<&str>,
125    xdg_lang: Option<&str>,
126) -> ResolvedLocale {
127    // Layer 1 — flag
128    if let Some(raw) = cli_lang.map(str::trim).filter(|s| !s.is_empty()) {
129        if let Some(idioma) = Idioma::parse_token(raw) {
130            return ResolvedLocale {
131                idioma,
132                source: LocaleSource::Flag,
133                system_raw: None,
134            };
135        }
136        // Invalid flag value: fall through but do not panic (clap may pre-validate).
137        tracing::warn!(value = raw, "invalid --lang value; continuing resolution chain");
138    }
139
140    // Layer 2 — product lang env (explicitly allowed for i18n; not general config).
141    if let Ok(raw) = std::env::var(LANG_ENV) {
142        let t = raw.trim();
143        if !t.is_empty() {
144            if let Some(idioma) = Idioma::parse_token(t) {
145                return ResolvedLocale {
146                    idioma,
147                    source: LocaleSource::Env,
148                    system_raw: None,
149                };
150            }
151            tracing::warn!(
152                env = LANG_ENV,
153                value = t,
154                "invalid BROWSER_AUTOMATION_CLI_LANG; continuing resolution chain"
155            );
156        }
157    }
158
159    // Layer 3 — XDG persisted preference
160    if let Some(raw) = xdg_lang.map(str::trim).filter(|s| !s.is_empty()) {
161        if let Some(idioma) = Idioma::parse_token(raw) {
162            return ResolvedLocale {
163                idioma,
164                source: LocaleSource::Xdg,
165                system_raw: None,
166            };
167        }
168        tracing::warn!(value = raw, "invalid XDG lang; continuing resolution chain");
169    }
170
171    // Layer 4 — OS locale (sys-locale abstracts LC_ALL/LC_MESSAGES/LANG / Win32 / CF)
172    match sys_locale::get_locale() {
173        Some(raw) => {
174            // Own the OS string once; never Box::leak for Copy convenience.
175            if let Some(id) = parse_langid(&raw) {
176                let idioma = negotiate(std::slice::from_ref(&id));
177                // If OS said something we could not map better than default and raw was C, still default.
178                return ResolvedLocale {
179                    idioma,
180                    source: LocaleSource::System,
181                    system_raw: Some(raw),
182                };
183            }
184            tracing::debug!(
185                raw = %raw,
186                "OS locale unparsable; falling back to default en"
187            );
188            ResolvedLocale {
189                idioma: Idioma::En,
190                source: LocaleSource::Default,
191                system_raw: Some(raw),
192            }
193        }
194        None => {
195            // Signal detection failure to local observability (no remote telemetry).
196            tracing::debug!("sys-locale returned None; using default en");
197            ResolvedLocale {
198                idioma: Idioma::En,
199                source: LocaleSource::Default,
200                system_raw: None,
201            }
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn parse_strips_encoding_and_underscore() {
212        let id = parse_langid("pt_BR.UTF-8").expect("parse");
213        assert_eq!(id.language.as_str(), "pt");
214        assert_eq!(id.region.as_ref().map(|r| r.as_str()), Some("BR"));
215    }
216
217    #[test]
218    fn reject_c_locale() {
219        assert!(parse_langid("C").is_none());
220        assert!(parse_langid("POSIX").is_none());
221    }
222
223    #[test]
224    fn negotiate_pt_br_prefers_pack() {
225        let id: LanguageIdentifier = "pt-BR".parse().unwrap();
226        assert_eq!(negotiate(&[id]), Idioma::PtBr);
227    }
228
229    #[test]
230    fn negotiate_unknown_falls_to_en() {
231        let id: LanguageIdentifier = "ja-JP".parse().unwrap();
232        assert_eq!(negotiate(&[id]), Idioma::En);
233    }
234
235    #[test]
236    fn flag_layer_wins() {
237        let r = resolve(Some("pt-BR"), Some("en"));
238        assert_eq!(r.idioma, Idioma::PtBr);
239        assert_eq!(r.source, LocaleSource::Flag);
240    }
241}