Skip to main content

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;