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 lista de VPS em formato texto (mascarado).
38pub fn imprimir_lista_texto(registros: &[VpsRegistro]) {
39    if registros.is_empty() {
40        println!(
41            "{}",
42            crate::i18n::t(crate::i18n::Mensagem::VpsRegistroVazio)
43        );
44        return;
45    }
46
47    println!(
48        "{:<20} {:<30} {:<6} {:<15} {:<20}",
49        "NOME", "HOST", "PORTA", "USUÁRIO", "SENHA"
50    );
51    for r in registros {
52        println!(
53            "{:<20} {:<30} {:<6} {:<15} {:<20}",
54            r.nome,
55            r.host,
56            r.porta,
57            r.usuario,
58            mascarar(r.senha.expose_secret())
59        );
60    }
61}
62
63/// Imprime lista de VPS em formato JSON (mascarado).
64pub fn imprimir_lista_json(registros: &[VpsRegistro]) {
65    let lista: Vec<_> = registros.iter().map(registro_para_json_mascarado).collect();
66    match serde_json::to_string_pretty(&lista) {
67        Ok(s) => println!("{s}"),
68        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
69    }
70}
71
72/// Imprime detalhes de UMA VPS em texto (mascarado).
73pub fn imprimir_detalhes_texto(r: &VpsRegistro) {
74    println!("Nome:           {}", r.nome);
75    println!("Host:           {}", r.host);
76    println!("Porta:          {}", r.porta);
77    println!("Usuário:        {}", r.usuario);
78    println!("Senha:          {}", mascarar(r.senha.expose_secret()));
79    println!(
80        "Senha sudo:     {}",
81        r.senha_sudo
82            .as_ref()
83            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
84    );
85    println!(
86        "Senha su:       {}",
87        r.senha_su
88            .as_ref()
89            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
90    );
91    println!("Timeout (ms):   {}", r.timeout_ms);
92    println!("Max chars:      {}", r.max_chars);
93    println!("Schema version: {}", r.schema_version);
94    println!("Adicionado em:  {}", r.adicionado_em);
95}
96
97/// Imprime detalhes de UMA VPS em JSON (mascarado).
98pub fn imprimir_detalhes_json(r: &VpsRegistro) {
99    let v = registro_para_json_mascarado(r);
100    match serde_json::to_string_pretty(&v) {
101        Ok(s) => println!("{s}"),
102        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
103    }
104}
105
106fn registro_para_json_mascarado(r: &VpsRegistro) -> serde_json::Value {
107    json!({
108        "name": r.nome,
109        "host": r.host,
110        "port": r.porta,
111        "user": r.usuario,
112        "password": mascarar(r.senha.expose_secret()),
113        "sudo_password": r.senha_sudo.as_ref().map(|s| mascarar(s.expose_secret())),
114        "su_password": r.senha_su.as_ref().map(|s| mascarar(s.expose_secret())),
115        "timeout_ms": r.timeout_ms,
116        "max_chars": r.max_chars,
117        "schema_version": r.schema_version,
118        "added_at": r.adicionado_em,
119    })
120}
121
122/// Imprime stdout/stderr de execução de comando SSH.
123///
124/// Formato:
125/// ```text
126/// --- stdout ---
127/// <stdout>
128/// --- stderr ---
129/// <stderr>
130/// --- exit code: <code> (<duracao_ms>ms) ---
131/// ```
132pub fn imprimir_saida_execucao(saida: &SaidaExecucao) {
133    println!("--- stdout ---");
134    if saida.stdout.is_empty() {
135        println!("(vazio)");
136    } else {
137        println!("{}", saida.stdout);
138    }
139    println!("--- stderr ---");
140    if saida.stderr.is_empty() {
141        println!("(vazio)");
142    } else {
143        println!("{}", saida.stderr);
144    }
145    let code_str = saida
146        .exit_code
147        .map(|c| c.to_string())
148        .unwrap_or_else(|| "N/A".to_string());
149    println!("--- exit code: {} ({}ms) ---", code_str, saida.duracao_ms);
150    if saida.truncado_stdout {
151        println!("(stdout foi truncado)");
152    }
153    if saida.truncado_stderr {
154        println!("(stderr foi truncado)");
155    }
156}
157
158/// Imprime stdout/stderr de execução de comando SSH em formato JSON.
159pub fn imprimir_saida_execucao_json(saida: &SaidaExecucao) {
160    let v = json!({
161        "stdout": saida.stdout,
162        "stderr": saida.stderr,
163        "exit_code": saida.exit_code,
164        "truncated_stdout": saida.truncado_stdout,
165        "truncated_stderr": saida.truncado_stderr,
166        "duration_ms": saida.duracao_ms,
167    });
168    match serde_json::to_string_pretty(&v) {
169        Ok(s) => println!("{s}"),
170        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
171    }
172}
173
174/// Imprime resultado de health-check em formato texto.
175pub fn imprimir_health_check(nome: &str, latencia_ms: u64) {
176    println!(
177        "{}",
178        crate::i18n::t(crate::i18n::Mensagem::HealthCheckOk {
179            nome: nome.to_string(),
180        })
181    );
182    println!("  latência: {latencia_ms}ms");
183}
184
185/// Imprime resultado de health-check em formato JSON.
186pub fn imprimir_health_check_json(nome: &str, latencia_ms: u64) {
187    let v = json!({
188        "name": nome,
189        "status": "ok",
190        "latency_ms": latencia_ms,
191    });
192    match serde_json::to_string_pretty(&v) {
193        Ok(s) => println!("{s}"),
194        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
195    }
196}
197
198#[cfg(test)]
199mod testes {
200    use super::*;
201    use crate::ssh::SaidaExecucao;
202    use crate::vps::modelo::VpsRegistro;
203    use secrecy::SecretString;
204
205    fn registro_teste() -> VpsRegistro {
206        VpsRegistro::novo(
207            "vps-teste".into(),
208            "1.2.3.4".into(),
209            22,
210            "root".into(),
211            SecretString::from("senha-super-secreta".to_string()),
212            Some(5000),
213            Some(1000),
214            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
215            None,
216        )
217    }
218
219    #[test]
220    fn registro_para_json_mascarado_contem_campos_obrigatorios() {
221        let r = registro_teste();
222        let json = registro_para_json_mascarado(&r);
223        assert_eq!(json["name"], "vps-teste");
224        assert_eq!(json["host"], "1.2.3.4");
225        assert_eq!(json["port"], 22);
226        assert_eq!(json["user"], "root");
227        assert!(json["password"].as_str().unwrap().contains("..."));
228        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
229        assert!(json["su_password"].is_null());
230        assert_eq!(json["timeout_ms"], 5000);
231        assert_eq!(json["max_chars"], 1000);
232        assert_eq!(json["schema_version"], 1);
233    }
234
235    #[test]
236    fn registro_para_json_mascarado_senha_sudo_nula_quando_nao_definida() {
237        let mut r = registro_teste();
238        r.senha_sudo = None;
239        let json = registro_para_json_mascarado(&r);
240        assert!(json["sudo_password"].is_null());
241    }
242
243    #[test]
244    fn registro_para_json_mascarado_su_password_presente() {
245        let mut r = registro_teste();
246        r.senha_su = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
247        let json = registro_para_json_mascarado(&r);
248        assert!(json["su_password"].as_str().unwrap().contains("..."));
249    }
250
251    #[test]
252    fn escribir_linha_ok() {
253        let resultado = escrever_linha("teste de escrita");
254        assert!(resultado.is_ok());
255    }
256
257    #[test]
258    fn escribir_linha_com_caracteres_especiais() {
259        let resultado = escrever_linha("linha com \t tab e \"aspas\"");
260        assert!(resultado.is_ok());
261    }
262
263    #[test]
264    fn salida_execucao_completa_formatada() {
265        let saida = SaidaExecucao {
266            stdout: "output do comando".to_string(),
267            stderr: "erro do comando".to_string(),
268            exit_code: Some(0),
269            truncado_stdout: false,
270            truncado_stderr: false,
271            duracao_ms: 150,
272        };
273        let resultado = escrever_linha(&format!(
274            "stdout: {}, stderr: {}, exit: {:?}",
275            saida.stdout, saida.stderr, saida.exit_code
276        ));
277        assert!(resultado.is_ok());
278    }
279
280    #[test]
281    fn salida_execucao_sem_exit_code() {
282        let saida = SaidaExecucao {
283            stdout: "".to_string(),
284            stderr: "".to_string(),
285            exit_code: None,
286            truncado_stdout: false,
287            truncado_stderr: false,
288            duracao_ms: 0,
289        };
290        let code_str = saida
291            .exit_code
292            .map(|c| c.to_string())
293            .unwrap_or_else(|| "N/A".to_string());
294        assert_eq!(code_str, "N/A");
295    }
296
297    #[test]
298    fn vps_registro_debug_nao_expoe_senha() {
299        let r = registro_teste();
300        let json = registro_para_json_mascarado(&r);
301        let json_str = serde_json::to_string(&json).unwrap();
302        assert!(!json_str.contains("senha-super-secreta"));
303        assert!(!json_str.contains("sudo-password-longa-aqui"));
304    }
305
306    #[test]
307    fn salida_execucao_truncada_mostra_aviso() {
308        let saida = SaidaExecucao {
309            stdout: "output".to_string(),
310            stderr: "erro".to_string(),
311            exit_code: Some(1),
312            truncado_stdout: true,
313            truncado_stderr: true,
314            duracao_ms: 100,
315        };
316        assert!(saida.truncado_stdout);
317        assert!(saida.truncado_stderr);
318    }
319
320    #[test]
321    fn salida_execucao_com_exit_code_numerico() {
322        let saida = SaidaExecucao {
323            stdout: "".to_string(),
324            stderr: "".to_string(),
325            exit_code: Some(127),
326            truncado_stdout: false,
327            truncado_stderr: false,
328            duracao_ms: 0,
329        };
330        let code_str = saida
331            .exit_code
332            .map(|c| c.to_string())
333            .unwrap_or_else(|| "N/A".to_string());
334        assert_eq!(code_str, "127");
335    }
336
337    #[test]
338    fn escribir_linha_string_vazia() {
339        let resultado = escrever_linha("");
340        assert!(resultado.is_ok());
341    }
342
343    #[test]
344    fn escribir_linha_com_unicode_brasileiro() {
345        let resultado = escrever_linha("ação você está Itaú");
346        assert!(resultado.is_ok());
347    }
348
349    #[test]
350    fn escribir_linha_com_emojis() {
351        let resultado = escrever_linha("texto com 🚀 e 🔐");
352        assert!(resultado.is_ok());
353    }
354
355    #[test]
356    fn escribir_linha_com_newlines() {
357        let resultado = escrever_linha("linha1\nlinha2\nlinha3");
358        assert!(resultado.is_ok());
359    }
360
361    #[test]
362    fn escribir_linha_longo_texto() {
363        let texto_longo = "a".repeat(10000);
364        let resultado = escrever_linha(&texto_longo);
365        assert!(resultado.is_ok());
366    }
367
368    #[test]
369    fn registro_para_json_mascarado_com_senha_curta_mascara_com_asteriscos() {
370        let mut r = registro_teste();
371        r.senha = SecretString::from("curta".to_string());
372        let json = registro_para_json_mascarado(&r);
373        let senha_str = json["password"].as_str().unwrap();
374        assert_eq!(senha_str, "***");
375    }
376
377    #[test]
378    fn registro_para_json_mascarado_com_sudo_e_su_definidos() {
379        let mut r = registro_teste();
380        r.senha_sudo = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
381        r.senha_su = Some(SecretString::from("su-pass-longa-aqui".to_string()));
382        let json = registro_para_json_mascarado(&r);
383        assert!(!json["sudo_password"].is_null());
384        assert!(!json["su_password"].is_null());
385        assert!(json["sudo_password"].as_str().unwrap().contains("..."));
386        assert!(json["su_password"].as_str().unwrap().contains("..."));
387    }
388
389    #[test]
390    fn saida_execucao_formatacao_completa() {
391        let saida = SaidaExecucao {
392            stdout: "comando executado".to_string(),
393            stderr: "aviso harmless".to_string(),
394            exit_code: Some(0),
395            truncado_stdout: false,
396            truncado_stderr: false,
397            duracao_ms: 1000,
398        };
399        assert_eq!(saida.stdout, "comando executado");
400        assert_eq!(saida.stderr, "aviso harmless");
401        assert_eq!(saida.exit_code, Some(0));
402        assert_eq!(saida.duracao_ms, 1000);
403        assert!(!saida.truncado_stdout);
404        assert!(!saida.truncado_stderr);
405    }
406
407    #[test]
408    fn saida_execucao_sem_stderr() {
409        let saida = SaidaExecucao {
410            stdout: "ok".to_string(),
411            stderr: String::new(),
412            exit_code: Some(0),
413            truncado_stdout: false,
414            truncado_stderr: false,
415            duracao_ms: 50,
416        };
417        assert!(saida.stderr.is_empty());
418    }
419
420    #[test]
421    fn saida_execucao_com_sinal_em_vez_de_exit_code() {
422        let saida = SaidaExecucao {
423            stdout: String::new(),
424            stderr: "signal received".to_string(),
425            exit_code: None,
426            truncado_stdout: false,
427            truncado_stderr: false,
428            duracao_ms: 5000,
429        };
430        assert!(saida.exit_code.is_none());
431    }
432
433    #[test]
434    fn saida_execucao_json_contem_campos_obrigatorios() {
435        let saida = SaidaExecucao {
436            stdout: "output".to_string(),
437            stderr: "erro".to_string(),
438            exit_code: Some(0),
439            truncado_stdout: false,
440            truncado_stderr: false,
441            duracao_ms: 100,
442        };
443        imprimir_saida_execucao_json(&saida);
444    }
445}