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