1use anyhow::Result;
14use clap::{Parser, Subcommand};
15use clap_complete::Shell;
16use std::path::PathBuf;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
20pub enum FormatoSaida {
21 #[default]
23 Text,
24 Json,
26}
27
28#[derive(Debug, Parser)]
30#[command(
31 name = "ssh-cli",
32 version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
33 about = "CLI Rust para LLMs operarem servidores via SSH.",
34 long_about = None,
35)]
36pub struct Argumentos {
37 #[arg(long, global = true, value_name = "LOCALE")]
39 pub lang: Option<String>,
40
41 #[arg(short, long, global = true)]
43 pub verbose: bool,
44
45 #[arg(short, long, global = true)]
47 pub quiet: bool,
48
49 #[arg(long, global = true, value_name = "DIR")]
51 pub config_dir: Option<PathBuf>,
52
53 #[arg(long, global = true)]
55 pub no_color: bool,
56
57 #[arg(long, global = true, value_enum, default_value_t = FormatoSaida::Text)]
59 pub output_format: FormatoSaida,
60
61 #[command(subcommand)]
63 pub comando: Comando,
64}
65
66#[derive(Debug, Subcommand)]
68pub enum Comando {
69 Vps {
71 #[command(subcommand)]
73 acao: AcaoVps,
74 },
75
76 Connect {
78 nome: String,
80 },
81
82 Exec {
84 vps_nome: String,
86
87 comando: String,
89
90 #[arg(long)]
92 json: bool,
93
94 #[arg(long)]
96 password: Option<String>,
97
98 #[arg(long)]
100 timeout: Option<u64>,
101 },
102
103 SudoExec {
105 vps_nome: String,
107
108 comando: String,
110
111 #[arg(long)]
113 json: bool,
114
115 #[arg(long)]
117 password: Option<String>,
118
119 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
121 sudo_password: Option<String>,
122
123 #[arg(long)]
125 timeout: Option<u64>,
126 },
127
128 Scp {
130 #[command(subcommand)]
132 acao: AcaoScp,
133 },
134
135 Tunnel {
137 vps_nome: String,
139
140 porta_local: u16,
142
143 host_remoto: String,
145
146 porta_remota: u16,
148
149 #[arg(long)]
151 password: Option<String>,
152 },
153
154 HealthCheck {
156 vps_nome: Option<String>,
158
159 #[arg(long)]
161 password: Option<String>,
162 },
163
164 Completions {
166 #[arg(value_enum)]
168 shell: Shell,
169 },
170}
171
172#[derive(Debug, Subcommand)]
174pub enum AcaoVps {
175 Add {
177 #[arg(long)]
179 name: String,
180
181 #[arg(long)]
183 host: String,
184
185 #[arg(long, default_value_t = 22)]
187 port: u16,
188
189 #[arg(long)]
191 user: String,
192
193 #[arg(long)]
195 password: Option<String>,
196
197 #[arg(long, default_value_t = 30_000)]
199 timeout: u64,
200
201 #[arg(long, default_value = "100000", alias = "maxChars")]
203 max_chars: String,
204
205 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
207 sudo_password: Option<String>,
208
209 #[arg(long, alias = "suPassword", alias = "su_password")]
211 su_password: Option<String>,
212 },
213
214 List {
216 #[arg(long)]
218 json: bool,
219 },
220
221 Remove {
223 nome: String,
225
226 #[arg(long, short = 'y')]
228 yes: bool,
229 },
230
231 Edit {
233 nome: String,
235
236 #[arg(long)]
238 host: Option<String>,
239
240 #[arg(long)]
242 port: Option<u16>,
243
244 #[arg(long)]
246 user: Option<String>,
247
248 #[arg(long)]
250 password: Option<String>,
251
252 #[arg(long)]
254 timeout: Option<u64>,
255
256 #[arg(long, alias = "maxChars")]
258 max_chars: Option<String>,
259
260 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
262 sudo_password: Option<String>,
263
264 #[arg(long, alias = "suPassword", alias = "su_password")]
266 su_password: Option<String>,
267 },
268
269 Show {
271 nome: String,
273
274 #[arg(long)]
276 json: bool,
277 },
278
279 Path,
281}
282
283#[derive(Debug, Subcommand)]
285pub enum AcaoScp {
286 Upload {
288 vps_nome: String,
290
291 local: PathBuf,
293
294 remote: PathBuf,
296
297 #[arg(long)]
299 password: Option<String>,
300 },
301
302 Download {
304 vps_nome: String,
306
307 remote: PathBuf,
309
310 local: PathBuf,
312
313 #[arg(long)]
315 password: Option<String>,
316 },
317}
318
319#[must_use]
321pub fn parse_args() -> Argumentos {
322 Argumentos::parse()
323}
324
325pub fn inicializar_logs(args: &Argumentos) {
327 use tracing_subscriber::{fmt, EnvFilter};
328
329 let filter = if std::env::var("RUST_LOG").is_ok() {
330 EnvFilter::from_default_env()
331 } else if args.verbose {
332 EnvFilter::new("debug")
333 } else if args.quiet {
334 EnvFilter::new("error")
335 } else {
336 EnvFilter::new("info")
337 };
338
339 let _ = fmt()
340 .with_env_filter(filter)
341 .with_writer(std::io::stderr)
342 .with_target(false)
343 .with_ansi(false)
344 .try_init();
345}
346
347pub fn gerar_completions(shell: Shell) {
349 use clap::CommandFactory;
350 let mut cmd = Argumentos::command();
351 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
352}
353
354pub async fn executar(args: Argumentos) -> Result<()> {
356 let config_override = args.config_dir.clone();
357 let formato = args.output_format;
358
359 match args.comando {
360 Comando::Vps { acao } => {
361 crate::vps::executar_comando_vps(acao, config_override, formato).await
362 }
363 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
364 Comando::Exec {
365 vps_nome,
366 comando,
367 json,
368 password,
369 timeout,
370 } => {
371 crate::vps::executar_exec(
372 &vps_nome,
373 &comando,
374 config_override,
375 formato,
376 json,
377 password,
378 timeout,
379 )
380 .await
381 }
382 Comando::SudoExec {
383 vps_nome,
384 comando,
385 json,
386 password,
387 sudo_password,
388 timeout,
389 } => {
390 crate::vps::executar_sudo_exec(
391 &vps_nome,
392 &comando,
393 config_override,
394 formato,
395 json,
396 password,
397 sudo_password,
398 timeout,
399 )
400 .await
401 }
402 Comando::Scp { acao } => {
403 let pwd = match &acao {
404 AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
405 password.clone()
406 }
407 };
408 crate::scp::executar_scp(acao, config_override, pwd).await
409 }
410 Comando::Tunnel {
411 vps_nome,
412 porta_local,
413 host_remoto,
414 porta_remota,
415 password,
416 } => {
417 crate::tunnel::executar_tunnel(
418 &vps_nome,
419 porta_local,
420 &host_remoto,
421 porta_remota,
422 config_override,
423 password,
424 )
425 .await
426 }
427 Comando::HealthCheck { vps_nome, password } => {
428 crate::vps::executar_health_check(
429 vps_nome.as_deref(),
430 config_override,
431 formato,
432 password,
433 )
434 .await
435 }
436 Comando::Completions { shell } => {
437 gerar_completions(shell);
438 Ok(())
439 }
440 }
441}
442
443#[cfg(test)]
444mod testes {
445 use super::*;
446 use clap::Parser;
447 use serial_test::serial;
448 use tempfile::TempDir;
449
450 fn argumentos_teste(comando: Comando, config_dir: Option<PathBuf>) -> Argumentos {
451 Argumentos {
452 lang: None,
453 verbose: false,
454 quiet: false,
455 config_dir,
456 no_color: false,
457 output_format: FormatoSaida::Text,
458 comando,
459 }
460 }
461
462 #[test]
463 fn parser_entende_tunnel() {
464 let args =
465 Argumentos::try_parse_from(["ssh-cli", "tunnel", "vps-a", "8080", "127.0.0.1", "5432"])
466 .expect("parser deve aceitar subcomando tunnel");
467
468 match args.comando {
469 Comando::Tunnel {
470 vps_nome,
471 porta_local,
472 host_remoto,
473 porta_remota,
474 ..
475 } => {
476 assert_eq!(vps_nome, "vps-a");
477 assert_eq!(porta_local, 8080);
478 assert_eq!(host_remoto, "127.0.0.1");
479 assert_eq!(porta_remota, 5432);
480 }
481 outro => panic!("comando inesperado: {outro:?}"),
482 }
483 }
484
485 #[test]
486 fn parser_entende_scp_upload() {
487 let args = Argumentos::try_parse_from([
488 "ssh-cli",
489 "scp",
490 "upload",
491 "vps-a",
492 "./arquivo-local.txt",
493 "/tmp/arquivo-remoto.txt",
494 ])
495 .expect("parser deve aceitar scp upload");
496
497 match args.comando {
498 Comando::Scp {
499 acao:
500 AcaoScp::Upload {
501 vps_nome,
502 local,
503 remote,
504 ..
505 },
506 } => {
507 assert_eq!(vps_nome, "vps-a");
508 assert_eq!(local, PathBuf::from("./arquivo-local.txt"));
509 assert_eq!(remote, PathBuf::from("/tmp/arquivo-remoto.txt"));
510 }
511 outro => panic!("comando inesperado: {outro:?}"),
512 }
513 }
514
515 #[test]
516 #[serial]
517 fn inicializar_logs_sem_panic_com_rust_log_definido() {
518 std::env::set_var("RUST_LOG", "trace");
519 let args = argumentos_teste(
520 Comando::Connect {
521 nome: "vps-a".to_string(),
522 },
523 None,
524 );
525 inicializar_logs(&args);
526 std::env::remove_var("RUST_LOG");
527 }
528
529 #[test]
530 #[serial]
531 fn inicializar_logs_sem_panic_com_verbose() {
532 std::env::remove_var("RUST_LOG");
533 let mut args = argumentos_teste(
534 Comando::Connect {
535 nome: "vps-a".to_string(),
536 },
537 None,
538 );
539 args.verbose = true;
540 inicializar_logs(&args);
541 }
542
543 #[test]
544 #[serial]
545 fn inicializar_logs_sem_panic_com_quiet() {
546 std::env::remove_var("RUST_LOG");
547 let mut args = argumentos_teste(
548 Comando::Connect {
549 nome: "vps-a".to_string(),
550 },
551 None,
552 );
553 args.quiet = true;
554 inicializar_logs(&args);
555 }
556
557 #[test]
558 #[serial]
559 fn inicializar_logs_sem_panic_no_padrao_info() {
560 std::env::remove_var("RUST_LOG");
561 let args = argumentos_teste(
562 Comando::Connect {
563 nome: "vps-a".to_string(),
564 },
565 None,
566 );
567 inicializar_logs(&args);
568 }
569
570 #[tokio::test]
571 async fn executar_branch_exec_retorna_erro_para_vps_inexistente() {
572 let tmp = TempDir::new().expect("tempdir");
573 let args = argumentos_teste(
574 Comando::Exec {
575 vps_nome: "inexistente".to_string(),
576 comando: "echo ok".to_string(),
577 json: false,
578 password: None,
579 timeout: None,
580 },
581 Some(tmp.path().to_path_buf()),
582 );
583
584 let resultado = executar(args).await;
585 assert!(resultado.is_err());
586 }
587
588 #[tokio::test]
589 async fn executar_branch_sudo_exec_retorna_erro_para_vps_inexistente() {
590 let tmp = TempDir::new().expect("tempdir");
591 let args = argumentos_teste(
592 Comando::SudoExec {
593 vps_nome: "inexistente".to_string(),
594 comando: "id".to_string(),
595 json: false,
596 password: None,
597 sudo_password: None,
598 timeout: None,
599 },
600 Some(tmp.path().to_path_buf()),
601 );
602
603 let resultado = executar(args).await;
604 assert!(resultado.is_err());
605 }
606
607 #[tokio::test]
608 async fn executar_branch_scp_retorna_erro_para_vps_inexistente() {
609 let tmp = TempDir::new().expect("tempdir");
610 let args = argumentos_teste(
611 Comando::Scp {
612 acao: AcaoScp::Upload {
613 vps_nome: "inexistente".to_string(),
614 local: PathBuf::from("./arquivo-local.txt"),
615 remote: PathBuf::from("/tmp/arquivo-remoto.txt"),
616 password: None,
617 },
618 },
619 Some(tmp.path().to_path_buf()),
620 );
621
622 let resultado = executar(args).await;
623 assert!(resultado.is_err());
624 }
625
626 #[tokio::test]
627 async fn executar_branch_tunnel_retorna_erro_para_vps_inexistente() {
628 let tmp = TempDir::new().expect("tempdir");
629 let args = argumentos_teste(
630 Comando::Tunnel {
631 vps_nome: "inexistente".to_string(),
632 porta_local: 38080,
633 host_remoto: "127.0.0.1".to_string(),
634 porta_remota: 5432,
635 password: None,
636 },
637 Some(tmp.path().to_path_buf()),
638 );
639
640 let resultado = executar(args).await;
641 assert!(resultado.is_err());
642 }
643
644 #[test]
645 fn test_parse_no_color() {
646 let args = Argumentos::try_parse_from(["ssh-cli", "--no-color", "vps", "list"])
647 .expect("parser deve aceitar --no-color");
648 assert!(args.no_color);
649 }
650
651 #[test]
652 fn test_parse_output_format_json() {
653 let args =
654 Argumentos::try_parse_from(["ssh-cli", "--output-format", "json", "vps", "list"])
655 .expect("parser deve aceitar --output-format json");
656 assert_eq!(args.output_format, FormatoSaida::Json);
657 }
658
659 #[test]
660 fn test_parse_output_format_default() {
661 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "list"])
662 .expect("parser deve aceitar subcomando sem output-format");
663 assert_eq!(args.output_format, FormatoSaida::Text);
664 }
665
666 #[test]
667 fn test_parse_completions_bash() {
668 let args = Argumentos::try_parse_from(["ssh-cli", "completions", "bash"])
669 .expect("parser deve aceitar completions bash");
670 assert!(matches!(
671 args.comando,
672 Comando::Completions { shell: Shell::Bash }
673 ));
674 }
675
676 #[test]
677 fn test_parse_health_check_com_nome() {
678 let args = Argumentos::try_parse_from(["ssh-cli", "health-check", "meu-vps"])
679 .expect("parser deve aceitar health-check com nome");
680 match args.comando {
681 Comando::HealthCheck { vps_nome, .. } => {
682 assert_eq!(vps_nome, Some("meu-vps".to_string()));
683 }
684 outro => panic!("comando inesperado: {outro:?}"),
685 }
686 }
687
688 #[test]
689 fn test_parse_health_check_sem_nome() {
690 let args = Argumentos::try_parse_from(["ssh-cli", "health-check"])
691 .expect("parser deve aceitar health-check sem nome");
692 match args.comando {
693 Comando::HealthCheck { vps_nome, .. } => {
694 assert!(vps_nome.is_none());
695 }
696 outro => panic!("comando inesperado: {outro:?}"),
697 }
698 }
699
700 #[test]
701 fn test_parse_exec_json() {
702 let args = Argumentos::try_parse_from(["ssh-cli", "exec", "vps1", "ls", "--json"])
703 .expect("parser deve aceitar exec com --json");
704 match args.comando {
705 Comando::Exec {
706 vps_nome,
707 comando,
708 json,
709 ..
710 } => {
711 assert_eq!(vps_nome, "vps1");
712 assert_eq!(comando, "ls");
713 assert!(json);
714 }
715 outro => panic!("comando inesperado: {outro:?}"),
716 }
717 }
718
719 #[test]
720 fn exec_com_password_override() {
721 let args =
722 Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--password", "abc123"])
723 .expect("parser deve aceitar exec com --password");
724 match args.comando {
725 Comando::Exec {
726 vps_nome, password, ..
727 } => {
728 assert_eq!(vps_nome, "myvps");
729 assert_eq!(password, Some("abc123".to_string()));
730 }
731 outro => panic!("comando inesperado: {outro:?}"),
732 }
733 }
734
735 #[test]
736 fn exec_com_timeout_override() {
737 let args =
738 Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--timeout", "5000"])
739 .expect("parser deve aceitar exec com --timeout");
740 match args.comando {
741 Comando::Exec {
742 vps_nome, timeout, ..
743 } => {
744 assert_eq!(vps_nome, "myvps");
745 assert_eq!(timeout, Some(5000u64));
746 }
747 outro => panic!("comando inesperado: {outro:?}"),
748 }
749 }
750
751 #[test]
752 fn sudo_exec_com_sudo_password() {
753 let args = Argumentos::try_parse_from([
754 "ssh-cli",
755 "sudo-exec",
756 "myvps",
757 "apt update",
758 "--sudo-password",
759 "abc",
760 ])
761 .expect("parser deve aceitar sudo-exec com --sudo-password");
762 match args.comando {
763 Comando::SudoExec {
764 vps_nome,
765 sudo_password,
766 ..
767 } => {
768 assert_eq!(vps_nome, "myvps");
769 assert_eq!(sudo_password, Some("abc".to_string()));
770 }
771 outro => panic!("comando inesperado: {outro:?}"),
772 }
773 }
774
775 #[test]
776 fn sudo_exec_alias_camelcase() {
777 let args = Argumentos::try_parse_from([
778 "ssh-cli",
779 "sudo-exec",
780 "myvps",
781 "apt update",
782 "--sudoPassword",
783 "abc",
784 ])
785 .expect("parser deve aceitar sudo-exec com --sudoPassword");
786 match args.comando {
787 Comando::SudoExec {
788 vps_nome,
789 sudo_password,
790 ..
791 } => {
792 assert_eq!(vps_nome, "myvps");
793 assert_eq!(sudo_password, Some("abc".to_string()));
794 }
795 outro => panic!("comando inesperado: {outro:?}"),
796 }
797 }
798
799 #[test]
800 fn vps_add_alias_camelcase() {
801 let args = Argumentos::try_parse_from([
802 "ssh-cli",
803 "vps",
804 "add",
805 "--name",
806 "x",
807 "--host",
808 "1.2.3.4",
809 "--user",
810 "root",
811 "--sudoPassword",
812 "abc",
813 "--maxChars",
814 "5000",
815 ])
816 .expect("parser deve aceitar vps add com aliases camelCase");
817 match args.comando {
818 Comando::Vps {
819 acao:
820 AcaoVps::Add {
821 sudo_password,
822 max_chars,
823 ..
824 },
825 } => {
826 assert_eq!(sudo_password, Some("abc".to_string()));
827 assert_eq!(max_chars, "5000");
828 }
829 outro => panic!("comando inesperado: {outro:?}"),
830 }
831 }
832}