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
227 Edit {
229 nome: String,
231
232 #[arg(long)]
234 host: Option<String>,
235
236 #[arg(long)]
238 port: Option<u16>,
239
240 #[arg(long)]
242 user: Option<String>,
243
244 #[arg(long)]
246 password: Option<String>,
247
248 #[arg(long)]
250 timeout: Option<u64>,
251
252 #[arg(long, alias = "maxChars")]
254 max_chars: Option<String>,
255
256 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
258 sudo_password: Option<String>,
259
260 #[arg(long, alias = "suPassword", alias = "su_password")]
262 su_password: Option<String>,
263 },
264
265 Show {
267 nome: String,
269
270 #[arg(long)]
272 json: bool,
273 },
274
275 Path,
277}
278
279#[derive(Debug, Subcommand)]
281pub enum AcaoScp {
282 Upload {
284 vps_nome: String,
286
287 local: PathBuf,
289
290 remote: PathBuf,
292
293 #[arg(long)]
295 password: Option<String>,
296 },
297
298 Download {
300 vps_nome: String,
302
303 remote: PathBuf,
305
306 local: PathBuf,
308
309 #[arg(long)]
311 password: Option<String>,
312 },
313}
314
315#[must_use]
317pub fn parse_args() -> Argumentos {
318 Argumentos::parse()
319}
320
321pub fn inicializar_logs(args: &Argumentos) {
323 use tracing_subscriber::{fmt, EnvFilter};
324
325 let filter = if std::env::var("RUST_LOG").is_ok() {
326 EnvFilter::from_default_env()
327 } else if args.verbose {
328 EnvFilter::new("debug")
329 } else if args.quiet {
330 EnvFilter::new("error")
331 } else {
332 EnvFilter::new("info")
333 };
334
335 let _ = fmt()
336 .with_env_filter(filter)
337 .with_writer(std::io::stderr)
338 .with_target(false)
339 .with_ansi(false)
340 .try_init();
341}
342
343pub fn gerar_completions(shell: Shell) {
345 use clap::CommandFactory;
346 let mut cmd = Argumentos::command();
347 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
348}
349
350pub async fn executar(args: Argumentos) -> Result<()> {
352 let config_override = args.config_dir.clone();
353 let formato = args.output_format;
354
355 match args.comando {
356 Comando::Vps { acao } => {
357 crate::vps::executar_comando_vps(acao, config_override, formato).await
358 }
359 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
360 Comando::Exec {
361 vps_nome,
362 comando,
363 json,
364 password,
365 timeout,
366 } => {
367 crate::vps::executar_exec(
368 &vps_nome,
369 &comando,
370 config_override,
371 formato,
372 json,
373 password,
374 timeout,
375 )
376 .await
377 }
378 Comando::SudoExec {
379 vps_nome,
380 comando,
381 json,
382 password,
383 sudo_password,
384 timeout,
385 } => {
386 crate::vps::executar_sudo_exec(
387 &vps_nome,
388 &comando,
389 config_override,
390 formato,
391 json,
392 password,
393 sudo_password,
394 timeout,
395 )
396 .await
397 }
398 Comando::Scp { acao } => {
399 let pwd = match &acao {
400 AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
401 password.clone()
402 }
403 };
404 crate::scp::executar_scp(acao, config_override, pwd).await
405 }
406 Comando::Tunnel {
407 vps_nome,
408 porta_local,
409 host_remoto,
410 porta_remota,
411 password,
412 } => {
413 crate::tunnel::executar_tunnel(
414 &vps_nome,
415 porta_local,
416 &host_remoto,
417 porta_remota,
418 config_override,
419 password,
420 )
421 .await
422 }
423 Comando::HealthCheck { vps_nome, password } => {
424 crate::vps::executar_health_check(
425 vps_nome.as_deref(),
426 config_override,
427 formato,
428 password,
429 )
430 .await
431 }
432 Comando::Completions { shell } => {
433 gerar_completions(shell);
434 Ok(())
435 }
436 }
437}
438
439#[cfg(test)]
440mod testes {
441 use super::*;
442 use clap::Parser;
443 use serial_test::serial;
444 use tempfile::TempDir;
445
446 fn argumentos_teste(comando: Comando, config_dir: Option<PathBuf>) -> Argumentos {
447 Argumentos {
448 lang: None,
449 verbose: false,
450 quiet: false,
451 config_dir,
452 no_color: false,
453 output_format: FormatoSaida::Text,
454 comando,
455 }
456 }
457
458 #[test]
459 fn parser_entende_tunnel() {
460 let args =
461 Argumentos::try_parse_from(["ssh-cli", "tunnel", "vps-a", "8080", "127.0.0.1", "5432"])
462 .expect("parser deve aceitar subcomando tunnel");
463
464 match args.comando {
465 Comando::Tunnel {
466 vps_nome,
467 porta_local,
468 host_remoto,
469 porta_remota,
470 ..
471 } => {
472 assert_eq!(vps_nome, "vps-a");
473 assert_eq!(porta_local, 8080);
474 assert_eq!(host_remoto, "127.0.0.1");
475 assert_eq!(porta_remota, 5432);
476 }
477 outro => panic!("comando inesperado: {outro:?}"),
478 }
479 }
480
481 #[test]
482 fn parser_entende_scp_upload() {
483 let args = Argumentos::try_parse_from([
484 "ssh-cli",
485 "scp",
486 "upload",
487 "vps-a",
488 "./arquivo-local.txt",
489 "/tmp/arquivo-remoto.txt",
490 ])
491 .expect("parser deve aceitar scp upload");
492
493 match args.comando {
494 Comando::Scp {
495 acao:
496 AcaoScp::Upload {
497 vps_nome,
498 local,
499 remote,
500 ..
501 },
502 } => {
503 assert_eq!(vps_nome, "vps-a");
504 assert_eq!(local, PathBuf::from("./arquivo-local.txt"));
505 assert_eq!(remote, PathBuf::from("/tmp/arquivo-remoto.txt"));
506 }
507 outro => panic!("comando inesperado: {outro:?}"),
508 }
509 }
510
511 #[test]
512 #[serial]
513 fn inicializar_logs_sem_panic_com_rust_log_definido() {
514 std::env::set_var("RUST_LOG", "trace");
515 let args = argumentos_teste(
516 Comando::Connect {
517 nome: "vps-a".to_string(),
518 },
519 None,
520 );
521 inicializar_logs(&args);
522 std::env::remove_var("RUST_LOG");
523 }
524
525 #[test]
526 #[serial]
527 fn inicializar_logs_sem_panic_com_verbose() {
528 std::env::remove_var("RUST_LOG");
529 let mut args = argumentos_teste(
530 Comando::Connect {
531 nome: "vps-a".to_string(),
532 },
533 None,
534 );
535 args.verbose = true;
536 inicializar_logs(&args);
537 }
538
539 #[test]
540 #[serial]
541 fn inicializar_logs_sem_panic_com_quiet() {
542 std::env::remove_var("RUST_LOG");
543 let mut args = argumentos_teste(
544 Comando::Connect {
545 nome: "vps-a".to_string(),
546 },
547 None,
548 );
549 args.quiet = true;
550 inicializar_logs(&args);
551 }
552
553 #[test]
554 #[serial]
555 fn inicializar_logs_sem_panic_no_padrao_info() {
556 std::env::remove_var("RUST_LOG");
557 let args = argumentos_teste(
558 Comando::Connect {
559 nome: "vps-a".to_string(),
560 },
561 None,
562 );
563 inicializar_logs(&args);
564 }
565
566 #[tokio::test]
567 async fn executar_branch_exec_retorna_erro_para_vps_inexistente() {
568 let tmp = TempDir::new().expect("tempdir");
569 let args = argumentos_teste(
570 Comando::Exec {
571 vps_nome: "inexistente".to_string(),
572 comando: "echo ok".to_string(),
573 json: false,
574 password: None,
575 timeout: None,
576 },
577 Some(tmp.path().to_path_buf()),
578 );
579
580 let resultado = executar(args).await;
581 assert!(resultado.is_err());
582 }
583
584 #[tokio::test]
585 async fn executar_branch_sudo_exec_retorna_erro_para_vps_inexistente() {
586 let tmp = TempDir::new().expect("tempdir");
587 let args = argumentos_teste(
588 Comando::SudoExec {
589 vps_nome: "inexistente".to_string(),
590 comando: "id".to_string(),
591 json: false,
592 password: None,
593 sudo_password: None,
594 timeout: None,
595 },
596 Some(tmp.path().to_path_buf()),
597 );
598
599 let resultado = executar(args).await;
600 assert!(resultado.is_err());
601 }
602
603 #[tokio::test]
604 async fn executar_branch_scp_retorna_erro_para_vps_inexistente() {
605 let tmp = TempDir::new().expect("tempdir");
606 let args = argumentos_teste(
607 Comando::Scp {
608 acao: AcaoScp::Upload {
609 vps_nome: "inexistente".to_string(),
610 local: PathBuf::from("./arquivo-local.txt"),
611 remote: PathBuf::from("/tmp/arquivo-remoto.txt"),
612 password: None,
613 },
614 },
615 Some(tmp.path().to_path_buf()),
616 );
617
618 let resultado = executar(args).await;
619 assert!(resultado.is_err());
620 }
621
622 #[tokio::test]
623 async fn executar_branch_tunnel_retorna_erro_para_vps_inexistente() {
624 let tmp = TempDir::new().expect("tempdir");
625 let args = argumentos_teste(
626 Comando::Tunnel {
627 vps_nome: "inexistente".to_string(),
628 porta_local: 38080,
629 host_remoto: "127.0.0.1".to_string(),
630 porta_remota: 5432,
631 password: None,
632 },
633 Some(tmp.path().to_path_buf()),
634 );
635
636 let resultado = executar(args).await;
637 assert!(resultado.is_err());
638 }
639
640 #[test]
641 fn test_parse_no_color() {
642 let args = Argumentos::try_parse_from(["ssh-cli", "--no-color", "vps", "list"])
643 .expect("parser deve aceitar --no-color");
644 assert!(args.no_color);
645 }
646
647 #[test]
648 fn test_parse_output_format_json() {
649 let args =
650 Argumentos::try_parse_from(["ssh-cli", "--output-format", "json", "vps", "list"])
651 .expect("parser deve aceitar --output-format json");
652 assert_eq!(args.output_format, FormatoSaida::Json);
653 }
654
655 #[test]
656 fn test_parse_output_format_default() {
657 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "list"])
658 .expect("parser deve aceitar subcomando sem output-format");
659 assert_eq!(args.output_format, FormatoSaida::Text);
660 }
661
662 #[test]
663 fn test_parse_completions_bash() {
664 let args = Argumentos::try_parse_from(["ssh-cli", "completions", "bash"])
665 .expect("parser deve aceitar completions bash");
666 assert!(matches!(
667 args.comando,
668 Comando::Completions { shell: Shell::Bash }
669 ));
670 }
671
672 #[test]
673 fn test_parse_health_check_com_nome() {
674 let args = Argumentos::try_parse_from(["ssh-cli", "health-check", "meu-vps"])
675 .expect("parser deve aceitar health-check com nome");
676 match args.comando {
677 Comando::HealthCheck { vps_nome, .. } => {
678 assert_eq!(vps_nome, Some("meu-vps".to_string()));
679 }
680 outro => panic!("comando inesperado: {outro:?}"),
681 }
682 }
683
684 #[test]
685 fn test_parse_health_check_sem_nome() {
686 let args = Argumentos::try_parse_from(["ssh-cli", "health-check"])
687 .expect("parser deve aceitar health-check sem nome");
688 match args.comando {
689 Comando::HealthCheck { vps_nome, .. } => {
690 assert!(vps_nome.is_none());
691 }
692 outro => panic!("comando inesperado: {outro:?}"),
693 }
694 }
695
696 #[test]
697 fn test_parse_exec_json() {
698 let args = Argumentos::try_parse_from(["ssh-cli", "exec", "vps1", "ls", "--json"])
699 .expect("parser deve aceitar exec com --json");
700 match args.comando {
701 Comando::Exec {
702 vps_nome,
703 comando,
704 json,
705 ..
706 } => {
707 assert_eq!(vps_nome, "vps1");
708 assert_eq!(comando, "ls");
709 assert!(json);
710 }
711 outro => panic!("comando inesperado: {outro:?}"),
712 }
713 }
714
715 #[test]
716 fn exec_com_password_override() {
717 let args =
718 Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--password", "abc123"])
719 .expect("parser deve aceitar exec com --password");
720 match args.comando {
721 Comando::Exec {
722 vps_nome, password, ..
723 } => {
724 assert_eq!(vps_nome, "myvps");
725 assert_eq!(password, Some("abc123".to_string()));
726 }
727 outro => panic!("comando inesperado: {outro:?}"),
728 }
729 }
730
731 #[test]
732 fn exec_com_timeout_override() {
733 let args =
734 Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--timeout", "5000"])
735 .expect("parser deve aceitar exec com --timeout");
736 match args.comando {
737 Comando::Exec {
738 vps_nome, timeout, ..
739 } => {
740 assert_eq!(vps_nome, "myvps");
741 assert_eq!(timeout, Some(5000u64));
742 }
743 outro => panic!("comando inesperado: {outro:?}"),
744 }
745 }
746
747 #[test]
748 fn sudo_exec_com_sudo_password() {
749 let args = Argumentos::try_parse_from([
750 "ssh-cli",
751 "sudo-exec",
752 "myvps",
753 "apt update",
754 "--sudo-password",
755 "abc",
756 ])
757 .expect("parser deve aceitar sudo-exec com --sudo-password");
758 match args.comando {
759 Comando::SudoExec {
760 vps_nome,
761 sudo_password,
762 ..
763 } => {
764 assert_eq!(vps_nome, "myvps");
765 assert_eq!(sudo_password, Some("abc".to_string()));
766 }
767 outro => panic!("comando inesperado: {outro:?}"),
768 }
769 }
770
771 #[test]
772 fn sudo_exec_alias_camelcase() {
773 let args = Argumentos::try_parse_from([
774 "ssh-cli",
775 "sudo-exec",
776 "myvps",
777 "apt update",
778 "--sudoPassword",
779 "abc",
780 ])
781 .expect("parser deve aceitar sudo-exec com --sudoPassword");
782 match args.comando {
783 Comando::SudoExec {
784 vps_nome,
785 sudo_password,
786 ..
787 } => {
788 assert_eq!(vps_nome, "myvps");
789 assert_eq!(sudo_password, Some("abc".to_string()));
790 }
791 outro => panic!("comando inesperado: {outro:?}"),
792 }
793 }
794
795 #[test]
796 fn vps_add_alias_camelcase() {
797 let args = Argumentos::try_parse_from([
798 "ssh-cli",
799 "vps",
800 "add",
801 "--name",
802 "x",
803 "--host",
804 "1.2.3.4",
805 "--user",
806 "root",
807 "--sudoPassword",
808 "abc",
809 "--maxChars",
810 "5000",
811 ])
812 .expect("parser deve aceitar vps add com aliases camelCase");
813 match args.comando {
814 Comando::Vps {
815 acao:
816 AcaoVps::Add {
817 sudo_password,
818 max_chars,
819 ..
820 },
821 } => {
822 assert_eq!(sudo_password, Some("abc".to_string()));
823 assert_eq!(max_chars, "5000");
824 }
825 outro => panic!("comando inesperado: {outro:?}"),
826 }
827 }
828}