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    /// Pergunta de confirmação para remoção de VPS.
131    ConfirmarRemocaoVps {
132        /// Nome da VPS a ser removida.
133        nome: String,
134    },
135    /// Mensagem informando que a remoção foi cancelada pelo usuário.
136    RemocaoCancelada,
137    /// Erro exigindo flag `--yes` em modo não-interativo.
138    RemoveExigeYesEmNaoInterativo,
139}
140
141impl Mensagem {
142    /// Retorna a string da mensagem no idioma especificado.
143    ///
144    /// Método determinístico para uso em testes — não depende de estado global.
145    pub fn texto(&self, idioma: Idioma) -> String {
146        match idioma {
147            Idioma::English => en(self),
148            Idioma::Portugues => pt(self),
149        }
150    }
151}
152
153/// Inicializa o sistema de i18n detectando o locale do SO.
154///
155/// Se `forcar` for `Some(...)`, esse idioma sobrescreve a detecção automática.
156pub fn inicializar_idioma(forcar: Option<&str>) -> Result<()> {
157    let idioma = crate::locale::resolver_idioma(forcar);
158    crate::locale::definir_idioma(idioma);
159    Ok(())
160}
161
162/// Retorna o idioma atualmente configurado.
163#[must_use]
164pub fn idioma_atual() -> Idioma {
165    crate::locale::idioma_atual()
166}
167
168/// Retorna a string da mensagem no idioma global atual.
169///
170/// Usa o estado global inicializado por `inicializar_idioma`.
171/// Em testes, prefira `Mensagem::texto(idioma)` para determinismo.
172///
173/// # Examples
174///
175/// ```
176/// use ssh_cli::i18n::{t, inicializar_idioma, Mensagem};
177///
178/// inicializar_idioma(Some("en-US")).unwrap();
179/// let texto = t(Mensagem::VpsRegistroVazio);
180/// assert!(!texto.is_empty());
181/// ```
182#[must_use]
183pub fn t(msg: Mensagem) -> String {
184    msg.texto(idioma_atual())
185}
186
187/// Traduções para inglês americano.
188fn en(msg: &Mensagem) -> String {
189    match msg {
190        Mensagem::VpsRegistroVazio => "No VPS registered.".to_string(),
191        Mensagem::VpsListaTitulo => "Registered VPS:".to_string(),
192        Mensagem::VpsAdicionada { nome } => format!("VPS '{nome}' added successfully."),
193        Mensagem::VpsRemovida { nome } => format!("VPS '{nome}' removed successfully."),
194        Mensagem::VpsDuplicada { nome } => format!("VPS '{nome}' is already registered."),
195        Mensagem::VpsNaoEncontrada { nome } => format!("VPS '{nome}' not found."),
196        Mensagem::VpsAtivaSelecionada { nome } => format!("Active VPS: '{nome}'."),
197        Mensagem::ConfigCaminhoLabel => "Configuration file:".to_string(),
198        Mensagem::ConfigCaminho { caminho } => caminho.clone(),
199        Mensagem::ConfigSemChaves => "No API keys configured.".to_string(),
200        Mensagem::ErroCarregarConfig => "Failed to load configuration.".to_string(),
201        Mensagem::ErroSalvarConfig => "Failed to save configuration.".to_string(),
202        Mensagem::ErroConexaoSsh => "SSH connection error.".to_string(),
203        Mensagem::ErroComandoFalhou => "Command execution failed.".to_string(),
204        Mensagem::ErroArgumentoInvalido { detalhe } => format!("Invalid argument: {detalhe}"),
205        Mensagem::ErroGenerico { detalhe } => detalhe.clone(),
206        Mensagem::TunnelAtivo {
207            porta_local,
208            host_remoto,
209            porta_remota,
210            vps_nome,
211        } => format!(
212            "SSH tunnel active: localhost:{porta_local} -> {host_remoto}:{porta_remota} via {vps_nome}"
213        ),
214        Mensagem::TunnelPressioneCtrlC => "Press Ctrl+C to terminate.".to_string(),
215        Mensagem::HealthCheckOk { nome } => format!("Health check passed for '{nome}'."),
216        Mensagem::HealthCheckSemVps => {
217            "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
218        }
219        Mensagem::HealthCheckFalhou { nome, detalhe } => {
220            format!("Health check FAILED for '{nome}': {detalhe}")
221        }
222        Mensagem::HealthCheckLatencia { nome, latencia_ms } => {
223            format!("Health check OK for '{nome}' ({latencia_ms}ms)")
224        }
225        Mensagem::OperacaoCancelada => "Operation cancelled by user.".to_string(),
226        Mensagem::ConfirmarRemocaoVps { nome } => format!("Remove VPS '{nome}'? (y/N): "),
227        Mensagem::RemocaoCancelada => "Removal cancelled.".to_string(),
228        Mensagem::RemoveExigeYesEmNaoInterativo => {
229            "Non-interactive mode: use --yes (-y) to confirm removal.".to_string()
230        }
231    }
232}
233
234/// Traduções para português brasileiro.
235fn pt(msg: &Mensagem) -> String {
236    match msg {
237        Mensagem::VpsRegistroVazio => "Nenhum VPS cadastrado.".to_string(),
238        Mensagem::VpsListaTitulo => "VPS cadastrados:".to_string(),
239        Mensagem::VpsAdicionada { nome } => format!("VPS '{nome}' adicionada com sucesso."),
240        Mensagem::VpsRemovida { nome } => format!("VPS '{nome}' removida com sucesso."),
241        Mensagem::VpsDuplicada { nome } => format!("VPS '{nome}' já está cadastrada."),
242        Mensagem::VpsNaoEncontrada { nome } => format!("VPS '{nome}' não encontrada."),
243        Mensagem::VpsAtivaSelecionada { nome } => format!("VPS ativa: '{nome}'."),
244        Mensagem::ConfigCaminhoLabel => "Arquivo de configuração:".to_string(),
245        Mensagem::ConfigCaminho { caminho } => caminho.clone(),
246        Mensagem::ConfigSemChaves => "Nenhuma chave de API configurada.".to_string(),
247        Mensagem::ErroCarregarConfig => "Falha ao carregar configuração.".to_string(),
248        Mensagem::ErroSalvarConfig => "Falha ao salvar configuração.".to_string(),
249        Mensagem::ErroConexaoSsh => "Erro de conexão SSH.".to_string(),
250        Mensagem::ErroComandoFalhou => "Falha na execução do comando.".to_string(),
251        Mensagem::ErroArgumentoInvalido { detalhe } => format!("Argumento inválido: {detalhe}"),
252        Mensagem::ErroGenerico { detalhe } => detalhe.clone(),
253        Mensagem::TunnelAtivo {
254            porta_local,
255            host_remoto,
256            porta_remota,
257            vps_nome,
258        } => format!(
259            "Tunnel SSH: localhost:{porta_local} -> {host_remoto}:{porta_remota} via {vps_nome}"
260        ),
261        Mensagem::TunnelPressioneCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
262        Mensagem::HealthCheckOk { nome } => format!("Health check bem-sucedido para '{nome}'."),
263        Mensagem::HealthCheckSemVps => {
264            "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
265        }
266        Mensagem::HealthCheckFalhou { nome, detalhe } => {
267            format!("Health check FALHOU para '{nome}': {detalhe}")
268        }
269        Mensagem::HealthCheckLatencia { nome, latencia_ms } => {
270            format!("Health check OK para '{nome}' ({latencia_ms}ms)")
271        }
272        Mensagem::OperacaoCancelada => "Operação cancelada pelo usuário.".to_string(),
273        Mensagem::ConfirmarRemocaoVps { nome } => format!("Remover VPS '{nome}'? (s/N): "),
274        Mensagem::RemocaoCancelada => "Remoção cancelada.".to_string(),
275        Mensagem::RemoveExigeYesEmNaoInterativo => {
276            "Modo não-interativo: use --yes (-y) para confirmar remoção.".to_string()
277        }
278    }
279}
280
281#[cfg(test)]
282mod testes {
283    use super::*;
284
285    #[test]
286    fn idioma_enum_e_copy() {
287        let a = Idioma::English;
288        let b = a;
289        assert_eq!(a, b);
290    }
291
292    #[test]
293    fn mensagem_nao_e_copy_mas_e_clone() {
294        let m = Mensagem::VpsAdicionada {
295            nome: "vps-01".to_string(),
296        };
297        let m2 = m.clone();
298        assert_eq!(m, m2);
299    }
300
301    #[test]
302    fn vps_registro_vazio_en() {
303        assert_eq!(
304            Mensagem::VpsRegistroVazio.texto(Idioma::English),
305            "No VPS registered."
306        );
307    }
308
309    #[test]
310    fn vps_registro_vazio_pt() {
311        assert_eq!(
312            Mensagem::VpsRegistroVazio.texto(Idioma::Portugues),
313            "Nenhum VPS cadastrado."
314        );
315    }
316
317    #[test]
318    fn vps_adicionada_inclui_nome_en() {
319        let msg = Mensagem::VpsAdicionada {
320            nome: "prod-01".to_string(),
321        };
322        assert_eq!(
323            msg.texto(Idioma::English),
324            "VPS 'prod-01' added successfully."
325        );
326    }
327
328    #[test]
329    fn vps_adicionada_inclui_nome_pt() {
330        let msg = Mensagem::VpsAdicionada {
331            nome: "prod-01".to_string(),
332        };
333        assert_eq!(
334            msg.texto(Idioma::Portugues),
335            "VPS 'prod-01' adicionada com sucesso."
336        );
337    }
338
339    #[test]
340    fn vps_removida_inclui_nome() {
341        let msg = Mensagem::VpsRemovida {
342            nome: "dev-01".to_string(),
343        };
344        assert!(msg.texto(Idioma::English).contains("dev-01"));
345        assert!(msg.texto(Idioma::Portugues).contains("dev-01"));
346    }
347
348    #[test]
349    fn vps_duplicada_inclui_nome() {
350        let msg = Mensagem::VpsDuplicada {
351            nome: "staging".to_string(),
352        };
353        assert!(msg.texto(Idioma::English).contains("staging"));
354        assert!(msg.texto(Idioma::Portugues).contains("staging"));
355    }
356
357    #[test]
358    fn vps_nao_encontrada_inclui_nome() {
359        let msg = Mensagem::VpsNaoEncontrada {
360            nome: "inexistente".to_string(),
361        };
362        assert!(msg.texto(Idioma::English).contains("inexistente"));
363        assert!(msg.texto(Idioma::Portugues).contains("inexistente"));
364    }
365
366    #[test]
367    fn tunnel_ativo_inclui_todos_os_campos() {
368        let msg = Mensagem::TunnelAtivo {
369            porta_local: 8080,
370            host_remoto: "1.2.3.4".to_string(),
371            porta_remota: 22,
372            vps_nome: "meu-servidor".to_string(),
373        };
374        let en = msg.texto(Idioma::English);
375        assert!(en.contains("8080"));
376        assert!(en.contains("1.2.3.4"));
377        assert!(en.contains("22"));
378        assert!(en.contains("meu-servidor"));
379    }
380
381    #[test]
382    fn erro_argumento_invalido_inclui_detalhe() {
383        let msg = Mensagem::ErroArgumentoInvalido {
384            detalhe: "porta fora do intervalo".to_string(),
385        };
386        assert!(msg
387            .texto(Idioma::English)
388            .contains("porta fora do intervalo"));
389        assert!(msg
390            .texto(Idioma::Portugues)
391            .contains("porta fora do intervalo"));
392    }
393
394    #[test]
395    fn health_check_ok_inclui_nome() {
396        let msg = Mensagem::HealthCheckOk {
397            nome: "prod-01".to_string(),
398        };
399        assert!(msg.texto(Idioma::English).contains("prod-01"));
400        assert!(msg.texto(Idioma::Portugues).contains("prod-01"));
401    }
402
403    #[test]
404    fn todas_variantes_unitarias_en_nao_vazias() {
405        let unitarias = [
406            Mensagem::VpsRegistroVazio,
407            Mensagem::VpsListaTitulo,
408            Mensagem::ConfigCaminhoLabel,
409            Mensagem::ConfigSemChaves,
410            Mensagem::ErroCarregarConfig,
411            Mensagem::ErroSalvarConfig,
412            Mensagem::ErroConexaoSsh,
413            Mensagem::ErroComandoFalhou,
414            Mensagem::TunnelPressioneCtrlC,
415            Mensagem::HealthCheckSemVps,
416            Mensagem::OperacaoCancelada,
417        ];
418        for v in &unitarias {
419            let texto = v.texto(Idioma::English);
420            assert!(!texto.is_empty(), "EN vazia para {:?}", v);
421        }
422    }
423
424    #[test]
425    fn todas_variantes_unitarias_pt_nao_vazias() {
426        let unitarias = [
427            Mensagem::VpsRegistroVazio,
428            Mensagem::VpsListaTitulo,
429            Mensagem::ConfigCaminhoLabel,
430            Mensagem::ConfigSemChaves,
431            Mensagem::ErroCarregarConfig,
432            Mensagem::ErroSalvarConfig,
433            Mensagem::ErroConexaoSsh,
434            Mensagem::ErroComandoFalhou,
435            Mensagem::TunnelPressioneCtrlC,
436            Mensagem::HealthCheckSemVps,
437            Mensagem::OperacaoCancelada,
438        ];
439        for v in &unitarias {
440            let texto = v.texto(Idioma::Portugues);
441            assert!(!texto.is_empty(), "PT vazia para {:?}", v);
442        }
443    }
444
445    #[test]
446    fn traducoes_pt_diferentes_de_en_para_unitarias() {
447        let pares = [
448            (Mensagem::VpsRegistroVazio, Mensagem::VpsRegistroVazio),
449            (Mensagem::ErroConexaoSsh, Mensagem::ErroConexaoSsh),
450            (Mensagem::HealthCheckSemVps, Mensagem::HealthCheckSemVps),
451            (Mensagem::OperacaoCancelada, Mensagem::OperacaoCancelada),
452        ];
453        for (a, b) in &pares {
454            let en = a.texto(Idioma::English);
455            let pt = b.texto(Idioma::Portugues);
456            assert_ne!(en, pt, "EN == PT para {:?}", a);
457        }
458    }
459
460    #[test]
461    fn health_check_falhou_inclui_nome_e_detalhe() {
462        let msg = Mensagem::HealthCheckFalhou {
463            nome: "prod-01".to_string(),
464            detalhe: "timeout".to_string(),
465        };
466        assert!(msg.texto(Idioma::English).contains("prod-01"));
467        assert!(msg.texto(Idioma::English).contains("timeout"));
468        assert!(msg.texto(Idioma::Portugues).contains("prod-01"));
469        assert!(msg.texto(Idioma::Portugues).contains("timeout"));
470    }
471
472    #[test]
473    fn health_check_latencia_inclui_nome_e_ms() {
474        let msg = Mensagem::HealthCheckLatencia {
475            nome: "relay-01".to_string(),
476            latencia_ms: 42,
477        };
478        assert!(msg.texto(Idioma::English).contains("relay-01"));
479        assert!(msg.texto(Idioma::English).contains("42"));
480        assert!(msg.texto(Idioma::Portugues).contains("relay-01"));
481        assert!(msg.texto(Idioma::Portugues).contains("42"));
482    }
483
484    #[test]
485    fn inicializar_idioma_sem_forcar_nao_panic() {
486        let resultado = inicializar_idioma(None);
487        assert!(resultado.is_ok());
488    }
489
490    #[test]
491    fn inicializar_idioma_com_pt_br_funciona() {
492        let resultado = inicializar_idioma(Some("pt-BR"));
493        assert!(resultado.is_ok());
494    }
495
496    #[test]
497    fn idioma_atual_retorna_valor_valido() {
498        let idioma = idioma_atual();
499        assert!(idioma == Idioma::English || idioma == Idioma::Portugues);
500    }
501}