Skip to main content

ssh_cli/
locale.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cross-platform language detection and resolution.
3//!
4//! Language selection precedence (highest to lowest):
5//! 1. CLI `--lang` flag
6//! 2. `SSH_CLI_LANG` environment variable
7//! 3. System locale via `sys_locale::get_locale()`
8//! 4. Fallback: `Language::English`
9
10use std::sync::OnceLock;
11
12use crate::i18n::Language;
13
14/// Global language state — set once at initialization.
15static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
16
17/// Resolves language using a 4-layer precedence hierarchy.
18///
19/// Returns the first valid language found in order:
20/// flag CLI > env SSH_CLI_LANG > sys_locale > English.
21pub fn resolve_language(force_lang: Option<&str>) -> Language {
22    // Camada 1: flag --lang da CLI
23    if let Some(codigo) = force_lang {
24        if let Some(language) = code_to_language(codigo) {
25            return language;
26        }
27    }
28
29    // Layer 2: SSH_CLI_LANG environment variable
30    if let Ok(env_lang) = std::env::var("SSH_CLI_LANG") {
31        if let Some(language) = code_to_language(&env_lang) {
32            return language;
33        }
34    }
35
36    // Camada 3: locale do sistema via sys_locale
37    if let Some(locale) = sys_locale::get_locale() {
38        if let Some(language) = code_to_language(&locale) {
39            return language;
40        }
41    }
42
43    // Camada 4: fallback incondicional
44    Language::English
45}
46
47/// Sets the global language (once at process startup).
48///
49/// Subsequent calls are silently ignored — `OnceLock`
50/// guarantees the language is immutable after first set.
51pub fn set_language(language: Language) {
52    let _ = GLOBAL_LANGUAGE.set(language);
53}
54
55/// Returns the current global language.
56///
57/// If `set_language` has not been called yet, returns `Language::English`
58/// as a safe fallback for code run before initialization.
59pub fn current_language() -> Language {
60    GLOBAL_LANGUAGE.get().copied().unwrap_or(Language::English)
61}
62
63/// Converts a language code string to `Language`.
64///
65/// Recognizes "pt" and "en" prefixes with any region suffix,
66/// case-insensitively.
67fn code_to_language(codigo: &str) -> Option<Language> {
68    let normalizado = codigo.to_lowercase();
69    match normalizado.as_str() {
70        "pt" | "pt-br" | "pt_br" => Some(Language::Portuguese),
71        "en" | "en-us" | "en_us" => Some(Language::English),
72        outro => {
73            if outro.starts_with("pt") {
74                Some(Language::Portuguese)
75            } else if outro.starts_with("en") {
76                Some(Language::English)
77            } else {
78                None
79            }
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn code_pt_returns_portuguese() {
90        assert_eq!(code_to_language("pt"), Some(Language::Portuguese));
91    }
92
93    #[test]
94    fn code_pt_br_returns_portuguese() {
95        assert_eq!(code_to_language("pt-BR"), Some(Language::Portuguese));
96    }
97
98    #[test]
99    fn code_pt_br_underscore_returns_portuguese() {
100        assert_eq!(code_to_language("pt_BR"), Some(Language::Portuguese));
101    }
102
103    #[test]
104    fn code_en_returns_english() {
105        assert_eq!(code_to_language("en"), Some(Language::English));
106    }
107
108    #[test]
109    fn code_en_us_returns_english() {
110        assert_eq!(code_to_language("en-US"), Some(Language::English));
111    }
112
113    #[test]
114    fn code_en_gb_returns_english_by_prefix() {
115        assert_eq!(code_to_language("en-GB"), Some(Language::English));
116    }
117
118    #[test]
119    fn unknown_code_returns_none() {
120        assert_eq!(code_to_language("fr-FR"), None);
121    }
122
123    #[test]
124    fn empty_code_returns_none() {
125        assert_eq!(code_to_language(""), None);
126    }
127
128    #[test]
129    fn uppercase_code_normalized() {
130        assert_eq!(code_to_language("PT"), Some(Language::Portuguese));
131        assert_eq!(code_to_language("EN"), Some(Language::English));
132    }
133
134    #[test]
135    fn resolve_force_pt_returns_portuguese() {
136        let result = resolve_language(Some("pt-BR"));
137        assert_eq!(result, Language::Portuguese);
138    }
139
140    #[test]
141    fn resolve_force_en_returns_english() {
142        let result = resolve_language(Some("en-US"));
143        assert_eq!(result, Language::English);
144    }
145
146    #[test]
147    fn resolve_force_invalid_uses_next_layers() {
148        // Invalid code does not resolve on layer 1; must fall through to sys_locale or fallback.
149        std::env::remove_var("SSH_CLI_LANG");
150        let result = resolve_language(Some("xx-YY"));
151        // Must return English or Portuguese — cannot be an invalid value.
152        assert!(
153            result == Language::English || result == Language::Portuguese,
154            "resolve_language must return a valid language even with invalid code"
155        );
156    }
157
158    #[test]
159    fn resolve_without_force_returns_valid_language() {
160        std::env::remove_var("SSH_CLI_LANG");
161        let result = resolve_language(None);
162        assert!(
163            result == Language::English || result == Language::Portuguese,
164            "resolve_language must return a valid language"
165        );
166    }
167
168    #[test]
169    fn current_language_fallback_english_before_set() {
170        // We do not call set_language — OnceLock may already be set in other tests,
171        // but the result MUST be a valid language.
172        let result = current_language();
173        assert!(
174            result == Language::English || result == Language::Portuguese,
175            "current_language must return a valid language"
176        );
177    }
178}