1use crate::masking::mask;
9use crate::ssh::ExecutionOutput;
10use crate::vps::model::VpsRecord;
11use secrecy::ExposeSecret;
12use serde_json::json;
13use std::io::{self, Write};
14use std::sync::atomic::{AtomicBool, Ordering};
15
16static QUIET: AtomicBool = AtomicBool::new(false);
18
19static JSON_ERRORS: AtomicBool = AtomicBool::new(false);
21
22pub fn set_quiet(quiet: bool) {
24 QUIET.store(quiet, Ordering::SeqCst);
25}
26
27pub fn set_json_errors(json: bool) {
29 JSON_ERRORS.store(json, Ordering::SeqCst);
30}
31
32#[must_use]
34pub fn is_quiet() -> bool {
35 QUIET.load(Ordering::SeqCst)
36}
37
38#[must_use]
40pub fn wants_json_errors() -> bool {
41 JSON_ERRORS.load(Ordering::SeqCst)
42}
43
44pub fn write_line(content: &str) -> io::Result<()> {
52 let stdout = io::stdout();
53 let mut handle = stdout.lock();
54 handle.write_all(content.as_bytes())?;
55 handle.write_all(b"\n")?;
56 handle.flush()?;
57 Ok(())
58}
59
60pub fn print_success(message: &str) {
62 if is_quiet() {
63 return;
64 }
65 println!("{message}");
66}
67
68pub fn emit_success(
77 event: &str,
78 fields: serde_json::Value,
79 human: &str,
80 json: bool,
81) -> io::Result<()> {
82 if json {
83 let mut v = match fields {
84 serde_json::Value::Object(map) => serde_json::Value::Object(map),
85 other => json!({ "data": other }),
86 };
87 if let Some(obj) = v.as_object_mut() {
88 obj.insert("ok".into(), json!(true));
89 obj.insert("event".into(), json!(event));
90 }
91 print_json_value(&v)?;
92 } else {
93 print_success(human);
94 }
95 Ok(())
96}
97
98pub fn print_human_banner(message: &str) {
102 if is_quiet() || wants_json_errors() {
103 return;
104 }
105 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
106 return;
107 }
108 if std::env::var_os("SSH_CLI_FORCE_TEXT").is_none() {
109 }
111 println!("{message}");
112}
113
114pub fn print_error(message: &str) {
116 eprintln!("{message}");
117}
118
119pub fn print_error_envelope(
121 exit_code: i32,
122 message: &str,
123 remote_exit_code: Option<i32>,
124) -> io::Result<()> {
125 let mut v = json!({
126 "exit_code": exit_code,
127 "message": message,
128 });
129 if let Some(r) = remote_exit_code {
130 v["remote_exit_code"] = json!(r);
131 }
132 let s = serde_json::to_string(&v).unwrap_or_else(|_| {
133 format!(r#"{{"exit_code":{exit_code},"message":"serialization error"}}"#)
134 });
135 let mut err = io::stderr().lock();
136 err.write_all(s.as_bytes())?;
137 err.write_all(b"\n")?;
138 err.flush()?;
139 Ok(())
140}
141
142pub fn print_json_value(v: &serde_json::Value) -> io::Result<()> {
144 let s = serde_json::to_string_pretty(v).map_err(io::Error::other)?;
145 write_line(&s)
146}
147
148#[allow(clippy::too_many_arguments)]
150pub fn print_doctor_text(
151 layer: &str,
152 config_path: &str,
153 exists: bool,
154 perms: &str,
155 schema_version: u32,
156 hosts: usize,
157 known_hosts: &str,
158 active_file: &str,
159 secrets_at_rest: &str,
160 secrets_key_source: &str,
161 secrets_key_file: &str,
162 plaintext_opt_out: bool,
163) {
164 if is_quiet() {
165 return;
166 }
167 println!("Winning layer: {layer}");
168 println!("Config path: {config_path}");
169 println!("Exists: {exists}");
170 println!("Permissions: {perms}");
171 println!("Schema: {schema_version}");
172 println!("Hosts: {hosts}");
173 println!("known_hosts: {known_hosts}");
174 println!("active file: {active_file}");
175 println!("Secrets at-rest: {secrets_at_rest} (key source: {secrets_key_source})");
176 println!("Secrets key file: {secrets_key_file}");
177 println!(
178 "Plaintext opt-out: {}",
179 if plaintext_opt_out { "yes" } else { "no" }
180 );
181 println!("Telemetry: disabled");
182}
183
184pub fn print_list_text(records: &[VpsRecord]) {
186 if is_quiet() {
187 return;
188 }
189 if records.is_empty() {
190 println!(
191 "{}",
192 crate::i18n::t(crate::i18n::Message::VpsRegistryEmpty)
193 );
194 return;
195 }
196
197 println!(
198 "{:<20} {:<30} {:<6} {:<15} {:<20}",
199 "NAME", "HOST", "PORT", "USER", "PASSWORD"
200 );
201 for r in records {
202 println!(
203 "{:<20} {:<30} {:<6} {:<15} {:<20}",
204 r.name,
205 r.host,
206 r.port,
207 r.username,
208 mask(r.password.expose_secret())
209 );
210 }
211}
212
213pub fn print_list_json(records: &[VpsRecord]) {
215 let list: Vec<_> = records.iter().map(record_to_masked_json).collect();
216 match serde_json::to_string_pretty(&list) {
217 Ok(s) => println!("{s}"),
218 Err(err) => eprintln!("failed to serialize JSON: {err}"),
219 }
220}
221
222pub fn print_details_text(r: &VpsRecord) {
224 if is_quiet() {
225 return;
226 }
227 println!("Name: {}", r.name);
228 println!("Host: {}", r.host);
229 println!("Port: {}", r.port);
230 println!("User: {}", r.username);
231 println!(
233 "Password: {}",
234 if r.password.expose_secret().is_empty() {
235 "(not set)".to_string()
236 } else {
237 mask(r.password.expose_secret())
238 }
239 );
240 println!(
241 "Key path: {}",
242 r.key_path.as_deref().unwrap_or("(not set)")
243 );
244 println!(
245 "Sudo password: {}",
246 r.sudo_password
247 .as_ref()
248 .map_or_else(|| "(not set)".into(), |s| mask(s.expose_secret()))
249 );
250 println!(
251 "Su password: {}",
252 r.su_password
253 .as_ref()
254 .map_or_else(|| "(not set)".into(), |s| mask(s.expose_secret()))
255 );
256 println!("Timeout (ms): {}", r.timeout_ms);
257 println!("Max cmd chars: {}", r.max_command_chars);
258 println!("Max out chars: {}", r.max_output_chars);
259 println!("Disable sudo: {}", r.disable_sudo);
260 println!("Schema version: {}", r.schema_version);
261 println!("Added at: {}", r.added_at);
262}
263
264pub fn print_details_json(r: &VpsRecord) {
266 let v = record_to_masked_json(r);
267 match serde_json::to_string_pretty(&v) {
268 Ok(s) => println!("{s}"),
269 Err(err) => eprintln!("failed to serialize JSON: {err}"),
270 }
271}
272
273fn record_to_masked_json(r: &VpsRecord) -> serde_json::Value {
274 let password = if r.password.expose_secret().is_empty() {
276 json!(null)
277 } else {
278 json!(mask(r.password.expose_secret()))
279 };
280 json!({
281 "name": r.name,
282 "host": r.host,
283 "port": r.port,
284 "user": r.username,
285 "password": password,
286 "key_path": r.key_path,
287 "key_passphrase": r.key_passphrase.as_ref().map(|s| mask(s.expose_secret())),
288 "sudo_password": r.sudo_password.as_ref().map(|s| mask(s.expose_secret())),
289 "su_password": r.su_password.as_ref().map(|s| mask(s.expose_secret())),
290 "timeout_ms": r.timeout_ms,
291 "max_command_chars": r.max_command_chars,
292 "max_output_chars": r.max_output_chars,
293 "disable_sudo": r.disable_sudo,
294 "schema_version": r.schema_version,
295 "added_at": r.added_at,
296 })
297}
298
299pub fn export_hosts_to_json(
305 hosts: &std::collections::BTreeMap<String, VpsRecord>,
306 include_secrets: bool,
307) -> serde_json::Value {
308 let mut map = serde_json::Map::new();
309 for (name, r) in hosts {
310 let entry = if include_secrets {
311 json!({
312 "name": r.name,
313 "host": r.host,
314 "port": r.port,
315 "user": r.username,
316 "password": r.password.expose_secret(),
317 "key_path": r.key_path,
318 "key_passphrase": r.key_passphrase.as_ref().map(|s| s.expose_secret().to_string()),
319 "sudo_password": r.sudo_password.as_ref().map(|s| s.expose_secret().to_string()),
320 "su_password": r.su_password.as_ref().map(|s| s.expose_secret().to_string()),
321 "timeout_ms": r.timeout_ms,
322 "max_command_chars": r.max_command_chars,
323 "max_output_chars": r.max_output_chars,
324 "disable_sudo": r.disable_sudo,
325 "schema_version": r.schema_version,
326 "added_at": r.added_at,
327 })
328 } else {
329 json!({
331 "name": r.name,
332 "host": r.host,
333 "port": r.port,
334 "user": r.username,
335 "password": "",
336 "key_path": r.key_path,
337 "key_passphrase": null,
338 "sudo_password": null,
339 "su_password": null,
340 "timeout_ms": r.timeout_ms,
341 "max_command_chars": r.max_command_chars,
342 "max_output_chars": r.max_output_chars,
343 "disable_sudo": r.disable_sudo,
344 "schema_version": r.schema_version,
345 "added_at": r.added_at,
346 })
347 };
348 map.insert(name.clone(), entry);
349 }
350 serde_json::Value::Object(map)
351}
352
353pub fn print_execution_output(output: &ExecutionOutput) {
364 println!("--- stdout ---");
365 if output.stdout.is_empty() {
366 println!("(empty)");
367 } else {
368 println!("{}", output.stdout);
369 }
370 println!("--- stderr ---");
371 if output.stderr.is_empty() {
372 println!("(empty)");
373 } else {
374 println!("{}", output.stderr);
375 }
376 let code_str = output
377 .exit_code
378 .map(|c| c.to_string())
379 .unwrap_or_else(|| "N/A".to_string());
380 println!("--- exit code: {} ({}ms) ---", code_str, output.duration_ms);
381 if output.truncated_stdout {
382 println!("(stdout foi truncado)");
383 }
384 if output.truncated_stderr {
385 println!("(stderr foi truncado)");
386 }
387}
388
389pub fn print_execution_output_json(output: &ExecutionOutput) {
391 let v = json!({
392 "stdout": output.stdout,
393 "stderr": output.stderr,
394 "exit_code": output.exit_code,
395 "truncated_stdout": output.truncated_stdout,
396 "truncated_stderr": output.truncated_stderr,
397 "duration_ms": output.duration_ms,
398 });
399 match serde_json::to_string_pretty(&v) {
400 Ok(s) => println!("{s}"),
401 Err(e) => eprintln!("failed to serialize JSON: {e}"),
402 }
403}
404
405pub fn print_health_check(name: &str, latency_ms: u64) {
407 if is_quiet() {
408 return;
409 }
410 println!(
411 "{}",
412 crate::i18n::t(crate::i18n::Message::HealthCheckOk {
413 name: name.to_string(),
414 })
415 );
416 println!(" latência: {latency_ms}ms");
417}
418
419pub fn print_health_check_json(name: &str, latency_ms: u64) {
421 let v = json!({
422 "name": name,
423 "status": "ok",
424 "latency_ms": latency_ms,
425 });
426 match serde_json::to_string_pretty(&v) {
427 Ok(s) => println!("{s}"),
428 Err(e) => eprintln!("failed to serialize JSON: {e}"),
429 }
430}
431
432pub fn print_transfer_json(
434 direction: &str,
435 vps: &str,
436 local: &str,
437 remote: &str,
438 bytes: u64,
439 duration_ms: u64,
440) {
441 let v = json!({
443 "ok": true,
444 "event": "scp-transfer",
445 "direction": direction,
446 "vps": vps,
447 "local": local,
448 "remote": remote,
449 "bytes": bytes,
450 "duration_ms": duration_ms,
451 });
452 match serde_json::to_string_pretty(&v) {
453 Ok(s) => {
454 let _ = write_line(&s);
455 }
456 Err(e) => eprintln!("failed to serialize JSON: {e}"),
457 }
458}
459
460pub fn print_tunnel_listening_json(
462 vps: &str,
463 local_port: u16,
464 remote_host: &str,
465 remote_port: u16,
466 timeout_ms: u64,
467) {
468 let v = json!({
469 "ok": true,
470 "event": "tunnel_listening",
471 "vps": vps,
472 "local_port": local_port,
473 "remote_host": remote_host,
474 "remote_port": remote_port,
475 "timeout_ms": timeout_ms,
476 });
477 match serde_json::to_string_pretty(&v) {
478 Ok(s) => {
479 let _ = write_line(&s);
480 }
481 Err(e) => eprintln!("failed to serialize JSON: {e}"),
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::ssh::ExecutionOutput;
489 use crate::vps::model::VpsRecord;
490 use secrecy::SecretString;
491
492 fn registro_teste() -> VpsRecord {
493 VpsRecord::new(
494 "vps-teste".into(),
495 "1.2.3.4".into(),
496 22,
497 "root".into(),
498 SecretString::from("senha-super-secreta".to_string()),
499 None,
500 None,
501 Some(5000),
502 Some(1000),
503 Some(1000),
504 Some(SecretString::from("sudo-password-longa-aqui".to_string())),
505 None,
506 false,
507 )
508 }
509
510 #[test]
511 fn masked_json_contains_required_fields() {
512 let r = registro_teste();
513 let json = record_to_masked_json(&r);
514 assert_eq!(json["name"], "vps-teste");
515 assert_eq!(json["host"], "1.2.3.4");
516 assert_eq!(json["port"], 22);
517 assert_eq!(json["user"], "root");
518 assert_eq!(json["password"].as_str().unwrap(), "***");
519 assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
520 assert!(json["su_password"].is_null());
521 assert_eq!(json["timeout_ms"], 5000);
522 assert_eq!(json["max_command_chars"], 1000);
523 assert_eq!(json["max_output_chars"], 1000);
524 assert_eq!(json["schema_version"], 3);
525 }
526
527 #[test]
528 fn masked_json_sudo_null_when_unset() {
529 let mut r = registro_teste();
530 r.sudo_password = None;
531 let json = record_to_masked_json(&r);
532 assert!(json["sudo_password"].is_null());
533 }
534
535 #[test]
536 fn masked_json_su_password_present() {
537 let mut r = registro_teste();
538 r.su_password = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
539 let json = record_to_masked_json(&r);
540 assert_eq!(json["su_password"].as_str().unwrap(), "***");
541 }
542
543 #[test]
544 fn masked_json_password_null_when_empty() {
545 let mut r = registro_teste();
546 r.password = SecretString::from(String::new());
547 let json = record_to_masked_json(&r);
548 assert!(json["password"].is_null());
549 }
550
551 #[test]
552 fn write_line_ok() {
553 let result = write_line("write test");
554 assert!(result.is_ok());
555 }
556
557 #[test]
558 fn write_line_special_chars() {
559 let result = write_line("line with \t tab and \"quotes\"");
560 assert!(result.is_ok());
561 }
562
563 #[test]
564 fn execution_output_fully_formatted() {
565 let output = ExecutionOutput {
566 stdout: "output do comando".to_string(),
567 stderr: "command error".to_string(),
568 exit_code: Some(0),
569 truncated_stdout: false,
570 truncated_stderr: false,
571 duration_ms: 150,
572 };
573 let result = write_line(&format!(
574 "stdout: {}, stderr: {}, exit: {:?}",
575 output.stdout, output.stderr, output.exit_code
576 ));
577 assert!(result.is_ok());
578 }
579
580 #[test]
581 fn execution_output_without_exit_code() {
582 let output = ExecutionOutput {
583 stdout: "".to_string(),
584 stderr: "".to_string(),
585 exit_code: None,
586 truncated_stdout: false,
587 truncated_stderr: false,
588 duration_ms: 0,
589 };
590 let code_str = output
591 .exit_code
592 .map(|c| c.to_string())
593 .unwrap_or_else(|| "N/A".to_string());
594 assert_eq!(code_str, "N/A");
595 }
596
597 #[test]
598 fn vps_record_debug_does_not_expose_password() {
599 let r = registro_teste();
600 let json = record_to_masked_json(&r);
601 let json_str = serde_json::to_string(&json).unwrap();
602 assert!(!json_str.contains("senha-super-secreta"));
603 assert!(!json_str.contains("sudo-password-longa-aqui"));
604 }
605
606 #[test]
607 fn execution_output_truncated_shows_warning() {
608 let output = ExecutionOutput {
609 stdout: "output".to_string(),
610 stderr: "error".to_string(),
611 exit_code: Some(1),
612 truncated_stdout: true,
613 truncated_stderr: true,
614 duration_ms: 100,
615 };
616 assert!(output.truncated_stdout);
617 assert!(output.truncated_stderr);
618 }
619
620 #[test]
621 fn execution_output_numeric_exit_code() {
622 let output = ExecutionOutput {
623 stdout: "".to_string(),
624 stderr: "".to_string(),
625 exit_code: Some(127),
626 truncated_stdout: false,
627 truncated_stderr: false,
628 duration_ms: 0,
629 };
630 let code_str = output
631 .exit_code
632 .map(|c| c.to_string())
633 .unwrap_or_else(|| "N/A".to_string());
634 assert_eq!(code_str, "127");
635 }
636
637 #[test]
638 fn write_line_empty_string() {
639 let result = write_line("");
640 assert!(result.is_ok());
641 }
642
643 #[test]
644 fn write_line_brazilian_unicode() {
645 let result = write_line("ação você está Itaú");
646 assert!(result.is_ok());
647 }
648
649 #[test]
650 fn write_line_with_emojis() {
651 let result = write_line("texto com 🚀 e 🔐");
652 assert!(result.is_ok());
653 }
654
655 #[test]
656 fn write_line_with_newlines() {
657 let result = write_line("linha1\nlinha2\nlinha3");
658 assert!(result.is_ok());
659 }
660
661 #[test]
662 fn write_line_long_text() {
663 let long_text = "a".repeat(10000);
664 let result = write_line(&long_text);
665 assert!(result.is_ok());
666 }
667
668 #[test]
669 fn masked_json_short_password_asterisks() {
670 let mut r = registro_teste();
671 r.password = SecretString::from("curta".to_string());
672 let json = record_to_masked_json(&r);
673 let password_str = json["password"].as_str().unwrap();
674 assert_eq!(password_str, "***");
675 }
676
677 #[test]
678 fn masked_json_with_sudo_and_su_set() {
679 let mut r = registro_teste();
680 r.sudo_password = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
681 r.su_password = Some(SecretString::from("su-pass-longa-aqui".to_string()));
682 let json = record_to_masked_json(&r);
683 assert!(!json["sudo_password"].is_null());
684 assert!(!json["su_password"].is_null());
685 assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
686 assert_eq!(json["su_password"].as_str().unwrap(), "***");
687 }
688
689 #[test]
690 fn execution_output_full_formatting() {
691 let output = ExecutionOutput {
692 stdout: "comando executado".to_string(),
693 stderr: "aviso harmless".to_string(),
694 exit_code: Some(0),
695 truncated_stdout: false,
696 truncated_stderr: false,
697 duration_ms: 1000,
698 };
699 assert_eq!(output.stdout, "comando executado");
700 assert_eq!(output.stderr, "aviso harmless");
701 assert_eq!(output.exit_code, Some(0));
702 assert_eq!(output.duration_ms, 1000);
703 assert!(!output.truncated_stdout);
704 assert!(!output.truncated_stderr);
705 }
706
707 #[test]
708 fn execution_output_without_stderr() {
709 let output = ExecutionOutput {
710 stdout: "ok".to_string(),
711 stderr: String::new(),
712 exit_code: Some(0),
713 truncated_stdout: false,
714 truncated_stderr: false,
715 duration_ms: 50,
716 };
717 assert!(output.stderr.is_empty());
718 }
719
720 #[test]
721 fn execution_output_signal_instead_of_exit() {
722 let output = ExecutionOutput {
723 stdout: String::new(),
724 stderr: "signal received".to_string(),
725 exit_code: None,
726 truncated_stdout: false,
727 truncated_stderr: false,
728 duration_ms: 5000,
729 };
730 assert!(output.exit_code.is_none());
731 }
732
733 #[test]
734 fn execution_output_json_required_fields() {
735 let output = ExecutionOutput {
736 stdout: "output".to_string(),
737 stderr: "error".to_string(),
738 exit_code: Some(0),
739 truncated_stdout: false,
740 truncated_stderr: false,
741 duration_ms: 100,
742 };
743 print_execution_output_json(&output);
744 }
745}