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 =
324        crate::locale::resolve_language_detailed(force_lang, config_dir_override);
325    tracing::debug!(
326        target: "ssh_cli::i18n",
327        language = resolution.language.bcp47(),
328        source = resolution.source.as_str(),
329        "locale resolved"
330    );
331    crate::locale::set_language(resolution.language);
332    Ok(())
333}
334
335/// Returns the currently configured language.
336#[must_use]
337pub fn current_language() -> Language {
338    crate::locale::current_language()
339}
340
341/// Returns the message string in the current global language.
342///
343/// Usa o estado global inicializado por `initialize_language`.
344/// In tests, prefer `Message::text(language)` for determinism.
345///
346/// # Examples
347///
348/// ```
349/// use ssh_cli::i18n::{t, initialize_language, Message};
350///
351/// initialize_language(Some("en"), None).unwrap();
352/// let text = t(Message::VpsRegistryEmpty);
353/// assert!(!text.is_empty());
354/// ```
355/// Takes [`Message`] by value: call sites construct ephemeral messages with
356/// owned payloads; consuming them is intentional (not a needless copy).
357#[must_use]
358#[allow(clippy::needless_pass_by_value)]
359pub fn t(msg: Message) -> String {
360    msg.text(current_language())
361}
362
363/// American English translations.
364fn en(msg: &Message) -> String {
365    match msg {
366        Message::VpsRegistryEmpty => "No VPS registered.".to_string(),
367        Message::VpsListTitle => "Registered VPS:".to_string(),
368        Message::VpsAdded { name } => format!("VPS '{name}' added successfully."),
369        Message::VpsRemoved { name } => format!("VPS '{name}' removed successfully."),
370        Message::VpsDuplicate { name } => format!("VPS '{name}' is already registered."),
371        Message::VpsNotFound { name } => format!("VPS '{name}' not found."),
372        Message::VpsActiveSelected { name } => format!("Active VPS: '{name}'."),
373        Message::ConfigPathLabel => "Configuration file:".to_string(),
374        Message::ConfigPath { path } => path.clone(),
375        Message::ConfigNoKeys => "No API keys configured.".to_string(),
376        Message::ErrorLoadConfig => "Failed to load configuration.".to_string(),
377        Message::ErrorSaveConfig => "Failed to save configuration.".to_string(),
378        Message::ErrorSshConnection => "SSH connection error.".to_string(),
379        Message::ErrorCommandFailed => "Command execution failed.".to_string(),
380        Message::ErrorInvalidArgument { detail } => format!("Invalid argument: {detail}"),
381        Message::ErrorGeneric { detail } => detail.clone(),
382        Message::VpsEdited { name } => format!("VPS '{name}' edited."),
383        Message::ExportCompleted { path } => format!("exported to {path}"),
384        Message::ImportCompleted => "import completed".to_string(),
385        Message::PrimaryKeyReady { source, key_file } => {
386            format!("primary-key ready (source={source}; key_file={key_file})")
387        }
388        Message::ReencryptCompleted { hosts } => {
389            format!("re-encrypt completed for {hosts} host(s)")
390        }
391        Message::Success { detail } => detail.clone(),
392        Message::TunnelActive {
393            local_port,
394            remote_host,
395            remote_port,
396            vps_name,
397        } => format!(
398            "SSH tunnel active: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
399            crate::constants::DEFAULT_TUNNEL_BIND_ADDR
400        ),
401        Message::TunnelPressCtrlC => "Press Ctrl+C to terminate.".to_string(),
402        Message::HealthCheckOk { name } => format!("Health check passed for '{name}'."),
403        Message::HealthCheckNoVps => {
404            "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
405        }
406        Message::HealthCheckFailed { name, detail } => {
407            format!("Health check FAILED for '{name}': {detail}")
408        }
409        Message::HealthCheckLatency { name, latency_ms } => {
410            format!("Health check OK for '{name}' ({latency_ms}ms)")
411        }
412        Message::OperationCancelled => "Operation cancelled by user.".to_string(),
413        Message::ScpUploadCompleted { bytes, ms } => {
414            format!("Upload completed: {bytes} bytes in {ms}ms")
415        }
416        Message::ScpDownloadCompleted { bytes, ms } => {
417            format!("Download completed: {bytes} bytes in {ms}ms")
418        }
419        Message::ScpUploadFileOnly => {
420            "upload only supports regular files (no directories / no -r)".to_string()
421        }
422        Message::ScpDownloadLocalNotDirectory => {
423            "download local path must be a file path, not an existing directory".to_string()
424        }
425        Message::SftpUploadCompleted { bytes, ms } => {
426            format!("SFTP upload completed: {bytes} bytes in {ms}ms")
427        }
428        Message::SftpDownloadCompleted { bytes, ms } => {
429            format!("SFTP download completed: {bytes} bytes in {ms}ms")
430        }
431        Message::LocalePreferenceSaved { lang, path } => {
432            format!("language preference saved: {lang} ({path})")
433        }
434        Message::LocalePreferenceCleared => "language preference cleared.".to_string(),
435        Message::LocaleStatusTitle => "Locale status:".to_string(),
436    }
437}
438
439/// Brazilian Portuguese translations.
440fn pt(msg: &Message) -> String {
441    match msg {
442        Message::VpsRegistryEmpty => "Nenhum VPS cadastrado.".to_string(),
443        Message::VpsListTitle => "VPS cadastrados:".to_string(),
444        Message::VpsAdded { name } => format!("VPS '{name}' adicionada com sucesso."),
445        Message::VpsRemoved { name } => format!("VPS '{name}' removida com sucesso."),
446        Message::VpsDuplicate { name } => format!("VPS '{name}' já está cadastrada."),
447        Message::VpsNotFound { name } => format!("VPS '{name}' não encontrada."),
448        Message::VpsActiveSelected { name } => format!("VPS ativa: '{name}'."),
449        Message::ConfigPathLabel => "Arquivo de configuração:".to_string(),
450        Message::ConfigPath { path } => path.clone(),
451        Message::ConfigNoKeys => "Nenhuma chave de API configurada.".to_string(),
452        Message::ErrorLoadConfig => "Falha ao carregar configuração.".to_string(),
453        Message::ErrorSaveConfig => "Falha ao salvar configuração.".to_string(),
454        Message::ErrorSshConnection => "Erro de conexão SSH.".to_string(),
455        Message::ErrorCommandFailed => "Falha na execução do comando.".to_string(),
456        Message::ErrorInvalidArgument { detail } => format!("Argumento inválido: {detail}"),
457        Message::ErrorGeneric { detail } => detail.clone(),
458        Message::VpsEdited { name } => format!("VPS '{name}' editada."),
459        Message::ExportCompleted { path } => format!("exportado para {path}"),
460        Message::ImportCompleted => "importação concluída".to_string(),
461        Message::PrimaryKeyReady { source, key_file } => {
462            format!("primary-key pronta (source={source}; key_file={key_file})")
463        }
464        Message::ReencryptCompleted { hosts } => {
465            format!("re-cifragem concluída para {hosts} host(s)")
466        }
467        Message::Success { detail } => detail.clone(),
468        Message::TunnelActive {
469            local_port,
470            remote_host,
471            remote_port,
472            vps_name,
473        } => format!(
474            "Tunnel SSH: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
475            crate::constants::DEFAULT_TUNNEL_BIND_ADDR
476        ),
477        Message::TunnelPressCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
478        Message::HealthCheckOk { name } => format!("Health check bem-sucedido para '{name}'."),
479        Message::HealthCheckNoVps => {
480            "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
481        }
482        Message::HealthCheckFailed { name, detail } => {
483            format!("Health check FALHOU para '{name}': {detail}")
484        }
485        Message::HealthCheckLatency { name, latency_ms } => {
486            format!("Health check OK para '{name}' ({latency_ms}ms)")
487        }
488        Message::OperationCancelled => "Operação cancelada pelo usuário.".to_string(),
489        Message::ScpUploadCompleted { bytes, ms } => {
490            format!("Upload concluído: {bytes} bytes em {ms}ms")
491        }
492        Message::ScpDownloadCompleted { bytes, ms } => {
493            format!("Download concluído: {bytes} bytes em {ms}ms")
494        }
495        Message::ScpUploadFileOnly => {
496            "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
497        }
498        Message::ScpDownloadLocalNotDirectory => {
499            "caminho local de download deve ser arquivo, não diretório existente".to_string()
500        }
501        Message::SftpUploadCompleted { bytes, ms } => {
502            format!("Upload SFTP concluído: {bytes} bytes em {ms}ms")
503        }
504        Message::SftpDownloadCompleted { bytes, ms } => {
505            format!("Download SFTP concluído: {bytes} bytes em {ms}ms")
506        }
507        Message::LocalePreferenceSaved { lang, path } => {
508            format!("preferência de idioma salva: {lang} ({path})")
509        }
510        Message::LocalePreferenceCleared => "preferência de idioma removida.".to_string(),
511        Message::LocaleStatusTitle => "Status do locale:".to_string(),
512    }
513}
514
515
516#[cfg(test)]
517#[path = "i18n_tests.rs"]
518mod tests;