ssh_cli/i18n.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! ssh-cli internationalization system (Rules Rust multi-idioma).
5//!
6//! Provides bilingual [`Language`] with [`Message`] as the **single source** of
7//! human UI strings. Locale detection / BCP47 negotiation lives in [`crate::locale`].
8//!
9//! ## Design (agent-first one-shot)
10//!
11//! - **MVP locales:** neutral `en` + `pt-BR` (100% key parity via exhaustive `match`).
12//! - **Not Fluent FTL at runtime:** size-sensitive CLI; compiler-enforced enum
13//! translations are the embedded equivalent of `i18n-embed` for two locales.
14//! - **JSON / agent wire:** stable English field names and technical
15//! [`crate::errors::SshCliError`] `Display` (not locale-dependent).
16//! - **Human UX** (success/status/cancel lines): always via [`Message`] / [`t`].
17//! - Optional top-20 locales: Cargo features `i18n-*` (stubs until translations land).
18//!
19//! ## Precedence (see [`crate::locale`])
20//!
21//! 1. CLI `--lang` → 2. persisted XDG `lang` (`locale set`) →
22//! 3. `sys_locale` → 4. `Language::English`.
23//!
24//! `SSH_CLI_LANG` is historical only — not read as a product store.
25
26use anyhow::Result;
27use unic_langid::LanguageIdentifier;
28
29use crate::errors::SshCliError;
30
31// C3: the translation tables are data, one exhaustive `match` arm per variant,
32// and they made this file the second-largest in the crate. Splitting them out by
33// locale keeps the exhaustiveness guarantee (each `match` still has to cover
34// every variant) while making a one-sided edit visible as a single-file diff.
35mod en;
36mod pt;
37
38/// Text direction for terminal rendering (LTR MVP; RTL reserved for `i18n-rtl`).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum TextDirection {
42 /// Left-to-right (Latin, CJK horizontal, etc.).
43 Ltr,
44 /// Right-to-left (Arabic, Hebrew) — not active in default build.
45 Rtl,
46}
47
48/// Languages supported by the internationalization system.
49///
50/// Single source of truth for product locales in this binary. Do **not** use
51/// `bool` / raw `String` / integers for language in APIs.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[non_exhaustive]
54pub enum Language {
55 /// Neutral English (`en`) — default / agent-stable technical baseline.
56 English,
57 /// Brazilian Portuguese (`pt-BR`) — mandatory MVP pair with `en`.
58 Portuguese,
59}
60
61impl Language {
62 /// Locales compiled into the default binary (MVP: `en`, `pt-BR` only).
63 pub const AVAILABLE: &'static [Language] = &[Language::English, Language::Portuguese];
64
65 /// Canonical BCP47 tag for this product locale.
66 ///
67 /// English is neutral `en` (not `en-US` alone). Portuguese is always `pt-BR`.
68 #[must_use]
69 pub const fn bcp47(self) -> &'static str {
70 match self {
71 Self::English => "en",
72 Self::Portuguese => "pt-BR",
73 }
74 }
75
76 /// Structured BCP47 identifier (`unic-langid`).
77 ///
78 /// Built-in tags are compile-time constants (`en`, `pt-BR`). On parse
79 /// failure (should never happen), falls back to the default undetermined
80 /// identifier — **no panic** on product paths (G-SEC-07).
81 #[must_use]
82 pub fn language_identifier(self) -> LanguageIdentifier {
83 self.bcp47()
84 .parse()
85 .unwrap_or_else(|_| LanguageIdentifier::default())
86 }
87
88 /// Base fallback language for regionals (MVP: English).
89 #[must_use]
90 pub const fn fallback(self) -> Language {
91 match self {
92 Self::English => Self::English,
93 Self::Portuguese => Self::English,
94 }
95 }
96
97 /// Writing direction for this locale.
98 #[must_use]
99 pub const fn direction(self) -> TextDirection {
100 match self {
101 Self::English | Self::Portuguese => TextDirection::Ltr,
102 }
103 }
104
105 /// ISO 15924 script subtag (MVP Latin only).
106 #[must_use]
107 pub const fn script(self) -> &'static str {
108 match self {
109 Self::English | Self::Portuguese => "Latn",
110 }
111 }
112
113 /// Maps a negotiated [`LanguageIdentifier`] to a product [`Language`].
114 ///
115 /// Matches primary language subtag: `en*` → English, `pt*` → Portuguese.
116 /// Region-specific product choice for Portuguese is always `pt-BR` in MVP
117 /// (no `pt-PT` variant compiled without a feature).
118 #[must_use]
119 pub fn from_langid(id: &LanguageIdentifier) -> Option<Language> {
120 match id.language.as_str() {
121 "en" => Some(Self::English),
122 "pt" => Some(Self::Portuguese),
123 _ => None,
124 }
125 }
126}
127
128/// All system UI messages.
129///
130/// SINGLE source of user-visible strings. Each variant has an exhaustive
131/// translation in `en()` and `pt()`. FORBIDDEN to use UI literals outside this enum.
132///
133/// Variants with dynamic fields (e.g. `{ name: String }`) allow including
134/// contextual data in the message. Message is not `Copy` because
135/// `String` fields are not `Copy`.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum Message {
138 // VPS
139 /// No VPS registered in the configuration file.
140 VpsRegistryEmpty,
141 /// VPS successfully added to the registry.
142 VpsAdded {
143 /// Name of the added VPS.
144 name: String,
145 },
146 /// VPS successfully removed from the registry.
147 VpsRemoved {
148 /// Name of the removed VPS.
149 name: String,
150 },
151 /// Attempt to add a VPS that already exists.
152 VpsDuplicate {
153 /// Name of the duplicate VPS.
154 name: String,
155 },
156 /// Requested VPS was not found in the registry.
157 VpsNotFound {
158 /// Name of the missing VPS.
159 name: String,
160 },
161 /// Active VPS selected for subsequent operations.
162 VpsActiveSelected {
163 /// Name of the selected VPS.
164 name: String,
165 },
166 // Errors (B2).
167 //
168 // These are reached exclusively from [`localized_error_text`], which is
169 // called on the *human* branch of the top-level error emitter. The `--json`
170 // envelope keeps `SshCliError`'s English `Display`, because agents branch on
171 // the stable `error_code` discriminator and must never parse localized
172 // prose. Every variant carries the upstream detail verbatim so no diagnostic
173 // information is lost in translation.
174 /// Configuration could not be read or written.
175 ErrorConfig {
176 /// Underlying failure detail (English, from the source error).
177 detail: String,
178 },
179 /// Error establishing an SSH connection to the remote server.
180 ErrorSshConnection {
181 /// Underlying failure detail.
182 detail: String,
183 },
184 /// SSH authentication was rejected by the server.
185 ErrorAuthentication {
186 /// Underlying failure detail.
187 detail: String,
188 },
189 /// Remote SSH command execution failed.
190 ErrorCommandFailed {
191 /// Underlying failure detail.
192 detail: String,
193 },
194 /// Remote host key no longer matches the pinned entry.
195 ErrorHostKeyChanged {
196 /// Underlying failure detail.
197 detail: String,
198 },
199 /// Operation exceeded its deadline.
200 ErrorTimeout {
201 /// Underlying failure detail.
202 detail: String,
203 },
204 /// Requested file was not found.
205 ErrorFileNotFound {
206 /// Path that was not found.
207 path: String,
208 },
209 /// A required external service is unavailable.
210 ErrorUnavailable {
211 /// Service name (for example `keyring`).
212 service: String,
213 },
214 /// The program itself failed in a way retrying cannot fix.
215 ErrorSoftware {
216 /// Failing operation name (for example `rng`).
217 op: String,
218 },
219 /// A multi-host fan-out succeeded only in part.
220 ErrorPartialFailure {
221 /// Underlying failure detail.
222 detail: String,
223 },
224 /// Invalid argument supplied to the operation.
225 ErrorInvalidArgument {
226 /// Detail of the invalid argument.
227 detail: String,
228 },
229 /// `--use-active` was requested but no active marker is set.
230 ///
231 /// Previously this error fell through to the untranslated branch and reached a
232 /// pt-BR operator in English. It is operator prose, not machine plumbing: the
233 /// remedy is a command the human has to type.
234 ErrorNoActiveVps,
235 /// A failure that carries no product error type.
236 ///
237 /// Reached from the last branch of `resolve_exit_code`, where an `anyhow`
238 /// chain held neither `SshCliError` nor `DomainError`. That branch printed
239 /// the raw English chain regardless of `--lang`, so the one error a user is
240 /// least equipped to interpret was also the only one never translated.
241 ErrorUnexpected {
242 /// Underlying failure detail (English, from the `anyhow` chain).
243 detail: String,
244 },
245 /// VPS record edited successfully.
246 VpsEdited {
247 /// VPS name.
248 name: String,
249 },
250 /// Export completed.
251 ExportCompleted {
252 /// Destination path.
253 path: String,
254 },
255 /// Import completed.
256 ImportCompleted,
257 /// Primary key ready.
258 PrimaryKeyReady {
259 /// Key source identifier.
260 source: String,
261 /// Key file path.
262 key_file: String,
263 },
264 /// Re-encrypt completed.
265 ReencryptCompleted {
266 /// Host count.
267 hosts: usize,
268 },
269 // Tunnel
270 /// Instruction to stop the tunnel via Ctrl+C.
271 TunnelPressCtrlC,
272 // Health Check
273 /// Successful VPS connectivity check.
274 HealthCheckOk {
275 /// Name of the checked VPS.
276 name: String,
277 },
278 // `OperationCancelled`, `ScpUploadFileOnly` and `ScpDownloadLocalNotDirectory`
279 // used to live here. Every call site fed them into a failure that reaches a JSON
280 // envelope — either as the body of `SshCliError::InvalidArgument` or as the
281 // `error` field of a batch entry on stdout. Under `--lang pt-BR` that put
282 // Portuguese inside the one channel `docs/schemas/error-envelope.schema.json`
283 // pins to English, so the translation was not a courtesy but a contract break.
284 //
285 // Their English text now lives in `crate::constants` as a plain `&str`. Nothing
286 // was lost for the human reader: the *label* around the body is still translated
287 // by `localized_error_text`, which is the split this table was always meant to
288 // express. A `Message` variant is a promise that the string is safe to localize,
289 // and for these three it never was.
290 // SCP (GAP-SSH-SCP-020)
291 /// SCP upload completed.
292 ScpUploadCompleted {
293 /// Bytes transferred.
294 bytes: u64,
295 /// Duration in milliseconds.
296 ms: u64,
297 },
298 /// SCP download completed.
299 ScpDownloadCompleted {
300 /// Bytes transferred.
301 bytes: u64,
302 /// Duration in milliseconds.
303 ms: u64,
304 },
305 /// SFTP upload completed (G-SFTP).
306 SftpUploadCompleted {
307 /// Bytes transferred.
308 bytes: u64,
309 /// Duration in milliseconds.
310 ms: u64,
311 },
312 /// SFTP download completed (G-SFTP).
313 SftpDownloadCompleted {
314 /// Bytes transferred.
315 bytes: u64,
316 /// Duration in milliseconds.
317 ms: u64,
318 },
319 /// SFTP filesystem operation completed on a single path (A4).
320 ///
321 /// A4: `mkdir`, `rmdir`, `rm` and `stat` built their human line with an inline
322 /// English `format!`, so `--lang pt-BR` silently produced English for exactly the
323 /// commands an operator reads most often. This is the same defect already recorded
324 /// for the tunnel banner: a translation that existed but no call site could reach.
325 SftpFsOpDone {
326 /// Operation name (`mkdir`, `rmdir`, `rm`, `stat`).
327 op: String,
328 /// Remote path acted upon.
329 path: String,
330 /// Duration in milliseconds.
331 ms: u64,
332 },
333 /// SFTP filesystem operation completed with a destination path (`rename`).
334 SftpFsOpDoneTo {
335 /// Operation name (`rename`).
336 op: String,
337 /// Source path.
338 path: String,
339 /// Destination path.
340 to: String,
341 /// Duration in milliseconds.
342 ms: u64,
343 },
344 // Locale diagnostics / preference
345 /// Locale preference saved.
346 LocalePreferenceSaved {
347 /// BCP47 tag written.
348 lang: String,
349 /// Path of the preference file.
350 path: String,
351 },
352 /// Locale preference cleared.
353 LocalePreferenceCleared,
354 /// Header for `locale` show output.
355 LocaleStatusTitle,
356 // Tunnel banners (human TTY only; agents read the JSON events instead)
357 /// Local forward is listening.
358 TunnelLocalListening {
359 /// Effective local bind address.
360 bind: String,
361 /// Effective local port (OS-assigned when 0 was requested).
362 port: u16,
363 /// Remote destination host.
364 remote_host: String,
365 /// Remote destination port.
366 remote_port: u16,
367 /// Registry name of the host.
368 vps: String,
369 /// Deadline in milliseconds.
370 timeout_ms: u64,
371 },
372 /// SOCKS5 proxy is listening.
373 TunnelSocks5Listening {
374 /// Effective local bind address.
375 bind: String,
376 /// Effective local port.
377 port: u16,
378 /// Registry name of the host.
379 vps: String,
380 /// Deadline in milliseconds.
381 timeout_ms: u64,
382 },
383 /// Forward to a remote Unix socket is listening.
384 TunnelStreamLocalListening {
385 /// Effective local bind address.
386 bind: String,
387 /// Effective local port.
388 port: u16,
389 /// Remote Unix socket path.
390 socket_path: String,
391 /// Registry name of the host.
392 vps: String,
393 /// Deadline in milliseconds.
394 timeout_ms: u64,
395 },
396 /// Reverse forward established on the server.
397 TunnelReverseListening {
398 /// Address the server bound.
399 remote_bind: String,
400 /// Port the server allocated.
401 remote_port: u16,
402 /// Local delivery host.
403 local_host: String,
404 /// Local delivery port.
405 local_port: u16,
406 /// Registry name of the host.
407 vps: String,
408 /// Deadline in milliseconds.
409 timeout_ms: u64,
410 },
411}
412
413impl Message {
414 /// Returns the message string in the specified language.
415 ///
416 /// Deterministic method for tests — does not depend on global state.
417 pub fn text(&self, language: Language) -> String {
418 match language {
419 Language::English => en::en(self),
420 Language::Portuguese => pt::pt(self),
421 }
422 }
423}
424
425/// Initializes i18n by resolving locale (5-layer precedence) and publishing
426/// once to the global [`crate::locale`] `OnceLock`.
427///
428/// `force_lang` is the CLI `--lang` value (already clap-validated when present).
429/// `config_dir_override` is `--config-dir` for persisted preference lookup.
430pub fn initialize_language(
431 force_lang: Option<&str>,
432 config_dir_override: Option<&std::path::Path>,
433) -> Result<()> {
434 let resolution = crate::locale::resolve_language_detailed(force_lang, config_dir_override);
435 tracing::debug!(
436 target: "ssh_cli::i18n",
437 language = resolution.language.bcp47(),
438 source = resolution.source.as_str(),
439 "locale resolved"
440 );
441 crate::locale::set_language(resolution.language);
442 Ok(())
443}
444
445/// Returns the currently configured language.
446#[must_use]
447pub fn current_language() -> Language {
448 crate::locale::current_language()
449}
450
451/// Returns the message string in the current global language.
452///
453/// Usa o estado global inicializado por `initialize_language`.
454/// In tests, prefer `Message::text(language)` for determinism.
455///
456/// # Examples
457///
458/// ```
459/// use ssh_cli::i18n::{t, initialize_language, Message};
460///
461/// initialize_language(Some("en"), None).unwrap();
462/// let text = t(Message::VpsRegistryEmpty);
463/// assert!(!text.is_empty());
464/// ```
465/// Takes [`Message`] by value: call sites construct ephemeral messages with
466/// owned payloads; consuming them is intentional (not a needless copy).
467#[must_use]
468#[allow(clippy::needless_pass_by_value)]
469pub fn t(msg: Message) -> String {
470 msg.text(current_language())
471}
472
473/// Localizes the human line for a failure that carries no product error type.
474///
475/// # Why this exists (C2)
476///
477/// [`localized_error_text`] only accepts an [`SshCliError`]. The last branch of
478/// `resolve_exit_code` handles an `anyhow` chain that downcast to neither
479/// [`SshCliError`] nor `DomainError`, and it printed the raw chain regardless of
480/// `--lang`. B2 localized every *typed* error and left that one untranslated, so
481/// the failure a user is least equipped to interpret stayed English-only.
482///
483/// Unlike the typed path there is nothing to fail open to — the caller has no
484/// alternative rendering — so this returns [`String`], never [`Option`]. The
485/// `detail` is the upstream chain verbatim and stays English; only the label
486/// that classifies it is translated.
487///
488/// The `--json` envelope is untouched and keeps `error_code` `"unexpected"`.
489#[must_use]
490pub fn localized_unexpected_text(detail: &str) -> String {
491 t(Message::ErrorUnexpected {
492 detail: detail.to_string(),
493 })
494}
495
496/// Renders a domain error in the operator's language, for **human output only**.
497///
498/// # Why this exists (B2)
499///
500/// Six `Error*` variants shipped with full English and Brazilian Portuguese
501/// translations and never had a single call site: every failure reached the user
502/// through `thiserror`'s `#[error("…")]` attribute literal instead, so
503/// `--lang pt-BR` produced byte-identical English output. This is the seam that
504/// makes the translations reachable.
505///
506/// # Contract boundary
507///
508/// This is **not** used for the `--json` envelope. There, `message` stays the
509/// English [`std::fmt::Display`] of [`SshCliError`] by contract: agents branch on
510/// the stable `error_code` discriminator, and a locale-dependent `message` would
511/// silently change the payload an agent parses when the host locale changes.
512///
513/// Returns [`None`] for any error code without a translation, so the caller falls
514/// back to the English `Display`. That fail-open shape means adding a new
515/// [`SshCliError`] variant can never blank out the human error line.
516#[must_use]
517pub fn localized_error_text(err: &SshCliError) -> Option<String> {
518 use std::fmt::Write as _;
519
520 // Matched on the variant, not on `error_code()`. Every `#[error("…")]`
521 // attribute already carries an English label ("vps '{0}' not found in
522 // registry"), so feeding `to_string()` into a template that adds its own
523 // label produces "VPS 'vps 'x' not found in registry' not found." Only the
524 // inner payload may cross into the translated sentence.
525 let msg = match err {
526 SshCliError::Config(detail) => Message::ErrorConfig {
527 detail: detail.clone(),
528 },
529 SshCliError::SshConnection(detail) | SshCliError::ConnectionFailed(detail) => {
530 Message::ErrorSshConnection {
531 detail: detail.clone(),
532 }
533 }
534 SshCliError::SshAuthentication(detail) => Message::ErrorAuthentication {
535 detail: detail.clone(),
536 },
537 SshCliError::AuthenticationFailed => Message::ErrorAuthentication {
538 // Unit variant: the remedy hint is the only payload worth carrying.
539 detail: "try --password-stdin, --key PATH, --key-passphrase-stdin, \
540 or verify the user"
541 .to_string(),
542 },
543 SshCliError::CommandFailed { exit_code, stderr } => {
544 let mut detail = format!("exit {exit_code}");
545 if !stderr.is_empty() {
546 let _ = write!(detail, ": {stderr}");
547 }
548 Message::ErrorCommandFailed { detail }
549 }
550 SshCliError::HostKeyChanged {
551 host,
552 port,
553 expected,
554 obtained,
555 } => Message::ErrorHostKeyChanged {
556 detail: format!(
557 "{host}:{port} expected {expected}, got {obtained} \
558 (use --replace-host-key if legitimate)"
559 ),
560 },
561 SshCliError::SshTimeout(ms) | SshCliError::Timeout(ms) => Message::ErrorTimeout {
562 detail: format!("{ms}ms"),
563 },
564 SshCliError::FileNotFound(path) => Message::ErrorFileNotFound { path: path.clone() },
565 SshCliError::Unavailable { service } => Message::ErrorUnavailable {
566 service: (*service).to_string(),
567 },
568 SshCliError::Software { op } => Message::ErrorSoftware {
569 op: (*op).to_string(),
570 },
571 SshCliError::PartialFailure { failed, total, op } => Message::ErrorPartialFailure {
572 detail: format!("{failed}/{total} ({op})"),
573 },
574 SshCliError::InvalidArgument(detail) => Message::ErrorInvalidArgument {
575 detail: detail.clone(),
576 },
577 SshCliError::NoActiveVps => Message::ErrorNoActiveVps,
578 SshCliError::VpsNotFound(name) => Message::VpsNotFound { name: name.clone() },
579 SshCliError::VpsDuplicate(name) => Message::VpsDuplicate { name: name.clone() },
580 // Untranslated variants (io, json, toml_*, broken_pipe, crypto, tls, …)
581 // keep the English Display: they are machine-facing plumbing, not
582 // operator prose.
583 _ => return None,
584 };
585 Some(t(msg))
586}
587
588#[cfg(test)]
589#[path = "i18n_tests.rs"]
590mod tests;