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