1#![forbid(unsafe_code)]
4use std::path::{Path, PathBuf};
26use std::str::FromStr;
27use std::sync::OnceLock;
28
29use fluent_langneg::{negotiate_languages, NegotiationStrategy};
30use unic_langid::LanguageIdentifier;
31
32use crate::i18n::Language;
33
34static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum LocaleSource {
44 CliFlag,
46 EnvVar,
48 Persisted,
50 System,
52 Default,
54}
55
56impl LocaleSource {
57 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::CliFlag => "cli_flag",
62 Self::EnvVar => "env_var",
63 Self::Persisted => "persisted",
64 Self::System => "system",
65 Self::Default => "default",
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct LocaleResolution {
73 pub language: Language,
75 pub source: LocaleSource,
77 pub system_raw: Option<String>,
79 pub persisted_raw: Option<String>,
81}
82
83pub const LANG_PREFERENCE_FILE: &str = crate::constants::LANG_PREFERENCE_FILE_NAME;
85
86#[must_use]
92pub fn resolve_language(force_lang: Option<&str>, config_dir_override: Option<&Path>) -> Language {
93 resolve_language_detailed(force_lang, config_dir_override).language
94}
95
96#[must_use]
98pub fn resolve_language_detailed(
99 force_lang: Option<&str>,
100 config_dir_override: Option<&Path>,
101) -> LocaleResolution {
102 let system_raw = sys_locale::get_locale();
103 let persisted_raw = read_persisted_lang(config_dir_override);
104
105 if let Some(code) = force_lang {
107 match negotiate_code(code) {
108 Some(language) => {
109 return LocaleResolution {
110 language,
111 source: LocaleSource::CliFlag,
112 system_raw,
113 persisted_raw,
114 };
115 }
116 None => {
117 tracing::warn!(
118 target: "ssh_cli::locale",
119 code,
120 "invalid or unsupported --lang; falling through precedence"
121 );
122 }
123 }
124 }
125
126 if let Some(ref raw) = persisted_raw {
130 match negotiate_code(raw) {
131 Some(language) => {
132 return LocaleResolution {
133 language,
134 source: LocaleSource::Persisted,
135 system_raw,
136 persisted_raw,
137 };
138 }
139 None => {
140 tracing::warn!(
141 target: "ssh_cli::locale",
142 code = %raw,
143 "persisted lang preference unsupported; falling through"
144 );
145 }
146 }
147 }
148
149 if let Some(ref locale) = system_raw {
151 match negotiate_code(locale) {
152 Some(language) => {
153 return LocaleResolution {
154 language,
155 source: LocaleSource::System,
156 system_raw,
157 persisted_raw,
158 };
159 }
160 None => {
161 tracing::warn!(
162 target: "ssh_cli::locale",
163 system_locale = %locale,
164 "OS locale did not negotiate to a supported language; using default en"
165 );
166 }
167 }
168 } else {
169 tracing::warn!(
170 target: "ssh_cli::locale",
171 "sys_locale::get_locale returned None (container/distroless/WASM); using default en"
172 );
173 }
174
175 LocaleResolution {
177 language: Language::English,
178 source: LocaleSource::Default,
179 system_raw,
180 persisted_raw,
181 }
182}
183
184pub fn set_language(language: Language) {
189 let _ = GLOBAL_LANGUAGE.set(language);
190}
191
192#[must_use]
197pub fn current_language() -> Language {
198 GLOBAL_LANGUAGE.get().copied().unwrap_or(Language::English)
199}
200
201#[must_use]
211pub fn normalize_raw_locale(raw: &str) -> String {
212 let s = raw.trim();
213 let s = s.split('.').next().unwrap_or(s);
214 let s = s.split('@').next().unwrap_or(s);
215 s.replace('_', "-")
216}
217
218#[must_use]
222pub fn parse_language_identifier(raw: &str) -> Option<LanguageIdentifier> {
223 let normalized = normalize_raw_locale(raw);
224 if normalized.is_empty() {
225 return None;
226 }
227 if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
229 return None;
230 }
231 LanguageIdentifier::from_str(&normalized).ok()
232}
233
234#[must_use]
238pub fn negotiate_code(raw: &str) -> Option<Language> {
239 let requested = parse_language_identifier(raw)?;
240 negotiate_langid(&requested)
241}
242
243#[must_use]
245pub fn negotiate_langid(requested: &LanguageIdentifier) -> Option<Language> {
246 let available: Vec<LanguageIdentifier> = Language::AVAILABLE
247 .iter()
248 .map(|l| l.language_identifier())
249 .collect();
250 let default = Language::English.language_identifier();
251 let supported = negotiate_languages(
252 std::slice::from_ref(requested),
253 &available,
254 Some(&default),
255 NegotiationStrategy::Lookup,
256 );
257 let first = supported.first()?;
258 if let Some(lang) = Language::from_langid(first) {
261 let req_lang = requested.language.as_str();
264 if lang == Language::English
265 && req_lang != "en"
266 && !Language::AVAILABLE
267 .iter()
268 .any(|a| a.language_identifier().language == requested.language)
269 {
270 return None;
271 }
272 return Some(lang);
273 }
274 None
275}
276
277#[must_use]
279pub fn resolve_config_dir(config_dir_override: Option<&Path>) -> Option<PathBuf> {
280 if let Some(p) = config_dir_override {
281 if p.is_dir() || !p.exists() {
282 return Some(p.to_path_buf());
283 }
284 return p.parent().map(|d| d.to_path_buf());
286 }
287 crate::vps::default_config_path()
288 .ok()
289 .and_then(|cfg| cfg.parent().map(|d| d.to_path_buf()))
290}
291
292#[must_use]
294pub fn lang_preference_path(config_dir_override: Option<&Path>) -> Option<PathBuf> {
295 resolve_config_dir(config_dir_override).map(|d| d.join(LANG_PREFERENCE_FILE))
296}
297
298#[must_use]
300pub fn read_persisted_lang(config_dir_override: Option<&Path>) -> Option<String> {
301 let path = lang_preference_path(config_dir_override)?;
302 let content = std::fs::read_to_string(&path).ok()?;
303 let trimmed = content.trim();
304 if trimmed.is_empty() {
305 None
306 } else {
307 Some(trimmed.to_string())
308 }
309}
310
311pub fn write_persisted_lang(
318 language: Language,
319 config_dir_override: Option<&Path>,
320) -> std::io::Result<PathBuf> {
321 let dir = resolve_config_dir(config_dir_override).ok_or_else(|| {
322 std::io::Error::new(
323 std::io::ErrorKind::NotFound,
324 "configuration directory unavailable",
325 )
326 })?;
327 std::fs::create_dir_all(&dir)?;
328 let path = dir.join(LANG_PREFERENCE_FILE);
329 let body = format!("{}\n", language.bcp47());
330 std::fs::write(&path, body.as_bytes())?;
331 crate::fs_perm::set_secret_file_mode(&path).map_err(|e| match e {
332 crate::errors::SshCliError::Io(io) => io,
333 other => std::io::Error::other(other.to_string()),
334 })?;
335 Ok(path)
336}
337
338pub fn clear_persisted_lang(config_dir_override: Option<&Path>) -> std::io::Result<()> {
343 if let Some(path) = lang_preference_path(config_dir_override) {
344 match std::fs::remove_file(&path) {
345 Ok(()) => Ok(()),
346 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
347 Err(e) => Err(e),
348 }
349 } else {
350 Ok(())
351 }
352}
353
354pub fn parse_lang_cli_arg(s: &str) -> Result<String, String> {
357 match negotiate_code(s) {
358 Some(lang) => Ok(lang.bcp47().to_string()),
359 None => Err(format!(
360 "unsupported language '{s}' (supported: en, pt-BR; BCP47 tags that negotiate to these)"
361 )),
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn normalize_strips_encoding_and_underscore() {
371 assert_eq!(normalize_raw_locale("pt_BR.UTF-8"), "pt-BR");
372 assert_eq!(normalize_raw_locale("en_US.utf8"), "en-US");
373 assert_eq!(normalize_raw_locale("de_DE@euro"), "de-DE");
374 assert_eq!(normalize_raw_locale(" pt-BR "), "pt-BR");
375 }
376
377 #[test]
378 fn parse_rejects_c_and_posix() {
379 assert!(parse_language_identifier("C").is_none());
380 assert!(parse_language_identifier("POSIX").is_none());
381 assert!(parse_language_identifier("C.UTF-8").is_none());
382 assert!(parse_language_identifier("").is_none());
383 }
384
385 #[test]
386 fn parse_accepts_bcp47_variants() {
387 assert!(parse_language_identifier("pt-BR").is_some());
388 assert!(parse_language_identifier("pt_BR.UTF-8").is_some());
389 assert!(parse_language_identifier("en-GB").is_some());
390 assert!(parse_language_identifier("en").is_some());
391 }
392
393 #[test]
394 fn negotiate_pt_br_and_prefixes() {
395 assert_eq!(negotiate_code("pt-BR"), Some(Language::Portuguese));
396 assert_eq!(negotiate_code("pt_BR.UTF-8"), Some(Language::Portuguese));
397 assert_eq!(negotiate_code("pt"), Some(Language::Portuguese));
398 assert_eq!(negotiate_code("en-US"), Some(Language::English));
399 assert_eq!(negotiate_code("en-GB"), Some(Language::English));
400 assert_eq!(negotiate_code("en"), Some(Language::English));
401 }
402
403 #[test]
404 fn negotiate_unsupported_returns_none() {
405 assert_eq!(negotiate_code("fr-FR"), None);
406 assert_eq!(negotiate_code("zh-Hans-CN"), None);
407 assert_eq!(negotiate_code("xx-YY"), None);
408 assert_eq!(negotiate_code("C"), None);
409 }
410
411 #[test]
412 fn parse_lang_cli_arg_ok_and_err() {
413 assert_eq!(parse_lang_cli_arg("pt-BR").unwrap(), "pt-BR");
414 assert_eq!(parse_lang_cli_arg("en").unwrap(), "en");
415 assert!(parse_lang_cli_arg("fr-FR").is_err());
416 }
417
418 #[test]
419 fn resolve_force_pt_returns_portuguese() {
420 let result = resolve_language(Some("pt-BR"), None);
421 assert_eq!(result, Language::Portuguese);
422 }
423
424 #[test]
425 fn resolve_force_en_returns_english() {
426 let result = resolve_language(Some("en-US"), None);
427 assert_eq!(result, Language::English);
428 }
429
430 #[test]
431 fn resolve_force_invalid_falls_through() {
432 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
433 let result = resolve_language_detailed(Some("xx-YY"), None);
434 assert!(
435 result.language == Language::English || result.language == Language::Portuguese,
436 "must return a valid language"
437 );
438 assert_ne!(result.source, LocaleSource::CliFlag);
439 }
440
441 #[test]
442 fn resolve_without_force_returns_valid_language() {
443 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
444 let result = resolve_language(None, None);
445 assert!(
446 result == Language::English || result == Language::Portuguese,
447 "resolve_language must return a valid language"
448 );
449 }
450
451 #[test]
452 fn current_language_fallback_english_before_set() {
453 let result = current_language();
454 assert!(
455 result == Language::English || result == Language::Portuguese,
456 "current_language must return a valid language"
457 );
458 }
459
460 #[test]
461 fn locale_source_as_str_stable() {
462 assert_eq!(LocaleSource::CliFlag.as_str(), "cli_flag");
463 assert_eq!(LocaleSource::Default.as_str(), "default");
464 }
465
466 #[test]
467 fn persisted_lang_roundtrip() {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let path = write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
470 assert!(path.exists());
471 let raw = read_persisted_lang(Some(dir.path())).expect("read");
472 assert_eq!(negotiate_code(&raw), Some(Language::Portuguese));
473 clear_persisted_lang(Some(dir.path())).expect("clear");
474 assert!(read_persisted_lang(Some(dir.path())).is_none());
475 }
476
477 #[test]
478 fn resolve_uses_persisted_when_no_flag_or_env() {
479 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
480 let dir = tempfile::tempdir().expect("tempdir");
481 write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
482 let r = resolve_language_detailed(None, Some(dir.path()));
483 assert_eq!(r.language, Language::Portuguese);
486 assert_eq!(r.source, LocaleSource::Persisted);
487 }
488}