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(
93 force_lang: Option<&str>,
94 config_dir_override: Option<&Path>,
95) -> Language {
96 resolve_language_detailed(force_lang, config_dir_override).language
97}
98
99#[must_use]
101pub fn resolve_language_detailed(
102 force_lang: Option<&str>,
103 config_dir_override: Option<&Path>,
104) -> LocaleResolution {
105 let system_raw = sys_locale::get_locale();
106 let persisted_raw = read_persisted_lang(config_dir_override);
107
108 if let Some(code) = force_lang {
110 match negotiate_code(code) {
111 Some(language) => {
112 return LocaleResolution {
113 language,
114 source: LocaleSource::CliFlag,
115 system_raw,
116 persisted_raw,
117 };
118 }
119 None => {
120 tracing::warn!(
121 target: "ssh_cli::locale",
122 code,
123 "invalid or unsupported --lang; falling through precedence"
124 );
125 }
126 }
127 }
128
129 if let Some(ref raw) = persisted_raw {
133 match negotiate_code(raw) {
134 Some(language) => {
135 return LocaleResolution {
136 language,
137 source: LocaleSource::Persisted,
138 system_raw,
139 persisted_raw,
140 };
141 }
142 None => {
143 tracing::warn!(
144 target: "ssh_cli::locale",
145 code = %raw,
146 "persisted lang preference unsupported; falling through"
147 );
148 }
149 }
150 }
151
152 if let Some(ref locale) = system_raw {
154 match negotiate_code(locale) {
155 Some(language) => {
156 return LocaleResolution {
157 language,
158 source: LocaleSource::System,
159 system_raw,
160 persisted_raw,
161 };
162 }
163 None => {
164 tracing::warn!(
165 target: "ssh_cli::locale",
166 system_locale = %locale,
167 "OS locale did not negotiate to a supported language; using default en"
168 );
169 }
170 }
171 } else {
172 tracing::warn!(
173 target: "ssh_cli::locale",
174 "sys_locale::get_locale returned None (container/distroless/WASM); using default en"
175 );
176 }
177
178 LocaleResolution {
180 language: Language::English,
181 source: LocaleSource::Default,
182 system_raw,
183 persisted_raw,
184 }
185}
186
187pub fn set_language(language: Language) {
192 let _ = GLOBAL_LANGUAGE.set(language);
193}
194
195#[must_use]
200pub fn current_language() -> Language {
201 GLOBAL_LANGUAGE.get().copied().unwrap_or(Language::English)
202}
203
204#[must_use]
214pub fn normalize_raw_locale(raw: &str) -> String {
215 let s = raw.trim();
216 let s = s.split('.').next().unwrap_or(s);
217 let s = s.split('@').next().unwrap_or(s);
218 s.replace('_', "-")
219}
220
221#[must_use]
225pub fn parse_language_identifier(raw: &str) -> Option<LanguageIdentifier> {
226 let normalized = normalize_raw_locale(raw);
227 if normalized.is_empty() {
228 return None;
229 }
230 if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
232 return None;
233 }
234 LanguageIdentifier::from_str(&normalized).ok()
235}
236
237#[must_use]
241pub fn negotiate_code(raw: &str) -> Option<Language> {
242 let requested = parse_language_identifier(raw)?;
243 negotiate_langid(&requested)
244}
245
246#[must_use]
248pub fn negotiate_langid(requested: &LanguageIdentifier) -> Option<Language> {
249 let available: Vec<LanguageIdentifier> = Language::AVAILABLE
250 .iter()
251 .map(|l| l.language_identifier())
252 .collect();
253 let default = Language::English.language_identifier();
254 let supported = negotiate_languages(
255 std::slice::from_ref(requested),
256 &available,
257 Some(&default),
258 NegotiationStrategy::Lookup,
259 );
260 let first = supported.first()?;
261 if let Some(lang) = Language::from_langid(first) {
264 let req_lang = requested.language.as_str();
267 if lang == Language::English
268 && req_lang != "en"
269 && !Language::AVAILABLE
270 .iter()
271 .any(|a| a.language_identifier().language == requested.language)
272 {
273 return None;
274 }
275 return Some(lang);
276 }
277 None
278}
279
280#[must_use]
282pub fn resolve_config_dir(config_dir_override: Option<&Path>) -> Option<PathBuf> {
283 if let Some(p) = config_dir_override {
284 if p.is_dir() || !p.exists() {
285 return Some(p.to_path_buf());
286 }
287 return p.parent().map(|d| d.to_path_buf());
289 }
290 crate::vps::default_config_path()
291 .ok()
292 .and_then(|cfg| cfg.parent().map(|d| d.to_path_buf()))
293}
294
295#[must_use]
297pub fn lang_preference_path(config_dir_override: Option<&Path>) -> Option<PathBuf> {
298 resolve_config_dir(config_dir_override).map(|d| d.join(LANG_PREFERENCE_FILE))
299}
300
301#[must_use]
303pub fn read_persisted_lang(config_dir_override: Option<&Path>) -> Option<String> {
304 let path = lang_preference_path(config_dir_override)?;
305 let content = std::fs::read_to_string(&path).ok()?;
306 let trimmed = content.trim();
307 if trimmed.is_empty() {
308 None
309 } else {
310 Some(trimmed.to_string())
311 }
312}
313
314pub fn write_persisted_lang(
321 language: Language,
322 config_dir_override: Option<&Path>,
323) -> std::io::Result<PathBuf> {
324 let dir = resolve_config_dir(config_dir_override).ok_or_else(|| {
325 std::io::Error::new(
326 std::io::ErrorKind::NotFound,
327 "configuration directory unavailable",
328 )
329 })?;
330 std::fs::create_dir_all(&dir)?;
331 let path = dir.join(LANG_PREFERENCE_FILE);
332 let body = format!("{}\n", language.bcp47());
333 std::fs::write(&path, body.as_bytes())?;
334 crate::fs_perm::set_secret_file_mode(&path).map_err(|e| match e {
335 crate::errors::SshCliError::Io(io) => io,
336 other => std::io::Error::other(other.to_string()),
337 })?;
338 Ok(path)
339}
340
341pub fn clear_persisted_lang(config_dir_override: Option<&Path>) -> std::io::Result<()> {
346 if let Some(path) = lang_preference_path(config_dir_override) {
347 match std::fs::remove_file(&path) {
348 Ok(()) => Ok(()),
349 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
350 Err(e) => Err(e),
351 }
352 } else {
353 Ok(())
354 }
355}
356
357pub fn parse_lang_cli_arg(s: &str) -> Result<String, String> {
360 match negotiate_code(s) {
361 Some(lang) => Ok(lang.bcp47().to_string()),
362 None => Err(format!(
363 "unsupported language '{s}' (supported: en, pt-BR; BCP47 tags that negotiate to these)"
364 )),
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn normalize_strips_encoding_and_underscore() {
374 assert_eq!(normalize_raw_locale("pt_BR.UTF-8"), "pt-BR");
375 assert_eq!(normalize_raw_locale("en_US.utf8"), "en-US");
376 assert_eq!(normalize_raw_locale("de_DE@euro"), "de-DE");
377 assert_eq!(normalize_raw_locale(" pt-BR "), "pt-BR");
378 }
379
380 #[test]
381 fn parse_rejects_c_and_posix() {
382 assert!(parse_language_identifier("C").is_none());
383 assert!(parse_language_identifier("POSIX").is_none());
384 assert!(parse_language_identifier("C.UTF-8").is_none());
385 assert!(parse_language_identifier("").is_none());
386 }
387
388 #[test]
389 fn parse_accepts_bcp47_variants() {
390 assert!(parse_language_identifier("pt-BR").is_some());
391 assert!(parse_language_identifier("pt_BR.UTF-8").is_some());
392 assert!(parse_language_identifier("en-GB").is_some());
393 assert!(parse_language_identifier("en").is_some());
394 }
395
396 #[test]
397 fn negotiate_pt_br_and_prefixes() {
398 assert_eq!(negotiate_code("pt-BR"), Some(Language::Portuguese));
399 assert_eq!(negotiate_code("pt_BR.UTF-8"), Some(Language::Portuguese));
400 assert_eq!(negotiate_code("pt"), Some(Language::Portuguese));
401 assert_eq!(negotiate_code("en-US"), Some(Language::English));
402 assert_eq!(negotiate_code("en-GB"), Some(Language::English));
403 assert_eq!(negotiate_code("en"), Some(Language::English));
404 }
405
406 #[test]
407 fn negotiate_unsupported_returns_none() {
408 assert_eq!(negotiate_code("fr-FR"), None);
409 assert_eq!(negotiate_code("zh-Hans-CN"), None);
410 assert_eq!(negotiate_code("xx-YY"), None);
411 assert_eq!(negotiate_code("C"), None);
412 }
413
414 #[test]
415 fn parse_lang_cli_arg_ok_and_err() {
416 assert_eq!(parse_lang_cli_arg("pt-BR").unwrap(), "pt-BR");
417 assert_eq!(parse_lang_cli_arg("en").unwrap(), "en");
418 assert!(parse_lang_cli_arg("fr-FR").is_err());
419 }
420
421 #[test]
422 fn resolve_force_pt_returns_portuguese() {
423 let result = resolve_language(Some("pt-BR"), None);
424 assert_eq!(result, Language::Portuguese);
425 }
426
427 #[test]
428 fn resolve_force_en_returns_english() {
429 let result = resolve_language(Some("en-US"), None);
430 assert_eq!(result, Language::English);
431 }
432
433 #[test]
434 fn resolve_force_invalid_falls_through() {
435 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
436 let result = resolve_language_detailed(Some("xx-YY"), None);
437 assert!(
438 result.language == Language::English || result.language == Language::Portuguese,
439 "must return a valid language"
440 );
441 assert_ne!(result.source, LocaleSource::CliFlag);
442 }
443
444 #[test]
445 fn resolve_without_force_returns_valid_language() {
446 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
447 let result = resolve_language(None, None);
448 assert!(
449 result == Language::English || result == Language::Portuguese,
450 "resolve_language must return a valid language"
451 );
452 }
453
454 #[test]
455 fn current_language_fallback_english_before_set() {
456 let result = current_language();
457 assert!(
458 result == Language::English || result == Language::Portuguese,
459 "current_language must return a valid language"
460 );
461 }
462
463 #[test]
464 fn locale_source_as_str_stable() {
465 assert_eq!(LocaleSource::CliFlag.as_str(), "cli_flag");
466 assert_eq!(LocaleSource::Default.as_str(), "default");
467 }
468
469 #[test]
470 fn persisted_lang_roundtrip() {
471 let dir = tempfile::tempdir().expect("tempdir");
472 let path = write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
473 assert!(path.exists());
474 let raw = read_persisted_lang(Some(dir.path())).expect("read");
475 assert_eq!(negotiate_code(&raw), Some(Language::Portuguese));
476 clear_persisted_lang(Some(dir.path())).expect("clear");
477 assert!(read_persisted_lang(Some(dir.path())).is_none());
478 }
479
480 #[test]
481 fn resolve_uses_persisted_when_no_flag_or_env() {
482 crate::test_util::env::remove_var(crate::constants::ENV_LANG);
483 let dir = tempfile::tempdir().expect("tempdir");
484 write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
485 let r = resolve_language_detailed(None, Some(dir.path()));
486 assert_eq!(r.language, Language::Portuguese);
489 assert_eq!(r.source, LocaleSource::Persisted);
490 }
491}