Skip to main content

ssh_cli/
i18n.rs

1//! Sistema de internacionalização do ssh-cli.
2//!
3//! Fornece o enum `Idioma` bilíngue com enum `Mensagem` como única fonte de
4//! strings de UI. A detecção de locale é delegada ao módulo `locale`.
5//!
6//! Precedência de seleção de idioma:
7//! 1. Flag `--lang` da CLI
8//! 2. Variável de ambiente `SSH_CLI_LANG`
9//! 3. Locale do sistema via `sys_locale::get_locale()`
10//! 4. Fallback: `Idioma::English`
11
12use anyhow::Result;
13
14/// Idioma suportado pelo sistema de internacionalização.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum Idioma {
17    /// Inglês americano (en-US) — idioma padrão.
18    English,
19    /// Português brasileiro (pt-BR).
20    Portugues,
21}
22
23/// Todas as mensagens de UI do sistema.
24///
25/// ÚNICA fonte de strings visíveis ao usuário. Cada variante possui tradução
26/// exaustiva em `en()` e `pt()`. PROIBIDO usar string literal de UI fora deste enum.
27///
28/// Variantes com campos dinâmicos (ex.: `{ nome: String }`) permitem incluir
29/// dados contextuais na mensagem. Mensagem não implementa `Copy` pois campos
30/// `String` não são `Copy`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Mensagem {
33    // VPS
34    /// Nenhuma VPS cadastrada no arquivo de configuração.
35    VpsRegistroVazio,
36    /// Cabeçalho da listagem de VPS registradas.
37    VpsListaTitulo,
38    /// VPS adicionada com sucesso ao registro.
39    VpsAdicionada {
40        /// Nome da VPS adicionada.
41        nome: String,
42    },
43    /// VPS removida com sucesso do registro.
44    VpsRemovida {
45        /// Nome da VPS removida.
46        nome: String,
47    },
48    /// Tentativa de adicionar VPS já existente no registro.
49    VpsDuplicada {
50        /// Nome da VPS duplicada.
51        nome: String,
52    },
53    /// VPS solicitada não foi encontrada no registro.
54    VpsNaoEncontrada {
55        /// Nome da VPS não encontrada.
56        nome: String,
57    },
58    /// VPS ativa selecionada para operações subsequentes.
59    VpsAtivaSelecionada {
60        /// Nome da VPS selecionada.
61        nome: String,
62    },
63    // Config
64    /// Rótulo do caminho do arquivo de configuração.
65    ConfigCaminhoLabel,
66    /// Caminho atual do arquivo de configuração.
67    ConfigCaminho {
68        /// Caminho absoluto do arquivo de configuração.
69        caminho: String,
70    },
71    /// Nenhuma chave de API configurada no sistema.
72    ConfigSemChaves,
73    // Erros
74    /// Falha ao carregar o arquivo de configuração.
75    ErroCarregarConfig,
76    /// Falha ao salvar o arquivo de configuração.
77    ErroSalvarConfig,
78    /// Erro ao estabelecer conexão SSH com o servidor remoto.
79    ErroConexaoSsh,
80    /// Falha na execução de comando remoto via SSH.
81    ErroComandoFalhou,
82    /// Argumento inválido fornecido à operação.
83    ErroArgumentoInvalido {
84        /// Detalhe do argumento inválido.
85        detalhe: String,
86    },
87    /// Erro genérico com descrição textual.
88    ErroGenerico {
89        /// Descrição do erro.
90        detalhe: String,
91    },
92    // Tunnel
93    /// Tunnel SSH ativo com informações de porta e host.
94    TunnelAtivo {
95        /// Porta local do tunnel.
96        porta_local: u16,
97        /// Host remoto destino.
98        host_remoto: String,
99        /// Porta remota destino.
100        porta_remota: u16,
101        /// Nome da VPS usada como relay.
102        vps_nome: String,
103    },
104    /// Instrução para encerrar o tunnel via Ctrl+C.
105    TunnelPressioneCtrlC,
106    // Health Check
107    /// Verificação de conectividade com VPS bem-sucedida.
108    HealthCheckOk {
109        /// Nome da VPS verificada.
110        nome: String,
111    },
112    /// Nenhuma VPS ativa selecionada para health check.
113    HealthCheckSemVps,
114    /// Falha na verificação de conectividade com VPS.
115    HealthCheckFalhou {
116        /// Nome da VPS verificada.
117        nome: String,
118        /// Detalhe do erro.
119        detalhe: String,
120    },
121    /// Resultado de health check com latência.
122    HealthCheckLatencia {
123        /// Nome da VPS verificada.
124        nome: String,
125        /// Latência em milissegundos.
126        latencia_ms: u64,
127    },
128    /// Operação cancelada por sinal do usuário (Ctrl+C ou SIGTERM).
129    OperacaoCancelada,
130    // SCP (GAP-SSH-SCP-020)
131    /// Upload SCP concluído.
132    ScpUploadConcluido {
133        /// Bytes transferidos.
134        bytes: u64,
135        /// Duração em milissegundos.
136        ms: u64,
137    },
138    /// Download SCP concluído.
139    ScpDownloadConcluido {
140        /// Bytes transferidos.
141        bytes: u64,
142        /// Duração em milissegundos.
143        ms: u64,
144    },
145    /// Upload recusado: path local é diretório (file-only, sem -r).
146    ScpUploadSomenteArquivo,
147    /// Download recusado: path local já é diretório.
148    ScpDownloadLocalNaoDiretorio,
149}
150
151impl Mensagem {
152    /// Retorna a string da mensagem no idioma especificado.
153    ///
154    /// Método determinístico para uso em testes — não depende de estado global.
155    pub fn texto(&self, idioma: Idioma) -> String {
156        match idioma {
157            Idioma::English => en(self),
158            Idioma::Portugues => pt(self),
159        }
160    }
161}
162
163/// Inicializa o sistema de i18n detectando o locale do SO.
164///
165/// Se `forcar` for `Some(...)`, esse idioma sobrescreve a detecção automática.
166pub fn inicializar_idioma(forcar: Option<&str>) -> Result<()> {
167    let idioma = crate::locale::resolver_idioma(forcar);
168    crate::locale::definir_idioma(idioma);
169    Ok(())
170}
171
172/// Retorna o idioma atualmente configurado.
173#[must_use]
174pub fn idioma_atual() -> Idioma {
175    crate::locale::idioma_atual()
176}
177
178/// Retorna a string da mensagem no idioma global atual.
179///
180/// Usa o estado global inicializado por `inicializar_idioma`.
181/// Em testes, prefira `Mensagem::texto(idioma)` para determinismo.
182///
183/// # Examples
184///
185/// ```
186/// use ssh_cli::i18n::{t, inicializar_idioma, Mensagem};
187///
188/// inicializar_idioma(Some("en-US")).unwrap();
189/// let texto = t(Mensagem::VpsRegistroVazio);
190/// assert!(!texto.is_empty());
191/// ```
192#[must_use]
193pub fn t(msg: Mensagem) -> String {
194    msg.texto(idioma_atual())
195}
196
197/// Traduções para inglês americano.
198fn en(msg: &Mensagem) -> String {
199    match msg {
200        Mensagem::VpsRegistroVazio => "No VPS registered.".to_string(),
201        Mensagem::VpsListaTitulo => "Registered VPS:".to_string(),
202        Mensagem::VpsAdicionada { nome } => format!("VPS '{nome}' added successfully."),
203        Mensagem::VpsRemovida { nome } => format!("VPS '{nome}' removed successfully."),
204        Mensagem::VpsDuplicada { nome } => format!("VPS '{nome}' is already registered."),
205        Mensagem::VpsNaoEncontrada { nome } => format!("VPS '{nome}' not found."),
206        Mensagem::VpsAtivaSelecionada { nome } => format!("Active VPS: '{nome}'."),
207        Mensagem::ConfigCaminhoLabel => "Configuration file:".to_string(),
208        Mensagem::ConfigCaminho { caminho } => caminho.clone(),
209        Mensagem::ConfigSemChaves => "No API keys configured.".to_string(),
210        Mensagem::ErroCarregarConfig => "Failed to load configuration.".to_string(),
211        Mensagem::ErroSalvarConfig => "Failed to save configuration.".to_string(),
212        Mensagem::ErroConexaoSsh => "SSH connection error.".to_string(),
213        Mensagem::ErroComandoFalhou => "Command execution failed.".to_string(),
214        Mensagem::ErroArgumentoInvalido { detalhe } => format!("Invalid argument: {detalhe}"),
215        Mensagem::ErroGenerico { detalhe } => detalhe.clone(),
216        Mensagem::TunnelAtivo {
217            porta_local,
218            host_remoto,
219            porta_remota,
220            vps_nome,
221        } => format!(
222            "SSH tunnel active: localhost:{porta_local} -> {host_remoto}:{porta_remota} via {vps_nome}"
223        ),
224        Mensagem::TunnelPressioneCtrlC => "Press Ctrl+C to terminate.".to_string(),
225        Mensagem::HealthCheckOk { nome } => format!("Health check passed for '{nome}'."),
226        Mensagem::HealthCheckSemVps => {
227            "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
228        }
229        Mensagem::HealthCheckFalhou { nome, detalhe } => {
230            format!("Health check FAILED for '{nome}': {detalhe}")
231        }
232        Mensagem::HealthCheckLatencia { nome, latencia_ms } => {
233            format!("Health check OK for '{nome}' ({latencia_ms}ms)")
234        }
235        Mensagem::OperacaoCancelada => "Operation cancelled by user.".to_string(),
236        Mensagem::ScpUploadConcluido { bytes, ms } => {
237            format!("Upload completed: {bytes} bytes in {ms}ms")
238        }
239        Mensagem::ScpDownloadConcluido { bytes, ms } => {
240            format!("Download completed: {bytes} bytes in {ms}ms")
241        }
242        Mensagem::ScpUploadSomenteArquivo => {
243            "upload only supports regular files (no directories / no -r)".to_string()
244        }
245        Mensagem::ScpDownloadLocalNaoDiretorio => {
246            "download local path must be a file path, not an existing directory".to_string()
247        }
248    }
249}
250
251/// Traduções para português brasileiro.
252fn pt(msg: &Mensagem) -> String {
253    match msg {
254        Mensagem::VpsRegistroVazio => "Nenhum VPS cadastrado.".to_string(),
255        Mensagem::VpsListaTitulo => "VPS cadastrados:".to_string(),
256        Mensagem::VpsAdicionada { nome } => format!("VPS '{nome}' adicionada com sucesso."),
257        Mensagem::VpsRemovida { nome } => format!("VPS '{nome}' removida com sucesso."),
258        Mensagem::VpsDuplicada { nome } => format!("VPS '{nome}' já está cadastrada."),
259        Mensagem::VpsNaoEncontrada { nome } => format!("VPS '{nome}' não encontrada."),
260        Mensagem::VpsAtivaSelecionada { nome } => format!("VPS ativa: '{nome}'."),
261        Mensagem::ConfigCaminhoLabel => "Arquivo de configuração:".to_string(),
262        Mensagem::ConfigCaminho { caminho } => caminho.clone(),
263        Mensagem::ConfigSemChaves => "Nenhuma chave de API configurada.".to_string(),
264        Mensagem::ErroCarregarConfig => "Falha ao carregar configuração.".to_string(),
265        Mensagem::ErroSalvarConfig => "Falha ao salvar configuração.".to_string(),
266        Mensagem::ErroConexaoSsh => "Erro de conexão SSH.".to_string(),
267        Mensagem::ErroComandoFalhou => "Falha na execução do comando.".to_string(),
268        Mensagem::ErroArgumentoInvalido { detalhe } => format!("Argumento inválido: {detalhe}"),
269        Mensagem::ErroGenerico { detalhe } => detalhe.clone(),
270        Mensagem::TunnelAtivo {
271            porta_local,
272            host_remoto,
273            porta_remota,
274            vps_nome,
275        } => format!(
276            "Tunnel SSH: localhost:{porta_local} -> {host_remoto}:{porta_remota} via {vps_nome}"
277        ),
278        Mensagem::TunnelPressioneCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
279        Mensagem::HealthCheckOk { nome } => format!("Health check bem-sucedido para '{nome}'."),
280        Mensagem::HealthCheckSemVps => {
281            "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
282        }
283        Mensagem::HealthCheckFalhou { nome, detalhe } => {
284            format!("Health check FALHOU para '{nome}': {detalhe}")
285        }
286        Mensagem::HealthCheckLatencia { nome, latencia_ms } => {
287            format!("Health check OK para '{nome}' ({latencia_ms}ms)")
288        }
289        Mensagem::OperacaoCancelada => "Operação cancelada pelo usuário.".to_string(),
290        Mensagem::ScpUploadConcluido { bytes, ms } => {
291            format!("Upload concluído: {bytes} bytes em {ms}ms")
292        }
293        Mensagem::ScpDownloadConcluido { bytes, ms } => {
294            format!("Download concluído: {bytes} bytes em {ms}ms")
295        }
296        Mensagem::ScpUploadSomenteArquivo => {
297            "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
298        }
299        Mensagem::ScpDownloadLocalNaoDiretorio => {
300            "caminho local de download deve ser arquivo, não diretório existente".to_string()
301        }
302    }
303}
304
305#[cfg(test)]
306mod testes {
307    use super::*;
308
309    #[test]
310    fn idioma_enum_e_copy() {
311        let a = Idioma::English;
312        let b = a;
313        assert_eq!(a, b);
314    }
315
316    #[test]
317    fn mensagem_nao_e_copy_mas_e_clone() {
318        let m = Mensagem::VpsAdicionada {
319            nome: "vps-01".to_string(),
320        };
321        let m2 = m.clone();
322        assert_eq!(m, m2);
323    }
324
325    #[test]
326    fn vps_registro_vazio_en() {
327        assert_eq!(
328            Mensagem::VpsRegistroVazio.texto(Idioma::English),
329            "No VPS registered."
330        );
331    }
332
333    #[test]
334    fn vps_registro_vazio_pt() {
335        assert_eq!(
336            Mensagem::VpsRegistroVazio.texto(Idioma::Portugues),
337            "Nenhum VPS cadastrado."
338        );
339    }
340
341    #[test]
342    fn vps_adicionada_inclui_nome_en() {
343        let msg = Mensagem::VpsAdicionada {
344            nome: "prod-01".to_string(),
345        };
346        assert_eq!(
347            msg.texto(Idioma::English),
348            "VPS 'prod-01' added successfully."
349        );
350    }
351
352    #[test]
353    fn vps_adicionada_inclui_nome_pt() {
354        let msg = Mensagem::VpsAdicionada {
355            nome: "prod-01".to_string(),
356        };
357        assert_eq!(
358            msg.texto(Idioma::Portugues),
359            "VPS 'prod-01' adicionada com sucesso."
360        );
361    }
362
363    #[test]
364    fn vps_removida_inclui_nome() {
365        let msg = Mensagem::VpsRemovida {
366            nome: "dev-01".to_string(),
367        };
368        assert!(msg.texto(Idioma::English).contains("dev-01"));
369        assert!(msg.texto(Idioma::Portugues).contains("dev-01"));
370    }
371
372    #[test]
373    fn vps_duplicada_inclui_nome() {
374        let msg = Mensagem::VpsDuplicada {
375            nome: "staging".to_string(),
376        };
377        assert!(msg.texto(Idioma::English).contains("staging"));
378        assert!(msg.texto(Idioma::Portugues).contains("staging"));
379    }
380
381    #[test]
382    fn vps_nao_encontrada_inclui_nome() {
383        let msg = Mensagem::VpsNaoEncontrada {
384            nome: "inexistente".to_string(),
385        };
386        assert!(msg.texto(Idioma::English).contains("inexistente"));
387        assert!(msg.texto(Idioma::Portugues).contains("inexistente"));
388    }
389
390    #[test]
391    fn tunnel_ativo_inclui_todos_os_campos() {
392        let msg = Mensagem::TunnelAtivo {
393            porta_local: 8080,
394            host_remoto: "1.2.3.4".to_string(),
395            porta_remota: 22,
396            vps_nome: "meu-servidor".to_string(),
397        };
398        let en = msg.texto(Idioma::English);
399        assert!(en.contains("8080"));
400        assert!(en.contains("1.2.3.4"));
401        assert!(en.contains("22"));
402        assert!(en.contains("meu-servidor"));
403    }
404
405    #[test]
406    fn erro_argumento_invalido_inclui_detalhe() {
407        let msg = Mensagem::ErroArgumentoInvalido {
408            detalhe: "porta fora do intervalo".to_string(),
409        };
410        assert!(msg
411            .texto(Idioma::English)
412            .contains("porta fora do intervalo"));
413        assert!(msg
414            .texto(Idioma::Portugues)
415            .contains("porta fora do intervalo"));
416    }
417
418    #[test]
419    fn health_check_ok_inclui_nome() {
420        let msg = Mensagem::HealthCheckOk {
421            nome: "prod-01".to_string(),
422        };
423        assert!(msg.texto(Idioma::English).contains("prod-01"));
424        assert!(msg.texto(Idioma::Portugues).contains("prod-01"));
425    }
426
427    #[test]
428    fn todas_variantes_unitarias_en_nao_vazias() {
429        let unitarias = [
430            Mensagem::VpsRegistroVazio,
431            Mensagem::VpsListaTitulo,
432            Mensagem::ConfigCaminhoLabel,
433            Mensagem::ConfigSemChaves,
434            Mensagem::ErroCarregarConfig,
435            Mensagem::ErroSalvarConfig,
436            Mensagem::ErroConexaoSsh,
437            Mensagem::ErroComandoFalhou,
438            Mensagem::TunnelPressioneCtrlC,
439            Mensagem::HealthCheckSemVps,
440            Mensagem::OperacaoCancelada,
441        ];
442        for v in &unitarias {
443            let texto = v.texto(Idioma::English);
444            assert!(!texto.is_empty(), "EN vazia para {:?}", v);
445        }
446    }
447
448    #[test]
449    fn todas_variantes_unitarias_pt_nao_vazias() {
450        let unitarias = [
451            Mensagem::VpsRegistroVazio,
452            Mensagem::VpsListaTitulo,
453            Mensagem::ConfigCaminhoLabel,
454            Mensagem::ConfigSemChaves,
455            Mensagem::ErroCarregarConfig,
456            Mensagem::ErroSalvarConfig,
457            Mensagem::ErroConexaoSsh,
458            Mensagem::ErroComandoFalhou,
459            Mensagem::TunnelPressioneCtrlC,
460            Mensagem::HealthCheckSemVps,
461            Mensagem::OperacaoCancelada,
462        ];
463        for v in &unitarias {
464            let texto = v.texto(Idioma::Portugues);
465            assert!(!texto.is_empty(), "PT vazia para {:?}", v);
466        }
467    }
468
469    #[test]
470    fn traducoes_pt_diferentes_de_en_para_unitarias() {
471        let pares = [
472            (Mensagem::VpsRegistroVazio, Mensagem::VpsRegistroVazio),
473            (Mensagem::ErroConexaoSsh, Mensagem::ErroConexaoSsh),
474            (Mensagem::HealthCheckSemVps, Mensagem::HealthCheckSemVps),
475            (Mensagem::OperacaoCancelada, Mensagem::OperacaoCancelada),
476        ];
477        for (a, b) in &pares {
478            let en = a.texto(Idioma::English);
479            let pt = b.texto(Idioma::Portugues);
480            assert_ne!(en, pt, "EN == PT para {:?}", a);
481        }
482    }
483
484    #[test]
485    fn health_check_falhou_inclui_nome_e_detalhe() {
486        let msg = Mensagem::HealthCheckFalhou {
487            nome: "prod-01".to_string(),
488            detalhe: "timeout".to_string(),
489        };
490        assert!(msg.texto(Idioma::English).contains("prod-01"));
491        assert!(msg.texto(Idioma::English).contains("timeout"));
492        assert!(msg.texto(Idioma::Portugues).contains("prod-01"));
493        assert!(msg.texto(Idioma::Portugues).contains("timeout"));
494    }
495
496    #[test]
497    fn health_check_latencia_inclui_nome_e_ms() {
498        let msg = Mensagem::HealthCheckLatencia {
499            nome: "relay-01".to_string(),
500            latencia_ms: 42,
501        };
502        assert!(msg.texto(Idioma::English).contains("relay-01"));
503        assert!(msg.texto(Idioma::English).contains("42"));
504        assert!(msg.texto(Idioma::Portugues).contains("relay-01"));
505        assert!(msg.texto(Idioma::Portugues).contains("42"));
506    }
507
508    #[test]
509    fn inicializar_idioma_sem_forcar_nao_panic() {
510        let resultado = inicializar_idioma(None);
511        assert!(resultado.is_ok());
512    }
513
514    #[test]
515    fn inicializar_idioma_com_pt_br_funciona() {
516        let resultado = inicializar_idioma(Some("pt-BR"));
517        assert!(resultado.is_ok());
518    }
519
520    #[test]
521    fn idioma_atual_retorna_valor_valido() {
522        let idioma = idioma_atual();
523        assert!(idioma == Idioma::English || idioma == Idioma::Portugues);
524    }
525}