Skip to main content

ssh_cli/
output.rs

1//! Único módulo autorizado a emitir output em stdout para CRUD de VPS.
2//!
3//! Este módulo centraliza TODA formatação de CRUD: texto e JSON.
4//!
5//! Logs (tracing) vão para stderr, gerenciados por `tracing-subscriber`.
6
7use crate::mascaramento::mascarar;
8use crate::ssh::SaidaExecucao;
9use crate::vps::modelo::VpsRegistro;
10use secrecy::ExposeSecret;
11use serde_json::json;
12use std::io::{self, Write};
13
14/// Escreve uma linha em stdout garantindo LF puro (nunca CRLF).
15///
16/// # Erros
17/// Retorna erro se o I/O em stdout falhar.
18pub fn escrever_linha(conteudo: &str) -> io::Result<()> {
19    let stdout = io::stdout();
20    let mut handle = stdout.lock();
21    handle.write_all(conteudo.as_bytes())?;
22    handle.write_all(b"\n")?;
23    handle.flush()?;
24    Ok(())
25}
26
27/// Imprime mensagem de sucesso em texto para humanos.
28pub fn imprimir_sucesso(mensagem: &str) {
29    println!("{mensagem}");
30}
31
32/// Imprime mensagem de erro em stderr (para humanos).
33pub fn imprimir_erro(mensagem: &str) {
34    eprintln!("{mensagem}");
35}
36
37/// Imprime erro de inicialização de runtime em stderr.
38///
39/// Emite no formato `erro ao criar runtime: {mensagem}` via stderr.
40/// Usada em `main.rs` para falhas de construção do runtime tokio ANTES
41/// de qualquer lógica async estar disponível.
42pub fn imprimir_erro_runtime(mensagem: &str) {
43    eprintln!("erro ao criar runtime: {mensagem}");
44}
45
46/// Imprime erro de domínio [`crate::erros::ErroSshCli`] em stderr.
47///
48/// Usa o `Display` do `thiserror` para emitir a mensagem canônica do erro.
49/// Chamada pelo `main.rs` após downcast de `anyhow::Error` para o tipo de
50/// domínio, preservando o contrato de mensagens definido em `errors.rs`.
51pub fn imprimir_erro_dominio(erro: &crate::erros::ErroSshCli) {
52    eprintln!("{erro}");
53}
54
55/// Imprime erro genérico `anyhow::Error` em stderr incluindo a cadeia de causas.
56///
57/// Emite primeiro a mensagem principal e, se houver, as causas encadeadas
58/// prefixadas por `  causado por: ` (uma por linha). Usada em `main.rs`
59/// como fallback quando o erro NÃO é um `ErroSshCli` conhecido.
60pub fn imprimir_erro_generico(erro: &anyhow::Error) {
61    eprintln!("{erro}");
62    for causa in erro.chain().skip(1) {
63        eprintln!("  causado por: {causa}");
64    }
65}
66
67/// Imprime lista de VPS em formato texto (mascarado).
68pub fn imprimir_lista_texto(registros: &[VpsRegistro]) {
69    if registros.is_empty() {
70        println!(
71            "{}",
72            crate::i18n::t(crate::i18n::Mensagem::VpsRegistroVazio)
73        );
74        return;
75    }
76
77    println!(
78        "{:<20} {:<30} {:<6} {:<15} {:<20}",
79        "NOME", "HOST", "PORTA", "USUÁRIO", "SENHA"
80    );
81    for r in registros {
82        println!(
83            "{:<20} {:<30} {:<6} {:<15} {:<20}",
84            r.nome,
85            r.host,
86            r.porta,
87            r.usuario,
88            mascarar(r.senha.expose_secret())
89        );
90    }
91}
92
93/// Imprime lista de VPS em formato JSON (mascarado).
94pub fn imprimir_lista_json(registros: &[VpsRegistro]) {
95    let lista: Vec<_> = registros.iter().map(registro_para_json_mascarado).collect();
96    match serde_json::to_string_pretty(&lista) {
97        Ok(s) => println!("{s}"),
98        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
99    }
100}
101
102/// Imprime detalhes de UMA VPS em texto (mascarado).
103pub fn imprimir_detalhes_texto(r: &VpsRegistro) {
104    println!("Nome:           {}", r.nome);
105    println!("Host:           {}", r.host);
106    println!("Porta:          {}", r.porta);
107    println!("Usuário:        {}", r.usuario);
108    println!("Senha:          {}", mascarar(r.senha.expose_secret()));
109    println!(
110        "Senha sudo:     {}",
111        r.senha_sudo
112            .as_ref()
113            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
114    );
115    println!(
116        "Senha su:       {}",
117        r.senha_su
118            .as_ref()
119            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
120    );
121    println!("Timeout (ms):   {}", r.timeout_ms);
122    println!("Max chars:      {}", r.max_chars);
123    println!("Schema version: {}", r.schema_version);
124    println!("Adicionado em:  {}", r.adicionado_em);
125}
126
127/// Imprime detalhes de UMA VPS em JSON (mascarado).
128pub fn imprimir_detalhes_json(r: &VpsRegistro) {
129    let v = registro_para_json_mascarado(r);
130    match serde_json::to_string_pretty(&v) {
131        Ok(s) => println!("{s}"),
132        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
133    }
134}
135
136fn registro_para_json_mascarado(r: &VpsRegistro) -> serde_json::Value {
137    json!({
138        "name": r.nome,
139        "host": r.host,
140        "port": r.porta,
141        "user": r.usuario,
142        "password": mascarar(r.senha.expose_secret()),
143        "sudo_password": r.senha_sudo.as_ref().map(|s| mascarar(s.expose_secret())),
144        "su_password": r.senha_su.as_ref().map(|s| mascarar(s.expose_secret())),
145        "timeout_ms": r.timeout_ms,
146        "max_chars": r.max_chars,
147        "schema_version": r.schema_version,
148        "added_at": r.adicionado_em,
149    })
150}
151
152/// Imprime stdout/stderr de execução de comando SSH.
153///
154/// Formato:
155/// ```text
156/// --- stdout ---
157/// <stdout>
158/// --- stderr ---
159/// <stderr>
160/// --- exit code: <code> (<duracao_ms>ms) ---
161/// ```
162pub fn imprimir_saida_execucao(saida: &SaidaExecucao) {
163    println!("--- stdout ---");
164    if saida.stdout.is_empty() {
165        println!("(vazio)");
166    } else {
167        println!("{}", saida.stdout);
168    }
169    println!("--- stderr ---");
170    if saida.stderr.is_empty() {
171        println!("(vazio)");
172    } else {
173        println!("{}", saida.stderr);
174    }
175    let code_str = saida
176        .exit_code
177        .map(|c| c.to_string())
178        .unwrap_or_else(|| "N/A".to_string());
179    println!("--- exit code: {} ({}ms) ---", code_str, saida.duracao_ms);
180    if saida.truncado_stdout {
181        println!("(stdout foi truncado)");
182    }
183    if saida.truncado_stderr {
184        println!("(stderr foi truncado)");
185    }
186}
187
188/// Imprime stdout/stderr de execução de comando SSH em formato JSON.
189pub fn imprimir_saida_execucao_json(saida: &SaidaExecucao) {
190    let v = json!({
191        "stdout": saida.stdout,
192        "stderr": saida.stderr,
193        "exit_code": saida.exit_code,
194        "truncated_stdout": saida.truncado_stdout,
195        "truncated_stderr": saida.truncado_stderr,
196        "duration_ms": saida.duracao_ms,
197    });
198    match serde_json::to_string_pretty(&v) {
199        Ok(s) => println!("{s}"),
200        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
201    }
202}
203
204/// Imprime resultado de health-check em formato texto.
205pub fn imprimir_health_check(nome: &str, latencia_ms: u64) {
206    println!(
207        "{}",
208        crate::i18n::t(crate::i18n::Mensagem::HealthCheckOk {
209            nome: nome.to_string(),
210        })
211    );
212    println!("  latência: {latencia_ms}ms");
213}
214
215/// Imprime resultado de health-check em formato JSON.
216pub fn imprimir_health_check_json(nome: &str, latencia_ms: u64) {
217    let v = json!({
218        "name": nome,
219        "status": "ok",
220        "latency_ms": latencia_ms,
221    });
222    match serde_json::to_string_pretty(&v) {
223        Ok(s) => println!("{s}"),
224        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
225    }
226}
227
228#[cfg(test)]
229mod testes {
230    use super::*;
231    use crate::ssh::SaidaExecucao;
232    use crate::vps::modelo::VpsRegistro;
233    use secrecy::SecretString;
234
235    fn registro_teste() -> VpsRegistro {
236        VpsRegistro::novo(
237            "vps-teste".into(),
238            "1.2.3.4".into(),
239            22,
240            "root".into(),
241            SecretString::from("senha-super-secreta".to_string()),
242            Some(5000),
243            Some(1000),
244            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
245            None,
246        )
247    }
248
249    #[test]
250    fn registro_para_json_mascarado_contem_campos_obrigatorios() {
251        let r = registro_teste();
252        let json = registro_para_json_mascarado(&r);
253        assert_eq!(json["name"], "vps-teste");
254        assert_eq!(json["host"], "1.2.3.4");
255        assert_eq!(json["port"], 22);
256        assert_eq!(json["user"], "root");
257        assert!(json["password"].as_str().unwrap().contains("..."));
258        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
259        assert!(json["su_password"].is_null());
260        assert_eq!(json["timeout_ms"], 5000);
261        assert_eq!(json["max_chars"], 1000);
262        assert_eq!(json["schema_version"], 1);
263    }
264
265    #[test]
266    fn registro_para_json_mascarado_senha_sudo_nula_quando_nao_definida() {
267        let mut r = registro_teste();
268        r.senha_sudo = None;
269        let json = registro_para_json_mascarado(&r);
270        assert!(json["sudo_password"].is_null());
271    }
272
273    #[test]
274    fn registro_para_json_mascarado_su_password_presente() {
275        let mut r = registro_teste();
276        r.senha_su = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
277        let json = registro_para_json_mascarado(&r);
278        assert!(json["su_password"].as_str().unwrap().contains("..."));
279    }
280
281    #[test]
282    fn escribir_linha_ok() {
283        let resultado = escrever_linha("teste de escrita");
284        assert!(resultado.is_ok());
285    }
286
287    #[test]
288    fn escribir_linha_com_caracteres_especiais() {
289        let resultado = escrever_linha("linha com \t tab e \"aspas\"");
290        assert!(resultado.is_ok());
291    }
292
293    #[test]
294    fn salida_execucao_completa_formatada() {
295        let saida = SaidaExecucao {
296            stdout: "output do comando".to_string(),
297            stderr: "erro do comando".to_string(),
298            exit_code: Some(0),
299            truncado_stdout: false,
300            truncado_stderr: false,
301            duracao_ms: 150,
302        };
303        let resultado = escrever_linha(&format!(
304            "stdout: {}, stderr: {}, exit: {:?}",
305            saida.stdout, saida.stderr, saida.exit_code
306        ));
307        assert!(resultado.is_ok());
308    }
309
310    #[test]
311    fn salida_execucao_sem_exit_code() {
312        let saida = SaidaExecucao {
313            stdout: "".to_string(),
314            stderr: "".to_string(),
315            exit_code: None,
316            truncado_stdout: false,
317            truncado_stderr: false,
318            duracao_ms: 0,
319        };
320        let code_str = saida
321            .exit_code
322            .map(|c| c.to_string())
323            .unwrap_or_else(|| "N/A".to_string());
324        assert_eq!(code_str, "N/A");
325    }
326
327    #[test]
328    fn vps_registro_debug_nao_expoe_senha() {
329        let r = registro_teste();
330        let json = registro_para_json_mascarado(&r);
331        let json_str = serde_json::to_string(&json).unwrap();
332        assert!(!json_str.contains("senha-super-secreta"));
333        assert!(!json_str.contains("sudo-password-longa-aqui"));
334    }
335
336    #[test]
337    fn salida_execucao_truncada_mostra_aviso() {
338        let saida = SaidaExecucao {
339            stdout: "output".to_string(),
340            stderr: "erro".to_string(),
341            exit_code: Some(1),
342            truncado_stdout: true,
343            truncado_stderr: true,
344            duracao_ms: 100,
345        };
346        assert!(saida.truncado_stdout);
347        assert!(saida.truncado_stderr);
348    }
349
350    #[test]
351    fn salida_execucao_com_exit_code_numerico() {
352        let saida = SaidaExecucao {
353            stdout: "".to_string(),
354            stderr: "".to_string(),
355            exit_code: Some(127),
356            truncado_stdout: false,
357            truncado_stderr: false,
358            duracao_ms: 0,
359        };
360        let code_str = saida
361            .exit_code
362            .map(|c| c.to_string())
363            .unwrap_or_else(|| "N/A".to_string());
364        assert_eq!(code_str, "127");
365    }
366
367    #[test]
368    fn escribir_linha_string_vazia() {
369        let resultado = escrever_linha("");
370        assert!(resultado.is_ok());
371    }
372
373    #[test]
374    fn escribir_linha_com_unicode_brasileiro() {
375        let resultado = escrever_linha("ação você está Itaú");
376        assert!(resultado.is_ok());
377    }
378
379    #[test]
380    fn escribir_linha_com_emojis() {
381        let resultado = escrever_linha("texto com 🚀 e 🔐");
382        assert!(resultado.is_ok());
383    }
384
385    #[test]
386    fn escribir_linha_com_newlines() {
387        let resultado = escrever_linha("linha1\nlinha2\nlinha3");
388        assert!(resultado.is_ok());
389    }
390
391    #[test]
392    fn escribir_linha_longo_texto() {
393        let texto_longo = "a".repeat(10000);
394        let resultado = escrever_linha(&texto_longo);
395        assert!(resultado.is_ok());
396    }
397
398    #[test]
399    fn registro_para_json_mascarado_com_senha_curta_mascara_com_asteriscos() {
400        let mut r = registro_teste();
401        r.senha = SecretString::from("curta".to_string());
402        let json = registro_para_json_mascarado(&r);
403        let senha_str = json["password"].as_str().unwrap();
404        assert_eq!(senha_str, "***");
405    }
406
407    #[test]
408    fn registro_para_json_mascarado_com_sudo_e_su_definidos() {
409        let mut r = registro_teste();
410        r.senha_sudo = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
411        r.senha_su = Some(SecretString::from("su-pass-longa-aqui".to_string()));
412        let json = registro_para_json_mascarado(&r);
413        assert!(!json["sudo_password"].is_null());
414        assert!(!json["su_password"].is_null());
415        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
416        assert!(json["su_password"].as_str().unwrap().contains("..."));
417    }
418
419    #[test]
420    fn saida_execucao_formatacao_completa() {
421        let saida = SaidaExecucao {
422            stdout: "comando executado".to_string(),
423            stderr: "aviso harmless".to_string(),
424            exit_code: Some(0),
425            truncado_stdout: false,
426            truncado_stderr: false,
427            duracao_ms: 1000,
428        };
429        assert_eq!(saida.stdout, "comando executado");
430        assert_eq!(saida.stderr, "aviso harmless");
431        assert_eq!(saida.exit_code, Some(0));
432        assert_eq!(saida.duracao_ms, 1000);
433        assert!(!saida.truncado_stdout);
434        assert!(!saida.truncado_stderr);
435    }
436
437    #[test]
438    fn saida_execucao_sem_stderr() {
439        let saida = SaidaExecucao {
440            stdout: "ok".to_string(),
441            stderr: String::new(),
442            exit_code: Some(0),
443            truncado_stdout: false,
444            truncado_stderr: false,
445            duracao_ms: 50,
446        };
447        assert!(saida.stderr.is_empty());
448    }
449
450    #[test]
451    fn saida_execucao_com_sinal_em_vez_de_exit_code() {
452        let saida = SaidaExecucao {
453            stdout: String::new(),
454            stderr: "signal received".to_string(),
455            exit_code: None,
456            truncado_stdout: false,
457            truncado_stderr: false,
458            duracao_ms: 5000,
459        };
460        assert!(saida.exit_code.is_none());
461    }
462
463    #[test]
464    fn saida_execucao_json_contem_campos_obrigatorios() {
465        let saida = SaidaExecucao {
466            stdout: "output".to_string(),
467            stderr: "erro".to_string(),
468            exit_code: Some(0),
469            truncado_stdout: false,
470            truncado_stderr: false,
471            duracao_ms: 100,
472        };
473        imprimir_saida_execucao_json(&saida);
474    }
475
476    #[test]
477    fn imprimir_erro_runtime_nao_panica_com_mensagem_simples() {
478        imprimir_erro_runtime("falha ao bindar socket");
479    }
480
481    #[test]
482    fn imprimir_erro_runtime_nao_panica_com_mensagem_vazia() {
483        imprimir_erro_runtime("");
484    }
485
486    #[test]
487    fn imprimir_erro_runtime_nao_panica_com_unicode() {
488        imprimir_erro_runtime("erro acentuação: operação não concluída");
489    }
490
491    #[test]
492    fn imprimir_erro_dominio_nao_panica_com_variante_simples() {
493        let erro = crate::erros::ErroSshCli::VpsNaoEncontrada("producao".into());
494        imprimir_erro_dominio(&erro);
495    }
496
497    #[test]
498    fn imprimir_erro_dominio_nao_panica_com_variante_estruturada() {
499        let erro = crate::erros::ErroSshCli::ComandoFalhou {
500            exit_code: 127,
501            stderr: "command not found".into(),
502        };
503        imprimir_erro_dominio(&erro);
504    }
505
506    #[test]
507    fn imprimir_erro_dominio_nao_panica_com_autenticacao_falhou() {
508        let erro = crate::erros::ErroSshCli::AutenticacaoFalhou;
509        imprimir_erro_dominio(&erro);
510    }
511
512    #[test]
513    fn imprimir_erro_generico_nao_panica_com_erro_simples() {
514        let erro = anyhow::anyhow!("falha genérica no pipeline");
515        imprimir_erro_generico(&erro);
516    }
517
518    #[test]
519    fn imprimir_erro_generico_nao_panica_com_chain_de_causas() {
520        let raiz = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "acesso negado");
521        let intermediario = anyhow::Error::new(raiz).context("falha ao abrir socket");
522        let topo = intermediario.context("falha ao inicializar conexão");
523        imprimir_erro_generico(&topo);
524    }
525
526    #[test]
527    fn imprimir_erro_generico_com_chain_contem_multiplas_causas() {
528        let raiz = std::io::Error::new(std::io::ErrorKind::NotFound, "arquivo ausente");
529        let erro = anyhow::Error::new(raiz)
530            .context("falha ao carregar config")
531            .context("falha ao inicializar");
532        let total_causas = erro.chain().count();
533        assert!(total_causas >= 2);
534        imprimir_erro_generico(&erro);
535    }
536}