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
29/// Text direction for terminal rendering (LTR MVP; RTL reserved for `i18n-rtl`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum TextDirection {
33    /// Left-to-right (Latin, CJK horizontal, etc.).
34    Ltr,
35    /// Right-to-left (Arabic, Hebrew) — not active in default build.
36    Rtl,
37}
38
39/// Languages supported by the internationalization system.
40///
41/// Single source of truth for product locales in this binary. Do **not** use
42/// `bool` / raw `String` / integers for language in APIs.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44#[non_exhaustive]
45pub enum Language {
46    /// Neutral English (`en`) — default / agent-stable technical baseline.
47    English,
48    /// Brazilian Portuguese (`pt-BR`) — mandatory MVP pair with `en`.
49    Portuguese,
50}
51
52impl Language {
53    /// Locales compiled into the default binary (MVP: `en`, `pt-BR` only).
54    pub const AVAILABLE: &'static [Language] = &[Language::English, Language::Portuguese];
55
56    /// Canonical BCP47 tag for this product locale.
57    ///
58    /// English is neutral `en` (not `en-US` alone). Portuguese is always `pt-BR`.
59    #[must_use]
60    pub const fn bcp47(self) -> &'static str {
61        match self {
62            Self::English => "en",
63            Self::Portuguese => "pt-BR",
64        }
65    }
66
67    /// Structured BCP47 identifier (`unic-langid`).
68    ///
69    /// Built-in tags are compile-time constants (`en`, `pt-BR`). On parse
70    /// failure (should never happen), falls back to the default undetermined
71    /// identifier — **no panic** on product paths (G-SEC-07).
72    #[must_use]
73    pub fn language_identifier(self) -> LanguageIdentifier {
74        self.bcp47()
75            .parse()
76            .unwrap_or_else(|_| LanguageIdentifier::default())
77    }
78
79    /// Base fallback language for regionals (MVP: English).
80    #[must_use]
81    pub const fn fallback(self) -> Language {
82        match self {
83            Self::English => Self::English,
84            Self::Portuguese => Self::English,
85        }
86    }
87
88    /// Writing direction for this locale.
89    #[must_use]
90    pub const fn direction(self) -> TextDirection {
91        match self {
92            Self::English | Self::Portuguese => TextDirection::Ltr,
93        }
94    }
95
96    /// ISO 15924 script subtag (MVP Latin only).
97    #[must_use]
98    pub const fn script(self) -> &'static str {
99        match self {
100            Self::English | Self::Portuguese => "Latn",
101        }
102    }
103
104    /// Maps a negotiated [`LanguageIdentifier`] to a product [`Language`].
105    ///
106    /// Matches primary language subtag: `en*` → English, `pt*` → Portuguese.
107    /// Region-specific product choice for Portuguese is always `pt-BR` in MVP
108    /// (no `pt-PT` variant compiled without a feature).
109    #[must_use]
110    pub fn from_langid(id: &LanguageIdentifier) -> Option<Language> {
111        match id.language.as_str() {
112            "en" => Some(Self::English),
113            "pt" => Some(Self::Portuguese),
114            _ => None,
115        }
116    }
117}
118
119/// All system UI messages.
120///
121/// SINGLE source of user-visible strings. Each variant has an exhaustive
122/// translation in `en()` and `pt()`. FORBIDDEN to use UI literals outside this enum.
123///
124/// Variants with dynamic fields (e.g. `{ name: String }`) allow including
125/// contextual data in the message. Message is not `Copy` because
126/// `String` fields are not `Copy`.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Message {
129    // VPS
130    /// No VPS registered in the configuration file.
131    VpsRegistryEmpty,
132    /// Header for the registered VPS listing.
133    VpsListTitle,
134    /// VPS successfully added to the registry.
135    VpsAdded {
136        /// Name of the added VPS.
137        name: String,
138    },
139    /// VPS successfully removed from the registry.
140    VpsRemoved {
141        /// Name of the removed VPS.
142        name: String,
143    },
144    /// Attempt to add a VPS that already exists.
145    VpsDuplicate {
146        /// Name of the duplicate VPS.
147        name: String,
148    },
149    /// Requested VPS was not found in the registry.
150    VpsNotFound {
151        /// Name of the missing VPS.
152        name: String,
153    },
154    /// Active VPS selected for subsequent operations.
155    VpsActiveSelected {
156        /// Name of the selected VPS.
157        name: String,
158    },
159    // Config
160    /// Label for the configuration file path.
161    ConfigPathLabel,
162    /// Current configuration file path.
163    ConfigPath {
164        /// Absolute configuration file path.
165        path: String,
166    },
167    /// No API keys configured in the system.
168    ConfigNoKeys,
169    // Erros
170    /// Failed to load the configuration file.
171    ErrorLoadConfig,
172    /// Failed to save the configuration file.
173    ErrorSaveConfig,
174    /// Error establishing SSH connection to the remote server.
175    ErrorSshConnection,
176    /// Remote SSH command execution failed.
177    ErrorCommandFailed,
178    /// Invalid argument supplied to the operation.
179    ErrorInvalidArgument {
180        /// Detail of the invalid argument.
181        detail: String,
182    },
183    /// Generic error with a textual description.
184    ErrorGeneric {
185        /// Error description.
186        detail: String,
187    },
188    /// VPS record edited successfully.
189    VpsEdited {
190        /// VPS name.
191        name: String,
192    },
193    /// Export completed.
194    ExportCompleted {
195        /// Destination path.
196        path: String,
197    },
198    /// Import completed.
199    ImportCompleted,
200    /// Primary key ready.
201    PrimaryKeyReady {
202        /// Key source identifier.
203        source: String,
204        /// Key file path.
205        key_file: String,
206    },
207    /// Re-encrypt completed.
208    ReencryptCompleted {
209        /// Host count.
210        hosts: usize,
211    },
212    /// Generic human success line (already localized payload).
213    Success {
214        /// Success text.
215        detail: String,
216    },
217    // Tunnel
218    /// Active SSH tunnel with port and host information.
219    TunnelActive {
220        /// Local tunnel port.
221        local_port: u16,
222        /// Remote destination host.
223        remote_host: String,
224        /// Remote destination port.
225        remote_port: u16,
226        /// Name of the VPS used as relay.
227        vps_name: String,
228    },
229    /// Instruction to stop the tunnel via Ctrl+C.
230    TunnelPressCtrlC,
231    // Health Check
232    /// Successful VPS connectivity check.
233    HealthCheckOk {
234        /// Name of the checked VPS.
235        name: String,
236    },
237    /// No active VPS selected for health check.
238    HealthCheckNoVps,
239    /// VPS connectivity check failed.
240    HealthCheckFailed {
241        /// Name of the checked VPS.
242        name: String,
243        /// Error detail.
244        detail: String,
245    },
246    /// Health-check result with latency.
247    HealthCheckLatency {
248        /// Name of the checked VPS.
249        name: String,
250        /// Latency in milliseconds.
251        latency_ms: u64,
252    },
253    /// Operation cancelled by user signal (Ctrl+C or SIGTERM).
254    OperationCancelled,
255    // SCP (GAP-SSH-SCP-020)
256    /// SCP upload completed.
257    ScpUploadCompleted {
258        /// Bytes transferred.
259        bytes: u64,
260        /// Duration in milliseconds.
261        ms: u64,
262    },
263    /// SCP download completed.
264    ScpDownloadCompleted {
265        /// Bytes transferred.
266        bytes: u64,
267        /// Duration in milliseconds.
268        ms: u64,
269    },
270    /// Upload refused: local path is a directory (file-only, no -r).
271    ScpUploadFileOnly,
272    /// Download refused: local path is already a directory.
273    ScpDownloadLocalNotDirectory,
274    /// SFTP upload completed (G-SFTP).
275    SftpUploadCompleted {
276        /// Bytes transferred.
277        bytes: u64,
278        /// Duration in milliseconds.
279        ms: u64,
280    },
281    /// SFTP download completed (G-SFTP).
282    SftpDownloadCompleted {
283        /// Bytes transferred.
284        bytes: u64,
285        /// Duration in milliseconds.
286        ms: u64,
287    },
288    // Locale diagnostics / preference
289    /// Locale preference saved.
290    LocalePreferenceSaved {
291        /// BCP47 tag written.
292        lang: String,
293        /// Path of the preference file.
294        path: String,
295    },
296    /// Locale preference cleared.
297    LocalePreferenceCleared,
298    /// Header for `locale` show output.
299    LocaleStatusTitle,
300}
301
302impl Message {
303    /// Returns the message string in the specified language.
304    ///
305    /// Deterministic method for tests — does not depend on global state.
306    pub fn text(&self, language: Language) -> String {
307        match language {
308            Language::English => en(self),
309            Language::Portuguese => pt(self),
310        }
311    }
312}
313
314/// Initializes i18n by resolving locale (5-layer precedence) and publishing
315/// once to the global [`crate::locale`] `OnceLock`.
316///
317/// `force_lang` is the CLI `--lang` value (already clap-validated when present).
318/// `config_dir_override` is `--config-dir` for persisted preference lookup.
319pub fn initialize_language(
320    force_lang: Option<&str>,
321    config_dir_override: Option<&std::path::Path>,
322) -> Result<()> {
323    let resolution = crate::locale::resolve_language_detailed(force_lang, config_dir_override);
324    tracing::debug!(
325        target: "ssh_cli::i18n",
326        language = resolution.language.bcp47(),
327        source = resolution.source.as_str(),
328        "locale resolved"
329    );
330    crate::locale::set_language(resolution.language);
331    Ok(())
332}
333
334/// Returns the currently configured language.
335#[must_use]
336pub fn current_language() -> Language {
337    crate::locale::current_language()
338}
339
340/// Returns the message string in the current global language.
341///
342/// Usa o estado global inicializado por `initialize_language`.
343/// In tests, prefer `Message::text(language)` for determinism.
344///
345/// # Examples
346///
347/// ```
348/// use ssh_cli::i18n::{t, initialize_language, Message};
349///
350/// initialize_language(Some("en"), None).unwrap();
351/// let text = t(Message::VpsRegistryEmpty);
352/// assert!(!text.is_empty());
353/// ```
354/// Takes [`Message`] by value: call sites construct ephemeral messages with
355/// owned payloads; consuming them is intentional (not a needless copy).
356#[must_use]
357#[allow(clippy::needless_pass_by_value)]
358pub fn t(msg: Message) -> String {
359    msg.text(current_language())
360}
361
362/// American English translations.
363fn en(msg: &Message) -> String {
364    match msg {
365        Message::VpsRegistryEmpty => "No VPS registered.".to_string(),
366        Message::VpsListTitle => "Registered VPS:".to_string(),
367        Message::VpsAdded { name } => format!("VPS '{name}' added successfully."),
368        Message::VpsRemoved { name } => format!("VPS '{name}' removed successfully."),
369        Message::VpsDuplicate { name } => format!("VPS '{name}' is already registered."),
370        Message::VpsNotFound { name } => format!("VPS '{name}' not found."),
371        Message::VpsActiveSelected { name } => format!("Active VPS: '{name}'."),
372        Message::ConfigPathLabel => "Configuration file:".to_string(),
373        Message::ConfigPath { path } => path.clone(),
374        Message::ConfigNoKeys => "No API keys configured.".to_string(),
375        Message::ErrorLoadConfig => "Failed to load configuration.".to_string(),
376        Message::ErrorSaveConfig => "Failed to save configuration.".to_string(),
377        Message::ErrorSshConnection => "SSH connection error.".to_string(),
378        Message::ErrorCommandFailed => "Command execution failed.".to_string(),
379        Message::ErrorInvalidArgument { detail } => format!("Invalid argument: {detail}"),
380        Message::ErrorGeneric { detail } => detail.clone(),
381        Message::VpsEdited { name } => format!("VPS '{name}' edited."),
382        Message::ExportCompleted { path } => format!("exported to {path}"),
383        Message::ImportCompleted => "import completed".to_string(),
384        Message::PrimaryKeyReady { source, key_file } => {
385            format!("primary-key ready (source={source}; key_file={key_file})")
386        }
387        Message::ReencryptCompleted { hosts } => {
388            format!("re-encrypt completed for {hosts} host(s)")
389        }
390        Message::Success { detail } => detail.clone(),
391        Message::TunnelActive {
392            local_port,
393            remote_host,
394            remote_port,
395            vps_name,
396        } => format!(
397            "SSH tunnel active: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
398            crate::constants::DEFAULT_TUNNEL_BIND_ADDR
399        ),
400        Message::TunnelPressCtrlC => "Press Ctrl+C to terminate.".to_string(),
401        Message::HealthCheckOk { name } => format!("Health check passed for '{name}'."),
402        Message::HealthCheckNoVps => {
403            "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
404        }
405        Message::HealthCheckFailed { name, detail } => {
406            format!("Health check FAILED for '{name}': {detail}")
407        }
408        Message::HealthCheckLatency { name, latency_ms } => {
409            format!("Health check OK for '{name}' ({latency_ms}ms)")
410        }
411        Message::OperationCancelled => "Operation cancelled by user.".to_string(),
412        Message::ScpUploadCompleted { bytes, ms } => {
413            format!("Upload completed: {bytes} bytes in {ms}ms")
414        }
415        Message::ScpDownloadCompleted { bytes, ms } => {
416            format!("Download completed: {bytes} bytes in {ms}ms")
417        }
418        Message::ScpUploadFileOnly => {
419            "upload only supports regular files (no directories / no -r)".to_string()
420        }
421        Message::ScpDownloadLocalNotDirectory => {
422            "download local path must be a file path, not an existing directory".to_string()
423        }
424        Message::SftpUploadCompleted { bytes, ms } => {
425            format!("SFTP upload completed: {bytes} bytes in {ms}ms")
426        }
427        Message::SftpDownloadCompleted { bytes, ms } => {
428            format!("SFTP download completed: {bytes} bytes in {ms}ms")
429        }
430        Message::LocalePreferenceSaved { lang, path } => {
431            format!("language preference saved: {lang} ({path})")
432        }
433        Message::LocalePreferenceCleared => "language preference cleared.".to_string(),
434        Message::LocaleStatusTitle => "Locale status:".to_string(),
435    }
436}
437
438/// Brazilian Portuguese translations.
439fn pt(msg: &Message) -> String {
440    match msg {
441        Message::VpsRegistryEmpty => "Nenhum VPS cadastrado.".to_string(),
442        Message::VpsListTitle => "VPS cadastrados:".to_string(),
443        Message::VpsAdded { name } => format!("VPS '{name}' adicionada com sucesso."),
444        Message::VpsRemoved { name } => format!("VPS '{name}' removida com sucesso."),
445        Message::VpsDuplicate { name } => format!("VPS '{name}' já está cadastrada."),
446        Message::VpsNotFound { name } => format!("VPS '{name}' não encontrada."),
447        Message::VpsActiveSelected { name } => format!("VPS ativa: '{name}'."),
448        Message::ConfigPathLabel => "Arquivo de configuração:".to_string(),
449        Message::ConfigPath { path } => path.clone(),
450        Message::ConfigNoKeys => "Nenhuma chave de API configurada.".to_string(),
451        Message::ErrorLoadConfig => "Falha ao carregar configuração.".to_string(),
452        Message::ErrorSaveConfig => "Falha ao salvar configuração.".to_string(),
453        Message::ErrorSshConnection => "Erro de conexão SSH.".to_string(),
454        Message::ErrorCommandFailed => "Falha na execução do comando.".to_string(),
455        Message::ErrorInvalidArgument { detail } => format!("Argumento inválido: {detail}"),
456        Message::ErrorGeneric { detail } => detail.clone(),
457        Message::VpsEdited { name } => format!("VPS '{name}' editada."),
458        Message::ExportCompleted { path } => format!("exportado para {path}"),
459        Message::ImportCompleted => "importação concluída".to_string(),
460        Message::PrimaryKeyReady { source, key_file } => {
461            format!("primary-key pronta (source={source}; key_file={key_file})")
462        }
463        Message::ReencryptCompleted { hosts } => {
464            format!("re-cifragem concluída para {hosts} host(s)")
465        }
466        Message::Success { detail } => detail.clone(),
467        Message::TunnelActive {
468            local_port,
469            remote_host,
470            remote_port,
471            vps_name,
472        } => format!(
473            "Tunnel SSH: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
474            crate::constants::DEFAULT_TUNNEL_BIND_ADDR
475        ),
476        Message::TunnelPressCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
477        Message::HealthCheckOk { name } => format!("Health check bem-sucedido para '{name}'."),
478        Message::HealthCheckNoVps => {
479            "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
480        }
481        Message::HealthCheckFailed { name, detail } => {
482            format!("Health check FALHOU para '{name}': {detail}")
483        }
484        Message::HealthCheckLatency { name, latency_ms } => {
485            format!("Health check OK para '{name}' ({latency_ms}ms)")
486        }
487        Message::OperationCancelled => "Operação cancelada pelo usuário.".to_string(),
488        Message::ScpUploadCompleted { bytes, ms } => {
489            format!("Upload concluído: {bytes} bytes em {ms}ms")
490        }
491        Message::ScpDownloadCompleted { bytes, ms } => {
492            format!("Download concluído: {bytes} bytes em {ms}ms")
493        }
494        Message::ScpUploadFileOnly => {
495            "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
496        }
497        Message::ScpDownloadLocalNotDirectory => {
498            "caminho local de download deve ser arquivo, não diretório existente".to_string()
499        }
500        Message::SftpUploadCompleted { bytes, ms } => {
501            format!("Upload SFTP concluído: {bytes} bytes em {ms}ms")
502        }
503        Message::SftpDownloadCompleted { bytes, ms } => {
504            format!("Download SFTP concluído: {bytes} bytes em {ms}ms")
505        }
506        Message::LocalePreferenceSaved { lang, path } => {
507            format!("preferência de idioma salva: {lang} ({path})")
508        }
509        Message::LocalePreferenceCleared => "preferência de idioma removida.".to_string(),
510        Message::LocaleStatusTitle => "Status do locale:".to_string(),
511    }
512}
513
514#[cfg(test)]
515#[path = "i18n_tests.rs"]
516mod tests;