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, BufRead, IsTerminal, 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/// Indica se stdin está conectado a um terminal interativo (TTY).
33#[must_use]
34pub fn stdin_e_tty() -> bool {
35    io::stdin().is_terminal()
36}
37
38/// Versão pura e testável da leitura de confirmação `sim/não`.
39///
40/// Escreve `prompt` em `writer` e lê UMA linha de `reader`. Aceita como
41/// afirmativo: `s`, `S`, `sim`, `SIM`, `y`, `Y`, `yes`, `YES` (case-insensitive,
42/// com espaços em branco ao redor ignorados). Qualquer outra entrada — incluindo
43/// linha vazia ou EOF — é tratada como negativa.
44///
45/// # Erros
46/// Retorna erro se a escrita do prompt ou a leitura falharem.
47pub fn ler_confirmacao<R: BufRead, W: Write>(
48    reader: &mut R,
49    writer: &mut W,
50    prompt: &str,
51) -> io::Result<bool> {
52    writer.write_all(prompt.as_bytes())?;
53    writer.flush()?;
54    let mut linha = String::new();
55    let lidos = reader.read_line(&mut linha)?;
56    if lidos == 0 {
57        // EOF sem input = negativo (seguro para operação destrutiva).
58        return Ok(false);
59    }
60    let resposta = linha.trim().to_lowercase();
61    Ok(matches!(resposta.as_str(), "s" | "sim" | "y" | "yes"))
62}
63
64/// Emite `prompt` em stderr e lê a resposta de stdin.
65///
66/// Wrapper sobre [`ler_confirmacao`] usando stderr (para não poluir stdout em
67/// pipelines) e stdin real. Usado pelo handler de `vps remove` quando a flag
68/// `--yes` não foi passada e stdin está em modo TTY.
69///
70/// # Erros
71/// Retorna erro se o I/O falhar.
72pub fn perguntar_confirmacao(prompt: &str) -> io::Result<bool> {
73    let stdin = io::stdin();
74    let mut reader = stdin.lock();
75    let stderr = io::stderr();
76    let mut writer = stderr.lock();
77    ler_confirmacao(&mut reader, &mut writer, prompt)
78}
79
80/// Imprime mensagem de erro em stderr (para humanos).
81pub fn imprimir_erro(mensagem: &str) {
82    eprintln!("{mensagem}");
83}
84
85/// Imprime erro de inicialização de runtime em stderr.
86///
87/// Emite no formato `erro ao criar runtime: {mensagem}` via stderr.
88/// Usada em `main.rs` para falhas de construção do runtime tokio ANTES
89/// de qualquer lógica async estar disponível.
90pub fn imprimir_erro_runtime(mensagem: &str) {
91    eprintln!("erro ao criar runtime: {mensagem}");
92}
93
94/// Imprime erro de domínio [`crate::erros::ErroSshCli`] em stderr.
95///
96/// Usa o `Display` do `thiserror` para emitir a mensagem canônica do erro.
97/// Chamada pelo `main.rs` após downcast de `anyhow::Error` para o tipo de
98/// domínio, preservando o contrato de mensagens definido em `errors.rs`.
99pub fn imprimir_erro_dominio(erro: &crate::erros::ErroSshCli) {
100    eprintln!("{}", erro.mensagem_i18n());
101}
102
103/// Imprime erro genérico `anyhow::Error` em stderr incluindo a cadeia de causas.
104///
105/// Emite primeiro a mensagem principal e, se houver, as causas encadeadas
106/// prefixadas por `  causado por: ` (uma por linha). Usada em `main.rs`
107/// como fallback quando o erro NÃO é um `ErroSshCli` conhecido.
108pub fn imprimir_erro_generico(erro: &anyhow::Error) {
109    eprintln!("{erro}");
110    for causa in erro.chain().skip(1) {
111        eprintln!("  causado por: {causa}");
112    }
113}
114
115/// Imprime lista de VPS em formato texto (mascarado).
116pub fn imprimir_lista_texto(registros: &[VpsRegistro]) {
117    if registros.is_empty() {
118        println!(
119            "{}",
120            crate::i18n::t(crate::i18n::Mensagem::VpsRegistroVazio)
121        );
122        return;
123    }
124
125    println!(
126        "{:<20} {:<30} {:<6} {:<15} {:<20}",
127        "NOME", "HOST", "PORTA", "USUÁRIO", "SENHA"
128    );
129    for r in registros {
130        println!(
131            "{:<20} {:<30} {:<6} {:<15} {:<20}",
132            r.nome,
133            r.host,
134            r.porta,
135            r.usuario,
136            mascarar(r.senha.expose_secret())
137        );
138    }
139}
140
141/// Imprime lista de VPS em formato JSON (mascarado).
142pub fn imprimir_lista_json(registros: &[VpsRegistro]) {
143    let lista: Vec<_> = registros.iter().map(registro_para_json_mascarado).collect();
144    match serde_json::to_string_pretty(&lista) {
145        Ok(s) => println!("{s}"),
146        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
147    }
148}
149
150/// Imprime detalhes de UMA VPS em texto (mascarado).
151pub fn imprimir_detalhes_texto(r: &VpsRegistro) {
152    println!("Nome:           {}", r.nome);
153    println!("Host:           {}", r.host);
154    println!("Porta:          {}", r.porta);
155    println!("Usuário:        {}", r.usuario);
156    println!("Senha:          {}", mascarar(r.senha.expose_secret()));
157    println!(
158        "Senha sudo:     {}",
159        r.senha_sudo
160            .as_ref()
161            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
162    );
163    println!(
164        "Senha su:       {}",
165        r.senha_su
166            .as_ref()
167            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
168    );
169    println!("Timeout (ms):   {}", r.timeout_ms);
170    println!("Max chars:      {}", r.max_chars);
171    println!("Schema version: {}", r.schema_version);
172    println!("Adicionado em:  {}", r.adicionado_em);
173}
174
175/// Imprime detalhes de UMA VPS em JSON (mascarado).
176pub fn imprimir_detalhes_json(r: &VpsRegistro) {
177    let v = registro_para_json_mascarado(r);
178    match serde_json::to_string_pretty(&v) {
179        Ok(s) => println!("{s}"),
180        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
181    }
182}
183
184fn registro_para_json_mascarado(r: &VpsRegistro) -> serde_json::Value {
185    json!({
186        "name": r.nome,
187        "host": r.host,
188        "port": r.porta,
189        "user": r.usuario,
190        "password": mascarar(r.senha.expose_secret()),
191        "sudo_password": r.senha_sudo.as_ref().map(|s| mascarar(s.expose_secret())),
192        "su_password": r.senha_su.as_ref().map(|s| mascarar(s.expose_secret())),
193        "timeout_ms": r.timeout_ms,
194        "max_chars": r.max_chars,
195        "schema_version": r.schema_version,
196        "added_at": r.adicionado_em,
197    })
198}
199
200/// Imprime stdout/stderr de execução de comando SSH.
201///
202/// Formato:
203/// ```text
204/// --- stdout ---
205/// <stdout>
206/// --- stderr ---
207/// <stderr>
208/// --- exit code: <code> (<duracao_ms>ms) ---
209/// ```
210pub fn imprimir_saida_execucao(saida: &SaidaExecucao) {
211    println!("--- stdout ---");
212    if saida.stdout.is_empty() {
213        println!("(vazio)");
214    } else {
215        println!("{}", saida.stdout);
216    }
217    println!("--- stderr ---");
218    if saida.stderr.is_empty() {
219        println!("(vazio)");
220    } else {
221        println!("{}", saida.stderr);
222    }
223    let code_str = saida
224        .exit_code
225        .map(|c| c.to_string())
226        .unwrap_or_else(|| "N/A".to_string());
227    println!("--- exit code: {} ({}ms) ---", code_str, saida.duracao_ms);
228    if saida.truncado_stdout {
229        println!("(stdout foi truncado)");
230    }
231    if saida.truncado_stderr {
232        println!("(stderr foi truncado)");
233    }
234}
235
236/// Imprime stdout/stderr de execução de comando SSH em formato JSON.
237pub fn imprimir_saida_execucao_json(saida: &SaidaExecucao) {
238    let v = json!({
239        "stdout": saida.stdout,
240        "stderr": saida.stderr,
241        "exit_code": saida.exit_code,
242        "truncated_stdout": saida.truncado_stdout,
243        "truncated_stderr": saida.truncado_stderr,
244        "duration_ms": saida.duracao_ms,
245    });
246    match serde_json::to_string_pretty(&v) {
247        Ok(s) => println!("{s}"),
248        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
249    }
250}
251
252/// Imprime resultado de health-check em formato texto.
253pub fn imprimir_health_check(nome: &str, latencia_ms: u64) {
254    println!(
255        "{}",
256        crate::i18n::t(crate::i18n::Mensagem::HealthCheckOk {
257            nome: nome.to_string(),
258        })
259    );
260    println!("  latência: {latencia_ms}ms");
261}
262
263/// Imprime resultado de health-check em formato JSON.
264pub fn imprimir_health_check_json(nome: &str, latencia_ms: u64) {
265    let v = json!({
266        "name": nome,
267        "status": "ok",
268        "latency_ms": latencia_ms,
269    });
270    match serde_json::to_string_pretty(&v) {
271        Ok(s) => println!("{s}"),
272        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
273    }
274}
275
276#[cfg(test)]
277mod testes {
278    use super::*;
279    use crate::ssh::SaidaExecucao;
280    use crate::vps::modelo::VpsRegistro;
281    use secrecy::SecretString;
282
283    fn registro_teste() -> VpsRegistro {
284        VpsRegistro::novo(
285            "vps-teste".into(),
286            "1.2.3.4".into(),
287            22,
288            "root".into(),
289            SecretString::from("senha-super-secreta".to_string()),
290            Some(5000),
291            Some(1000),
292            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
293            None,
294        )
295    }
296
297    #[test]
298    fn registro_para_json_mascarado_contem_campos_obrigatorios() {
299        let r = registro_teste();
300        let json = registro_para_json_mascarado(&r);
301        assert_eq!(json["name"], "vps-teste");
302        assert_eq!(json["host"], "1.2.3.4");
303        assert_eq!(json["port"], 22);
304        assert_eq!(json["user"], "root");
305        assert!(json["password"].as_str().unwrap().contains("..."));
306        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
307        assert!(json["su_password"].is_null());
308        assert_eq!(json["timeout_ms"], 5000);
309        assert_eq!(json["max_chars"], 1000);
310        assert_eq!(json["schema_version"], 1);
311    }
312
313    #[test]
314    fn registro_para_json_mascarado_senha_sudo_nula_quando_nao_definida() {
315        let mut r = registro_teste();
316        r.senha_sudo = None;
317        let json = registro_para_json_mascarado(&r);
318        assert!(json["sudo_password"].is_null());
319    }
320
321    #[test]
322    fn registro_para_json_mascarado_su_password_presente() {
323        let mut r = registro_teste();
324        r.senha_su = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
325        let json = registro_para_json_mascarado(&r);
326        assert!(json["su_password"].as_str().unwrap().contains("..."));
327    }
328
329    #[test]
330    fn escribir_linha_ok() {
331        let resultado = escrever_linha("teste de escrita");
332        assert!(resultado.is_ok());
333    }
334
335    #[test]
336    fn escribir_linha_com_caracteres_especiais() {
337        let resultado = escrever_linha("linha com \t tab e \"aspas\"");
338        assert!(resultado.is_ok());
339    }
340
341    #[test]
342    fn salida_execucao_completa_formatada() {
343        let saida = SaidaExecucao {
344            stdout: "output do comando".to_string(),
345            stderr: "erro do comando".to_string(),
346            exit_code: Some(0),
347            truncado_stdout: false,
348            truncado_stderr: false,
349            duracao_ms: 150,
350        };
351        let resultado = escrever_linha(&format!(
352            "stdout: {}, stderr: {}, exit: {:?}",
353            saida.stdout, saida.stderr, saida.exit_code
354        ));
355        assert!(resultado.is_ok());
356    }
357
358    #[test]
359    fn salida_execucao_sem_exit_code() {
360        let saida = SaidaExecucao {
361            stdout: "".to_string(),
362            stderr: "".to_string(),
363            exit_code: None,
364            truncado_stdout: false,
365            truncado_stderr: false,
366            duracao_ms: 0,
367        };
368        let code_str = saida
369            .exit_code
370            .map(|c| c.to_string())
371            .unwrap_or_else(|| "N/A".to_string());
372        assert_eq!(code_str, "N/A");
373    }
374
375    #[test]
376    fn vps_registro_debug_nao_expoe_senha() {
377        let r = registro_teste();
378        let json = registro_para_json_mascarado(&r);
379        let json_str = serde_json::to_string(&json).unwrap();
380        assert!(!json_str.contains("senha-super-secreta"));
381        assert!(!json_str.contains("sudo-password-longa-aqui"));
382    }
383
384    #[test]
385    fn salida_execucao_truncada_mostra_aviso() {
386        let saida = SaidaExecucao {
387            stdout: "output".to_string(),
388            stderr: "erro".to_string(),
389            exit_code: Some(1),
390            truncado_stdout: true,
391            truncado_stderr: true,
392            duracao_ms: 100,
393        };
394        assert!(saida.truncado_stdout);
395        assert!(saida.truncado_stderr);
396    }
397
398    #[test]
399    fn salida_execucao_com_exit_code_numerico() {
400        let saida = SaidaExecucao {
401            stdout: "".to_string(),
402            stderr: "".to_string(),
403            exit_code: Some(127),
404            truncado_stdout: false,
405            truncado_stderr: false,
406            duracao_ms: 0,
407        };
408        let code_str = saida
409            .exit_code
410            .map(|c| c.to_string())
411            .unwrap_or_else(|| "N/A".to_string());
412        assert_eq!(code_str, "127");
413    }
414
415    #[test]
416    fn escribir_linha_string_vazia() {
417        let resultado = escrever_linha("");
418        assert!(resultado.is_ok());
419    }
420
421    #[test]
422    fn escribir_linha_com_unicode_brasileiro() {
423        let resultado = escrever_linha("ação você está Itaú");
424        assert!(resultado.is_ok());
425    }
426
427    #[test]
428    fn escribir_linha_com_emojis() {
429        let resultado = escrever_linha("texto com 🚀 e 🔐");
430        assert!(resultado.is_ok());
431    }
432
433    #[test]
434    fn escribir_linha_com_newlines() {
435        let resultado = escrever_linha("linha1\nlinha2\nlinha3");
436        assert!(resultado.is_ok());
437    }
438
439    #[test]
440    fn escribir_linha_longo_texto() {
441        let texto_longo = "a".repeat(10000);
442        let resultado = escrever_linha(&texto_longo);
443        assert!(resultado.is_ok());
444    }
445
446    #[test]
447    fn registro_para_json_mascarado_com_senha_curta_mascara_com_asteriscos() {
448        let mut r = registro_teste();
449        r.senha = SecretString::from("curta".to_string());
450        let json = registro_para_json_mascarado(&r);
451        let senha_str = json["password"].as_str().unwrap();
452        assert_eq!(senha_str, "***");
453    }
454
455    #[test]
456    fn registro_para_json_mascarado_com_sudo_e_su_definidos() {
457        let mut r = registro_teste();
458        r.senha_sudo = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
459        r.senha_su = Some(SecretString::from("su-pass-longa-aqui".to_string()));
460        let json = registro_para_json_mascarado(&r);
461        assert!(!json["sudo_password"].is_null());
462        assert!(!json["su_password"].is_null());
463        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
464        assert!(json["su_password"].as_str().unwrap().contains("..."));
465    }
466
467    #[test]
468    fn saida_execucao_formatacao_completa() {
469        let saida = SaidaExecucao {
470            stdout: "comando executado".to_string(),
471            stderr: "aviso harmless".to_string(),
472            exit_code: Some(0),
473            truncado_stdout: false,
474            truncado_stderr: false,
475            duracao_ms: 1000,
476        };
477        assert_eq!(saida.stdout, "comando executado");
478        assert_eq!(saida.stderr, "aviso harmless");
479        assert_eq!(saida.exit_code, Some(0));
480        assert_eq!(saida.duracao_ms, 1000);
481        assert!(!saida.truncado_stdout);
482        assert!(!saida.truncado_stderr);
483    }
484
485    #[test]
486    fn saida_execucao_sem_stderr() {
487        let saida = SaidaExecucao {
488            stdout: "ok".to_string(),
489            stderr: String::new(),
490            exit_code: Some(0),
491            truncado_stdout: false,
492            truncado_stderr: false,
493            duracao_ms: 50,
494        };
495        assert!(saida.stderr.is_empty());
496    }
497
498    #[test]
499    fn saida_execucao_com_sinal_em_vez_de_exit_code() {
500        let saida = SaidaExecucao {
501            stdout: String::new(),
502            stderr: "signal received".to_string(),
503            exit_code: None,
504            truncado_stdout: false,
505            truncado_stderr: false,
506            duracao_ms: 5000,
507        };
508        assert!(saida.exit_code.is_none());
509    }
510
511    #[test]
512    fn saida_execucao_json_contem_campos_obrigatorios() {
513        let saida = SaidaExecucao {
514            stdout: "output".to_string(),
515            stderr: "erro".to_string(),
516            exit_code: Some(0),
517            truncado_stdout: false,
518            truncado_stderr: false,
519            duracao_ms: 100,
520        };
521        imprimir_saida_execucao_json(&saida);
522    }
523
524    #[test]
525    fn imprimir_erro_runtime_nao_panica_com_mensagem_simples() {
526        imprimir_erro_runtime("falha ao bindar socket");
527    }
528
529    #[test]
530    fn imprimir_erro_runtime_nao_panica_com_mensagem_vazia() {
531        imprimir_erro_runtime("");
532    }
533
534    #[test]
535    fn imprimir_erro_runtime_nao_panica_com_unicode() {
536        imprimir_erro_runtime("erro acentuação: operação não concluída");
537    }
538
539    #[test]
540    fn imprimir_erro_dominio_nao_panica_com_variante_simples() {
541        let erro = crate::erros::ErroSshCli::VpsNaoEncontrada("producao".into());
542        imprimir_erro_dominio(&erro);
543    }
544
545    #[test]
546    fn imprimir_erro_dominio_nao_panica_com_variante_estruturada() {
547        let erro = crate::erros::ErroSshCli::ComandoFalhou {
548            exit_code: 127,
549            stderr: "command not found".into(),
550        };
551        imprimir_erro_dominio(&erro);
552    }
553
554    #[test]
555    fn imprimir_erro_dominio_nao_panica_com_autenticacao_falhou() {
556        let erro = crate::erros::ErroSshCli::AutenticacaoFalhou;
557        imprimir_erro_dominio(&erro);
558    }
559
560    #[test]
561    fn imprimir_erro_generico_nao_panica_com_erro_simples() {
562        let erro = anyhow::anyhow!("falha genérica no pipeline");
563        imprimir_erro_generico(&erro);
564    }
565
566    #[test]
567    fn imprimir_erro_generico_nao_panica_com_chain_de_causas() {
568        let raiz = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "acesso negado");
569        let intermediario = anyhow::Error::new(raiz).context("falha ao abrir socket");
570        let topo = intermediario.context("falha ao inicializar conexão");
571        imprimir_erro_generico(&topo);
572    }
573
574    #[test]
575    fn ler_confirmacao_aceita_s_minusculo() {
576        let input = b"s\n";
577        let mut reader: &[u8] = input;
578        let mut writer: Vec<u8> = Vec::new();
579        let r = ler_confirmacao(&mut reader, &mut writer, "prompt: ").unwrap();
580        assert!(r);
581        assert_eq!(writer, b"prompt: ");
582    }
583
584    #[test]
585    fn ler_confirmacao_aceita_sim_maiusculo() {
586        let input = b"SIM\n";
587        let mut reader: &[u8] = input;
588        let mut writer: Vec<u8> = Vec::new();
589        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
590        assert!(r);
591    }
592
593    #[test]
594    fn ler_confirmacao_aceita_yes_com_espaco() {
595        let input = b"  yes  \n";
596        let mut reader: &[u8] = input;
597        let mut writer: Vec<u8> = Vec::new();
598        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
599        assert!(r);
600    }
601
602    #[test]
603    fn ler_confirmacao_aceita_y() {
604        let input = b"y\n";
605        let mut reader: &[u8] = input;
606        let mut writer: Vec<u8> = Vec::new();
607        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
608        assert!(r);
609    }
610
611    #[test]
612    fn ler_confirmacao_rejeita_n() {
613        let input = b"n\n";
614        let mut reader: &[u8] = input;
615        let mut writer: Vec<u8> = Vec::new();
616        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
617        assert!(!r);
618    }
619
620    #[test]
621    fn ler_confirmacao_rejeita_linha_vazia() {
622        let input = b"\n";
623        let mut reader: &[u8] = input;
624        let mut writer: Vec<u8> = Vec::new();
625        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
626        assert!(!r);
627    }
628
629    #[test]
630    fn ler_confirmacao_rejeita_eof() {
631        let input: &[u8] = b"";
632        let mut reader: &[u8] = input;
633        let mut writer: Vec<u8> = Vec::new();
634        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
635        assert!(!r);
636    }
637
638    #[test]
639    fn ler_confirmacao_rejeita_texto_arbitrario() {
640        let input = b"talvez\n";
641        let mut reader: &[u8] = input;
642        let mut writer: Vec<u8> = Vec::new();
643        let r = ler_confirmacao(&mut reader, &mut writer, "p: ").unwrap();
644        assert!(!r);
645    }
646
647    #[test]
648    fn imprimir_erro_generico_com_chain_contem_multiplas_causas() {
649        let raiz = std::io::Error::new(std::io::ErrorKind::NotFound, "arquivo ausente");
650        let erro = anyhow::Error::new(raiz)
651            .context("falha ao carregar config")
652            .context("falha ao inicializar");
653        let total_causas = erro.chain().count();
654        assert!(total_causas >= 2);
655        imprimir_erro_generico(&erro);
656    }
657}