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    /// A failure that carries no product error type.
230    ///
231    /// Reached from the last branch of `resolve_exit_code`, where an `anyhow`
232    /// chain held neither `SshCliError` nor `DomainError`. That branch printed
233    /// the raw English chain regardless of `--lang`, so the one error a user is
234    /// least equipped to interpret was also the only one never translated.
235    ErrorUnexpected {
236        /// Underlying failure detail (English, from the `anyhow` chain).
237        detail: String,
238    },
239    /// VPS record edited successfully.
240    VpsEdited {
241        /// VPS name.
242        name: String,
243    },
244    /// Export completed.
245    ExportCompleted {
246        /// Destination path.
247        path: String,
248    },
249    /// Import completed.
250    ImportCompleted,
251    /// Primary key ready.
252    PrimaryKeyReady {
253        /// Key source identifier.
254        source: String,
255        /// Key file path.
256        key_file: String,
257    },
258    /// Re-encrypt completed.
259    ReencryptCompleted {
260        /// Host count.
261        hosts: usize,
262    },
263    // Tunnel
264    /// Instruction to stop the tunnel via Ctrl+C.
265    TunnelPressCtrlC,
266    // Health Check
267    /// Successful VPS connectivity check.
268    HealthCheckOk {
269        /// Name of the checked VPS.
270        name: String,
271    },
272    /// Operation cancelled by user signal (Ctrl+C or SIGTERM).
273    OperationCancelled,
274    // SCP (GAP-SSH-SCP-020)
275    /// SCP upload completed.
276    ScpUploadCompleted {
277        /// Bytes transferred.
278        bytes: u64,
279        /// Duration in milliseconds.
280        ms: u64,
281    },
282    /// SCP download completed.
283    ScpDownloadCompleted {
284        /// Bytes transferred.
285        bytes: u64,
286        /// Duration in milliseconds.
287        ms: u64,
288    },
289    /// Upload refused: local path is a directory (file-only, no -r).
290    ScpUploadFileOnly,
291    /// Download refused: local path is already a directory.
292    ScpDownloadLocalNotDirectory,
293    /// SFTP upload completed (G-SFTP).
294    SftpUploadCompleted {
295        /// Bytes transferred.
296        bytes: u64,
297        /// Duration in milliseconds.
298        ms: u64,
299    },
300    /// SFTP download completed (G-SFTP).
301    SftpDownloadCompleted {
302        /// Bytes transferred.
303        bytes: u64,
304        /// Duration in milliseconds.
305        ms: u64,
306    },
307    /// SFTP filesystem operation completed on a single path (A4).
308    ///
309    /// A4: `mkdir`, `rmdir`, `rm` and `stat` built their human line with an inline
310    /// English `format!`, so `--lang pt-BR` silently produced English for exactly the
311    /// commands an operator reads most often. This is the same defect already recorded
312    /// for the tunnel banner: a translation that existed but no call site could reach.
313    SftpFsOpDone {
314        /// Operation name (`mkdir`, `rmdir`, `rm`, `stat`).
315        op: String,
316        /// Remote path acted upon.
317        path: String,
318        /// Duration in milliseconds.
319        ms: u64,
320    },
321    /// SFTP filesystem operation completed with a destination path (`rename`).
322    SftpFsOpDoneTo {
323        /// Operation name (`rename`).
324        op: String,
325        /// Source path.
326        path: String,
327        /// Destination path.
328        to: String,
329        /// Duration in milliseconds.
330        ms: u64,
331    },
332    // Locale diagnostics / preference
333    /// Locale preference saved.
334    LocalePreferenceSaved {
335        /// BCP47 tag written.
336        lang: String,
337        /// Path of the preference file.
338        path: String,
339    },
340    /// Locale preference cleared.
341    LocalePreferenceCleared,
342    /// Header for `locale` show output.
343    LocaleStatusTitle,
344    // Tunnel banners (human TTY only; agents read the JSON events instead)
345    /// Local forward is listening.
346    TunnelLocalListening {
347        /// Effective local bind address.
348        bind: String,
349        /// Effective local port (OS-assigned when 0 was requested).
350        port: u16,
351        /// Remote destination host.
352        remote_host: String,
353        /// Remote destination port.
354        remote_port: u16,
355        /// Registry name of the host.
356        vps: String,
357        /// Deadline in milliseconds.
358        timeout_ms: u64,
359    },
360    /// SOCKS5 proxy is listening.
361    TunnelSocks5Listening {
362        /// Effective local bind address.
363        bind: String,
364        /// Effective local port.
365        port: u16,
366        /// Registry name of the host.
367        vps: String,
368        /// Deadline in milliseconds.
369        timeout_ms: u64,
370    },
371    /// Forward to a remote Unix socket is listening.
372    TunnelStreamLocalListening {
373        /// Effective local bind address.
374        bind: String,
375        /// Effective local port.
376        port: u16,
377        /// Remote Unix socket path.
378        socket_path: String,
379        /// Registry name of the host.
380        vps: String,
381        /// Deadline in milliseconds.
382        timeout_ms: u64,
383    },
384    /// Reverse forward established on the server.
385    TunnelReverseListening {
386        /// Address the server bound.
387        remote_bind: String,
388        /// Port the server allocated.
389        remote_port: u16,
390        /// Local delivery host.
391        local_host: String,
392        /// Local delivery port.
393        local_port: u16,
394        /// Registry name of the host.
395        vps: String,
396        /// Deadline in milliseconds.
397        timeout_ms: u64,
398    },
399}
400
401impl Message {
402    /// Returns the message string in the specified language.
403    ///
404    /// Deterministic method for tests — does not depend on global state.
405    pub fn text(&self, language: Language) -> String {
406        match language {
407            Language::English => en::en(self),
408            Language::Portuguese => pt::pt(self),
409        }
410    }
411}
412
413/// Initializes i18n by resolving locale (5-layer precedence) and publishing
414/// once to the global [`crate::locale`] `OnceLock`.
415///
416/// `force_lang` is the CLI `--lang` value (already clap-validated when present).
417/// `config_dir_override` is `--config-dir` for persisted preference lookup.
418pub fn initialize_language(
419    force_lang: Option<&str>,
420    config_dir_override: Option<&std::path::Path>,
421) -> Result<()> {
422    let resolution = crate::locale::resolve_language_detailed(force_lang, config_dir_override);
423    tracing::debug!(
424        target: "ssh_cli::i18n",
425        language = resolution.language.bcp47(),
426        source = resolution.source.as_str(),
427        "locale resolved"
428    );
429    crate::locale::set_language(resolution.language);
430    Ok(())
431}
432
433/// Returns the currently configured language.
434#[must_use]
435pub fn current_language() -> Language {
436    crate::locale::current_language()
437}
438
439/// Returns the message string in the current global language.
440///
441/// Usa o estado global inicializado por `initialize_language`.
442/// In tests, prefer `Message::text(language)` for determinism.
443///
444/// # Examples
445///
446/// ```
447/// use ssh_cli::i18n::{t, initialize_language, Message};
448///
449/// initialize_language(Some("en"), None).unwrap();
450/// let text = t(Message::VpsRegistryEmpty);
451/// assert!(!text.is_empty());
452/// ```
453/// Takes [`Message`] by value: call sites construct ephemeral messages with
454/// owned payloads; consuming them is intentional (not a needless copy).
455#[must_use]
456#[allow(clippy::needless_pass_by_value)]
457pub fn t(msg: Message) -> String {
458    msg.text(current_language())
459}
460
461/// Localizes the human line for a failure that carries no product error type.
462///
463/// # Why this exists (C2)
464///
465/// [`localized_error_text`] only accepts an [`SshCliError`]. The last branch of
466/// `resolve_exit_code` handles an `anyhow` chain that downcast to neither
467/// [`SshCliError`] nor `DomainError`, and it printed the raw chain regardless of
468/// `--lang`. B2 localized every *typed* error and left that one untranslated, so
469/// the failure a user is least equipped to interpret stayed English-only.
470///
471/// Unlike the typed path there is nothing to fail open to — the caller has no
472/// alternative rendering — so this returns [`String`], never [`Option`]. The
473/// `detail` is the upstream chain verbatim and stays English; only the label
474/// that classifies it is translated.
475///
476/// The `--json` envelope is untouched and keeps `error_code` `"unexpected"`.
477#[must_use]
478pub fn localized_unexpected_text(detail: &str) -> String {
479    t(Message::ErrorUnexpected {
480        detail: detail.to_string(),
481    })
482}
483
484/// Renders a domain error in the operator's language, for **human output only**.
485///
486/// # Why this exists (B2)
487///
488/// Six `Error*` variants shipped with full English and Brazilian Portuguese
489/// translations and never had a single call site: every failure reached the user
490/// through `thiserror`'s `#[error("…")]` attribute literal instead, so
491/// `--lang pt-BR` produced byte-identical English output. This is the seam that
492/// makes the translations reachable.
493///
494/// # Contract boundary
495///
496/// This is **not** used for the `--json` envelope. There, `message` stays the
497/// English [`std::fmt::Display`] of [`SshCliError`] by contract: agents branch on
498/// the stable `error_code` discriminator, and a locale-dependent `message` would
499/// silently change the payload an agent parses when the host locale changes.
500///
501/// Returns [`None`] for any error code without a translation, so the caller falls
502/// back to the English `Display`. That fail-open shape means adding a new
503/// [`SshCliError`] variant can never blank out the human error line.
504#[must_use]
505pub fn localized_error_text(err: &SshCliError) -> Option<String> {
506    use std::fmt::Write as _;
507
508    // Matched on the variant, not on `error_code()`. Every `#[error("…")]`
509    // attribute already carries an English label ("vps '{0}' not found in
510    // registry"), so feeding `to_string()` into a template that adds its own
511    // label produces "VPS 'vps 'x' not found in registry' not found." Only the
512    // inner payload may cross into the translated sentence.
513    let msg = match err {
514        SshCliError::Config(detail) => Message::ErrorConfig {
515            detail: detail.clone(),
516        },
517        SshCliError::SshConnection(detail) | SshCliError::ConnectionFailed(detail) => {
518            Message::ErrorSshConnection {
519                detail: detail.clone(),
520            }
521        }
522        SshCliError::SshAuthentication(detail) => Message::ErrorAuthentication {
523            detail: detail.clone(),
524        },
525        SshCliError::AuthenticationFailed => Message::ErrorAuthentication {
526            // Unit variant: the remedy hint is the only payload worth carrying.
527            detail: "try --password-stdin, --key PATH, --key-passphrase-stdin, \
528                     or verify the user"
529                .to_string(),
530        },
531        SshCliError::CommandFailed { exit_code, stderr } => {
532            let mut detail = format!("exit {exit_code}");
533            if !stderr.is_empty() {
534                let _ = write!(detail, ": {stderr}");
535            }
536            Message::ErrorCommandFailed { detail }
537        }
538        SshCliError::HostKeyChanged {
539            host,
540            port,
541            expected,
542            obtained,
543        } => Message::ErrorHostKeyChanged {
544            detail: format!(
545                "{host}:{port} expected {expected}, got {obtained} \
546                 (use --replace-host-key if legitimate)"
547            ),
548        },
549        SshCliError::SshTimeout(ms) | SshCliError::Timeout(ms) => Message::ErrorTimeout {
550            detail: format!("{ms}ms"),
551        },
552        SshCliError::FileNotFound(path) => Message::ErrorFileNotFound { path: path.clone() },
553        SshCliError::Unavailable { service } => Message::ErrorUnavailable {
554            service: (*service).to_string(),
555        },
556        SshCliError::Software { op } => Message::ErrorSoftware {
557            op: (*op).to_string(),
558        },
559        SshCliError::PartialFailure { failed, total, op } => Message::ErrorPartialFailure {
560            detail: format!("{failed}/{total} ({op})"),
561        },
562        SshCliError::InvalidArgument(detail) => Message::ErrorInvalidArgument {
563            detail: detail.clone(),
564        },
565        SshCliError::VpsNotFound(name) => Message::VpsNotFound { name: name.clone() },
566        SshCliError::VpsDuplicate(name) => Message::VpsDuplicate { name: name.clone() },
567        // Untranslated variants (io, json, toml_*, broken_pipe, crypto, tls, …)
568        // keep the English Display: they are machine-facing plumbing, not
569        // operator prose.
570        _ => return None,
571    };
572    Some(t(msg))
573}
574
575#[cfg(test)]
576#[path = "i18n_tests.rs"]
577mod tests;