Skip to main content

ssh_cli/
i18n.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! ssh-cli internationalization system.
3//!
4//! Provides bilingual `Language` with `Message` as the single source of
5//! UI strings. Locale detection is delegated to the `locale` module.
6//!
7//! Language selection precedence:
8//! 1. CLI `--lang` flag
9//! 2. `SSH_CLI_LANG` environment variable
10//! 3. System locale via `sys_locale::get_locale()`
11//! 4. Fallback: `Language::English`
12
13use anyhow::Result;
14
15/// Languages supported by the internationalization system.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Language {
18    /// American English (en-US) — default language.
19    English,
20    /// Brazilian Portuguese (pt-BR).
21    Portuguese,
22}
23
24/// All system UI messages.
25///
26/// SINGLE source of user-visible strings. Each variant has an exhaustive
27/// translation in `en()` and `pt()`. FORBIDDEN to use UI literals outside this enum.
28///
29/// Variants with dynamic fields (e.g. `{ name: String }`) allow including
30/// contextual data in the message. Message is not `Copy` because
31/// `String` fields are not `Copy`.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Message {
34    // VPS
35    /// No VPS registered in the configuration file.
36    VpsRegistryEmpty,
37    /// Header for the registered VPS listing.
38    VpsListTitle,
39    /// VPS successfully added to the registry.
40    VpsAdded {
41        /// Name of the added VPS.
42        name: String,
43    },
44    /// VPS successfully removed from the registry.
45    VpsRemoved {
46        /// Name of the removed VPS.
47        name: String,
48    },
49    /// Attempt to add a VPS that already exists.
50    VpsDuplicate {
51        /// Name of the duplicate VPS.
52        name: String,
53    },
54    /// Requested VPS was not found in the registry.
55    VpsNotFound {
56        /// Name of the missing VPS.
57        name: String,
58    },
59    /// Active VPS selected for subsequent operations.
60    VpsActiveSelected {
61        /// Name of the selected VPS.
62        name: String,
63    },
64    // Config
65    /// Label for the configuration file path.
66    ConfigPathLabel,
67    /// Current configuration file path.
68    ConfigPath {
69        /// Absolute configuration file path.
70        path: String,
71    },
72    /// No API keys configured in the system.
73    ConfigNoKeys,
74    // Erros
75    /// Failed to load the configuration file.
76    ErrorLoadConfig,
77    /// Failed to save the configuration file.
78    ErrorSaveConfig,
79    /// Error establishing SSH connection to the remote server.
80    ErrorSshConnection,
81    /// Remote SSH command execution failed.
82    ErrorCommandFailed,
83    /// Invalid argument supplied to the operation.
84    ErrorInvalidArgument {
85        /// Detail of the invalid argument.
86        detail: String,
87    },
88    /// Generic error with a textual description.
89    ErrorGeneric {
90        /// Error description.
91        detail: String,
92    },
93    /// VPS record edited successfully.
94    VpsEdited {
95        /// VPS name.
96        name: String,
97    },
98    /// Export completed.
99    ExportCompleted {
100        /// Destination path.
101        path: String,
102    },
103    /// Import completed.
104    ImportCompleted,
105    /// Primary key ready.
106    PrimaryKeyReady {
107        /// Key source identifier.
108        source: String,
109        /// Key file path.
110        key_file: String,
111    },
112    /// Re-encrypt completed.
113    ReencryptCompleted {
114        /// Host count.
115        hosts: usize,
116    },
117    /// Generic human success line (already localized payload).
118    Success {
119        /// Success text.
120        detail: String,
121    },
122    // Tunnel
123    /// Active SSH tunnel with port and host information.
124    TunnelActive {
125        /// Local tunnel port.
126        local_port: u16,
127        /// Remote destination host.
128        remote_host: String,
129        /// Remote destination port.
130        remote_port: u16,
131        /// Name of the VPS used as relay.
132        vps_name: String,
133    },
134    /// Instruction to stop the tunnel via Ctrl+C.
135    TunnelPressCtrlC,
136    // Health Check
137    /// Successful VPS connectivity check.
138    HealthCheckOk {
139        /// Name of the checked VPS.
140        name: String,
141    },
142    /// No active VPS selected for health check.
143    HealthCheckNoVps,
144    /// VPS connectivity check failed.
145    HealthCheckFailed {
146        /// Name of the checked VPS.
147        name: String,
148        /// Error detail.
149        detail: String,
150    },
151    /// Health-check result with latency.
152    HealthCheckLatency {
153        /// Name of the checked VPS.
154        name: String,
155        /// Latency in milliseconds.
156        latency_ms: u64,
157    },
158    /// Operation cancelled by user signal (Ctrl+C or SIGTERM).
159    OperationCancelled,
160    // SCP (GAP-SSH-SCP-020)
161    /// SCP upload completed.
162    ScpUploadCompleted {
163        /// Bytes transferred.
164        bytes: u64,
165        /// Duration in milliseconds.
166        ms: u64,
167    },
168    /// SCP download completed.
169    ScpDownloadCompleted {
170        /// Bytes transferred.
171        bytes: u64,
172        /// Duration in milliseconds.
173        ms: u64,
174    },
175    /// Upload refused: local path is a directory (file-only, no -r).
176    ScpUploadFileOnly,
177    /// Download refused: local path is already a directory.
178    ScpDownloadLocalNotDirectory,
179}
180
181impl Message {
182    /// Returns the message string in the specified language.
183    ///
184    /// Deterministic method for tests — does not depend on global state.
185    pub fn text(&self, language: Language) -> String {
186        match language {
187            Language::English => en(self),
188            Language::Portuguese => pt(self),
189        }
190    }
191}
192
193/// Initializes i18n by detecting the OS locale.
194///
195/// If `force_lang` is `Some(...)`, it overrides automatic detection.
196pub fn initialize_language(force_lang: Option<&str>) -> Result<()> {
197    let language = crate::locale::resolve_language(force_lang);
198    crate::locale::set_language(language);
199    Ok(())
200}
201
202/// Returns the currently configured language.
203#[must_use]
204pub fn current_language() -> Language {
205    crate::locale::current_language()
206}
207
208/// Returns the message string in the current global language.
209///
210/// Usa o estado global inicializado por `initialize_language`.
211/// In tests, prefer `Message::text(language)` for determinism.
212///
213/// # Examples
214///
215/// ```
216/// use ssh_cli::i18n::{t, initialize_language, Message};
217///
218/// initialize_language(Some("en-US")).unwrap();
219/// let text = t(Message::VpsRegistryEmpty);
220/// assert!(!text.is_empty());
221/// ```
222#[must_use]
223pub fn t(msg: Message) -> String {
224    msg.text(current_language())
225}
226
227/// American English translations.
228fn en(msg: &Message) -> String {
229    match msg {
230        Message::VpsRegistryEmpty => "No VPS registered.".to_string(),
231        Message::VpsListTitle => "Registered VPS:".to_string(),
232        Message::VpsAdded { name } => format!("VPS '{name}' added successfully."),
233        Message::VpsRemoved { name } => format!("VPS '{name}' removed successfully."),
234        Message::VpsDuplicate { name } => format!("VPS '{name}' is already registered."),
235        Message::VpsNotFound { name } => format!("VPS '{name}' not found."),
236        Message::VpsActiveSelected { name } => format!("Active VPS: '{name}'."),
237        Message::ConfigPathLabel => "Configuration file:".to_string(),
238        Message::ConfigPath { path } => path.clone(),
239        Message::ConfigNoKeys => "No API keys configured.".to_string(),
240        Message::ErrorLoadConfig => "Failed to load configuration.".to_string(),
241        Message::ErrorSaveConfig => "Failed to save configuration.".to_string(),
242        Message::ErrorSshConnection => "SSH connection error.".to_string(),
243        Message::ErrorCommandFailed => "Command execution failed.".to_string(),
244        Message::ErrorInvalidArgument { detail } => format!("Invalid argument: {detail}"),
245        Message::ErrorGeneric { detail } => detail.clone(),
246        Message::VpsEdited { name } => format!("VPS '{name}' edited."),
247        Message::ExportCompleted { path } => format!("exported to {path}"),
248        Message::ImportCompleted => "import completed".to_string(),
249        Message::PrimaryKeyReady { source, key_file } => {
250            format!("primary-key ready (source={source}; key_file={key_file})")
251        }
252        Message::ReencryptCompleted { hosts } => {
253            format!("re-encrypt completed for {hosts} host(s)")
254        }
255        Message::Success { detail } => detail.clone(),
256        Message::TunnelActive {
257            local_port,
258            remote_host,
259            remote_port,
260            vps_name,
261        } => format!(
262            "SSH tunnel active: localhost:{local_port} -> {remote_host}:{remote_port} via {vps_name}"
263        ),
264        Message::TunnelPressCtrlC => "Press Ctrl+C to terminate.".to_string(),
265        Message::HealthCheckOk { name } => format!("Health check passed for '{name}'."),
266        Message::HealthCheckNoVps => {
267            "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
268        }
269        Message::HealthCheckFailed { name, detail } => {
270            format!("Health check FAILED for '{name}': {detail}")
271        }
272        Message::HealthCheckLatency { name, latency_ms } => {
273            format!("Health check OK for '{name}' ({latency_ms}ms)")
274        }
275        Message::OperationCancelled => "Operation cancelled by user.".to_string(),
276        Message::ScpUploadCompleted { bytes, ms } => {
277            format!("Upload completed: {bytes} bytes in {ms}ms")
278        }
279        Message::ScpDownloadCompleted { bytes, ms } => {
280            format!("Download completed: {bytes} bytes in {ms}ms")
281        }
282        Message::ScpUploadFileOnly => {
283            "upload only supports regular files (no directories / no -r)".to_string()
284        }
285        Message::ScpDownloadLocalNotDirectory => {
286            "download local path must be a file path, not an existing directory".to_string()
287        }
288    }
289}
290
291/// Brazilian Portuguese translations.
292fn pt(msg: &Message) -> String {
293    match msg {
294        Message::VpsRegistryEmpty => "Nenhum VPS cadastrado.".to_string(),
295        Message::VpsListTitle => "VPS cadastrados:".to_string(),
296        Message::VpsAdded { name } => format!("VPS '{name}' adicionada com sucesso."),
297        Message::VpsRemoved { name } => format!("VPS '{name}' removida com sucesso."),
298        Message::VpsDuplicate { name } => format!("VPS '{name}' já está cadastrada."),
299        Message::VpsNotFound { name } => format!("VPS '{name}' não encontrada."),
300        Message::VpsActiveSelected { name } => format!("VPS ativa: '{name}'."),
301        Message::ConfigPathLabel => "Arquivo de configuração:".to_string(),
302        Message::ConfigPath { path } => path.clone(),
303        Message::ConfigNoKeys => "Nenhuma chave de API configurada.".to_string(),
304        Message::ErrorLoadConfig => "Falha ao carregar configuração.".to_string(),
305        Message::ErrorSaveConfig => "Falha ao salvar configuração.".to_string(),
306        Message::ErrorSshConnection => "Erro de conexão SSH.".to_string(),
307        Message::ErrorCommandFailed => "Falha na execução do comando.".to_string(),
308        Message::ErrorInvalidArgument { detail } => format!("Argumento inválido: {detail}"),
309        Message::ErrorGeneric { detail } => detail.clone(),
310        Message::VpsEdited { name } => format!("VPS '{name}' editada."),
311        Message::ExportCompleted { path } => format!("exportado para {path}"),
312        Message::ImportCompleted => "importação concluída".to_string(),
313        Message::PrimaryKeyReady { source, key_file } => {
314            format!("primary-key pronta (source={source}; key_file={key_file})")
315        }
316        Message::ReencryptCompleted { hosts } => {
317            format!("re-cifragem concluída para {hosts} host(s)")
318        }
319        Message::Success { detail } => detail.clone(),
320        Message::TunnelActive {
321            local_port,
322            remote_host,
323            remote_port,
324            vps_name,
325        } => format!(
326            "Tunnel SSH: localhost:{local_port} -> {remote_host}:{remote_port} via {vps_name}"
327        ),
328        Message::TunnelPressCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
329        Message::HealthCheckOk { name } => format!("Health check bem-sucedido para '{name}'."),
330        Message::HealthCheckNoVps => {
331            "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
332        }
333        Message::HealthCheckFailed { name, detail } => {
334            format!("Health check FALHOU para '{name}': {detail}")
335        }
336        Message::HealthCheckLatency { name, latency_ms } => {
337            format!("Health check OK para '{name}' ({latency_ms}ms)")
338        }
339        Message::OperationCancelled => "Operação cancelada pelo usuário.".to_string(),
340        Message::ScpUploadCompleted { bytes, ms } => {
341            format!("Upload concluído: {bytes} bytes em {ms}ms")
342        }
343        Message::ScpDownloadCompleted { bytes, ms } => {
344            format!("Download concluído: {bytes} bytes em {ms}ms")
345        }
346        Message::ScpUploadFileOnly => {
347            "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
348        }
349        Message::ScpDownloadLocalNotDirectory => {
350            "caminho local de download deve ser arquivo, não diretório existente".to_string()
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn language_enum_is_copy() {
361        let a = Language::English;
362        let b = a;
363        assert_eq!(a, b);
364    }
365
366    #[test]
367    fn message_is_not_copy_but_is_clone() {
368        let m = Message::VpsAdded {
369            name: "vps-01".to_string(),
370        };
371        let m2 = m.clone();
372        assert_eq!(m, m2);
373    }
374
375    #[test]
376    fn vps_registry_empty_en() {
377        assert_eq!(
378            Message::VpsRegistryEmpty.text(Language::English),
379            "No VPS registered."
380        );
381    }
382
383    #[test]
384    fn vps_registry_empty_pt() {
385        assert_eq!(
386            Message::VpsRegistryEmpty.text(Language::Portuguese),
387            "Nenhum VPS cadastrado."
388        );
389    }
390
391    #[test]
392    fn vps_added_includes_name_en() {
393        let msg = Message::VpsAdded {
394            name: "prod-01".to_string(),
395        };
396        assert_eq!(
397            msg.text(Language::English),
398            "VPS 'prod-01' added successfully."
399        );
400    }
401
402    #[test]
403    fn vps_added_includes_name_pt() {
404        let msg = Message::VpsAdded {
405            name: "prod-01".to_string(),
406        };
407        assert_eq!(
408            msg.text(Language::Portuguese),
409            "VPS 'prod-01' adicionada com sucesso."
410        );
411    }
412
413    #[test]
414    fn vps_removed_includes_name() {
415        let msg = Message::VpsRemoved {
416            name: "dev-01".to_string(),
417        };
418        assert!(msg.text(Language::English).contains("dev-01"));
419        assert!(msg.text(Language::Portuguese).contains("dev-01"));
420    }
421
422    #[test]
423    fn vps_duplicate_includes_name() {
424        let msg = Message::VpsDuplicate {
425            name: "staging".to_string(),
426        };
427        assert!(msg.text(Language::English).contains("staging"));
428        assert!(msg.text(Language::Portuguese).contains("staging"));
429    }
430
431    #[test]
432    fn vps_not_found_includes_name() {
433        let msg = Message::VpsNotFound {
434            name: "inexistente".to_string(),
435        };
436        assert!(msg.text(Language::English).contains("inexistente"));
437        assert!(msg.text(Language::Portuguese).contains("inexistente"));
438    }
439
440    #[test]
441    fn tunnel_active_includes_all_fields() {
442        let msg = Message::TunnelActive {
443            local_port: 8080,
444            remote_host: "1.2.3.4".to_string(),
445            remote_port: 22,
446            vps_name: "meu-servidor".to_string(),
447        };
448        let en = msg.text(Language::English);
449        assert!(en.contains("8080"));
450        assert!(en.contains("1.2.3.4"));
451        assert!(en.contains("22"));
452        assert!(en.contains("meu-servidor"));
453    }
454
455    #[test]
456    fn error_invalid_argument_includes_detail() {
457        let msg = Message::ErrorInvalidArgument {
458            detail: "port out of range".to_string(),
459        };
460        assert!(msg
461            .text(Language::English)
462            .contains("port out of range"));
463        assert!(msg
464            .text(Language::Portuguese)
465            .contains("port out of range"));
466    }
467
468    #[test]
469    fn health_check_ok_includes_name() {
470        let msg = Message::HealthCheckOk {
471            name: "prod-01".to_string(),
472        };
473        assert!(msg.text(Language::English).contains("prod-01"));
474        assert!(msg.text(Language::Portuguese).contains("prod-01"));
475    }
476
477    #[test]
478    fn all_unit_variants_en_nonempty() {
479        let unit_variants = [
480            Message::VpsRegistryEmpty,
481            Message::VpsListTitle,
482            Message::ConfigPathLabel,
483            Message::ConfigNoKeys,
484            Message::ErrorLoadConfig,
485            Message::ErrorSaveConfig,
486            Message::ErrorSshConnection,
487            Message::ErrorCommandFailed,
488            Message::TunnelPressCtrlC,
489            Message::HealthCheckNoVps,
490            Message::OperationCancelled,
491        ];
492        for v in &unit_variants {
493            let text = v.text(Language::English);
494            assert!(!text.is_empty(), "empty EN for {:?}", v);
495        }
496    }
497
498    #[test]
499    fn all_unit_variants_pt_nonempty() {
500        let unit_variants = [
501            Message::VpsRegistryEmpty,
502            Message::VpsListTitle,
503            Message::ConfigPathLabel,
504            Message::ConfigNoKeys,
505            Message::ErrorLoadConfig,
506            Message::ErrorSaveConfig,
507            Message::ErrorSshConnection,
508            Message::ErrorCommandFailed,
509            Message::TunnelPressCtrlC,
510            Message::HealthCheckNoVps,
511            Message::OperationCancelled,
512        ];
513        for v in &unit_variants {
514            let text = v.text(Language::Portuguese);
515            assert!(!text.is_empty(), "empty PT for {:?}", v);
516        }
517    }
518
519    #[test]
520    fn pt_translations_differ_from_en_for_units() {
521        let pairs = [
522            (Message::VpsRegistryEmpty, Message::VpsRegistryEmpty),
523            (Message::ErrorSshConnection, Message::ErrorSshConnection),
524            (Message::HealthCheckNoVps, Message::HealthCheckNoVps),
525            (Message::OperationCancelled, Message::OperationCancelled),
526        ];
527        for (a, b) in &pairs {
528            let en = a.text(Language::English);
529            let pt = b.text(Language::Portuguese);
530            assert_ne!(en, pt, "EN == PT for {:?}", a);
531        }
532    }
533
534    #[test]
535    fn health_check_failed_includes_name_and_detail() {
536        let msg = Message::HealthCheckFailed {
537            name: "prod-01".to_string(),
538            detail: "timeout".to_string(),
539        };
540        assert!(msg.text(Language::English).contains("prod-01"));
541        assert!(msg.text(Language::English).contains("timeout"));
542        assert!(msg.text(Language::Portuguese).contains("prod-01"));
543        assert!(msg.text(Language::Portuguese).contains("timeout"));
544    }
545
546    #[test]
547    fn health_check_latency_includes_name_and_ms() {
548        let msg = Message::HealthCheckLatency {
549            name: "relay-01".to_string(),
550            latency_ms: 42,
551        };
552        assert!(msg.text(Language::English).contains("relay-01"));
553        assert!(msg.text(Language::English).contains("42"));
554        assert!(msg.text(Language::Portuguese).contains("relay-01"));
555        assert!(msg.text(Language::Portuguese).contains("42"));
556    }
557
558    #[test]
559    fn initialize_language_without_force_no_panic() {
560        let result = initialize_language(None);
561        assert!(result.is_ok());
562    }
563
564    #[test]
565    fn initialize_language_with_pt_br_works() {
566        let result = initialize_language(Some("pt-BR"));
567        assert!(result.is_ok());
568    }
569
570    #[test]
571    fn current_language_returns_valid_value() {
572        let language = current_language();
573        assert!(language == Language::English || language == Language::Portuguese);
574    }
575}