Skip to main content

ssh_cli/
cli.rs

1//! Definição de argumentos CLI via `clap` derive e dispatcher.
2//!
3//! O ssh-cli MVP tem os seguintes modos de operação:
4//!
5//! 1. **CRUD de VPS** — `ssh-cli vps add|list|remove|edit|show|path`.
6//! 2. **Seleção de ativa** — `ssh-cli connect <NOME>` (grava em `config.toml.active`).
7//! 3. **Execução remota** — `ssh-cli exec|sudo-exec|scp|tunnel`.
8//! 4. **Health check** — `ssh-cli health-check [VPS]`.
9//! 5. **Completions** — `ssh-cli completions <SHELL>`.
10//!
11//! ZERO arquivo `.env`. Toda configuração é gerenciada via comandos explícitos.
12
13use anyhow::Result;
14use clap::{Parser, Subcommand};
15use clap_complete::Shell;
16use std::path::PathBuf;
17
18/// Formato de saída suportado pela CLI.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
20pub enum FormatoSaida {
21    /// Texto legível por humanos (padrão).
22    #[default]
23    Text,
24    /// JSON estruturado.
25    Json,
26}
27
28/// Argumentos globais do ssh-cli.
29#[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    /// Força o idioma da CLI (ex.: `pt-BR`, `en-US`).
38    #[arg(long, global = true, value_name = "LOCALE")]
39    pub lang: Option<String>,
40
41    /// Aumenta a verbosidade de logs em stderr.
42    #[arg(short, long, global = true)]
43    pub verbose: bool,
44
45    /// Suprime output não-JSON (modo silencioso).
46    #[arg(short, long, global = true)]
47    pub quiet: bool,
48
49    /// Override do diretório de configuração (útil para testes).
50    #[arg(long, global = true, value_name = "DIR")]
51    pub config_dir: Option<PathBuf>,
52
53    /// Desativa cores no output.
54    #[arg(long, global = true)]
55    pub no_color: bool,
56
57    /// Formato global de saída (text, json).
58    #[arg(long, global = true, value_enum, default_value_t = FormatoSaida::Text)]
59    pub output_format: FormatoSaida,
60
61    /// Subcomando a executar.
62    #[command(subcommand)]
63    pub comando: Comando,
64}
65
66/// Subcomandos de primeiro nível.
67#[derive(Debug, Subcommand)]
68pub enum Comando {
69    /// Gerencia VPSs cadastradas (add, list, remove, edit, show, path).
70    Vps {
71        /// Ação específica do CRUD de VPS.
72        #[command(subcommand)]
73        acao: AcaoVps,
74    },
75
76    /// Define a VPS ativa (grava `active = "<NOME>"` no `config.toml`).
77    Connect {
78        /// Nome da VPS previamente adicionada via `vps add`.
79        nome: String,
80    },
81
82    /// Executa um comando na VPS via SSH (stdout/stderr capturados).
83    Exec {
84        /// Nome da VPS previamente adicionada via `vps add`.
85        vps_nome: String,
86
87        /// Comando shell a executar.
88        comando: String,
89
90        /// Saída em JSON.
91        #[arg(long)]
92        json: bool,
93
94        /// Override de senha SSH para esta execução.
95        #[arg(long)]
96        password: Option<String>,
97
98        /// Override de timeout em milissegundos.
99        #[arg(long)]
100        timeout: Option<u64>,
101    },
102
103    /// Executa um comando com `sudo` na VPS via SSH.
104    SudoExec {
105        /// Nome da VPS previamente adicionada via `vps add`.
106        vps_nome: String,
107
108        /// Comando shell a executar com privilégios sudo.
109        comando: String,
110
111        /// Saída em JSON.
112        #[arg(long)]
113        json: bool,
114
115        /// Override de senha SSH para esta execução.
116        #[arg(long)]
117        password: Option<String>,
118
119        /// Override de senha sudo para esta execução.
120        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
121        sudo_password: Option<String>,
122
123        /// Override de timeout em milissegundos.
124        #[arg(long)]
125        timeout: Option<u64>,
126    },
127
128    /// Transferência de arquivos via SCP (upload/download).
129    Scp {
130        /// Ação específica do SCP.
131        #[command(subcommand)]
132        acao: AcaoScp,
133    },
134
135    /// Cria um tunnel SSH (port-forward local).
136    Tunnel {
137        /// Nome da VPS previamente adicionada via `vps add`.
138        vps_nome: String,
139
140        /// Porta local para escuta (ex.: 8080).
141        porta_local: u16,
142
143        /// Host remoto accesible via SSH (ex.: 127.0.0.1).
144        host_remoto: String,
145
146        /// Porta remota (ex.: 5432).
147        porta_remota: u16,
148
149        /// Override de senha SSH para este tunnel.
150        #[arg(long)]
151        password: Option<String>,
152    },
153
154    /// Verifica conectividade SSH com uma VPS.
155    HealthCheck {
156        /// Nome da VPS a verificar (usa VPS ativa se omitido).
157        vps_nome: Option<String>,
158
159        /// Override de senha SSH para este health-check.
160        #[arg(long)]
161        password: Option<String>,
162    },
163
164    /// Gera completions de shell (bash, zsh, fish, powershell, elvish).
165    Completions {
166        /// Shell para gerar completions.
167        #[arg(value_enum)]
168        shell: Shell,
169    },
170}
171
172/// Ações do subcomando `vps`.
173#[derive(Debug, Subcommand)]
174pub enum AcaoVps {
175    /// Adiciona uma nova VPS ao registro.
176    Add {
177        /// Nome único da VPS.
178        #[arg(long)]
179        name: String,
180
181        /// Hostname ou IP.
182        #[arg(long)]
183        host: String,
184
185        /// Porta SSH.
186        #[arg(long, default_value_t = 22)]
187        port: u16,
188
189        /// Usuário SSH.
190        #[arg(long)]
191        user: String,
192
193        /// Senha SSH.
194        #[arg(long)]
195        password: Option<String>,
196
197        /// Timeout em milissegundos para comandos.
198        #[arg(long, default_value_t = 30_000)]
199        timeout: u64,
200
201        /// Limite de caracteres por output (`"none"` ou `"0"` = ilimitado).
202        #[arg(long, default_value = "100000", alias = "maxChars")]
203        max_chars: String,
204
205        /// Senha para `sudo`.
206        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
207        sudo_password: Option<String>,
208
209        /// Senha para `su -`.
210        #[arg(long, alias = "suPassword", alias = "su_password")]
211        su_password: Option<String>,
212    },
213
214    /// Lista todas as VPSs (senhas mascaradas).
215    List {
216        /// Saída em JSON (útil para pipes).
217        #[arg(long)]
218        json: bool,
219    },
220
221    /// Remove uma VPS do registro.
222    Remove {
223        /// Nome da VPS a remover.
224        nome: String,
225
226        /// Pula confirmação interativa (obrigatório em scripts e modo não-interativo).
227        #[arg(long, short = 'y')]
228        yes: bool,
229    },
230
231    /// Edita campos de uma VPS existente.
232    Edit {
233        /// Nome da VPS a editar.
234        nome: String,
235
236        /// Novo hostname/IP.
237        #[arg(long)]
238        host: Option<String>,
239
240        /// Nova porta SSH.
241        #[arg(long)]
242        port: Option<u16>,
243
244        /// Novo usuário.
245        #[arg(long)]
246        user: Option<String>,
247
248        /// Nova senha.
249        #[arg(long)]
250        password: Option<String>,
251
252        /// Novo timeout.
253        #[arg(long)]
254        timeout: Option<u64>,
255
256        /// Novo limite de caracteres.
257        #[arg(long, alias = "maxChars")]
258        max_chars: Option<String>,
259
260        /// Nova senha sudo.
261        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
262        sudo_password: Option<String>,
263
264        /// Nova senha su.
265        #[arg(long, alias = "suPassword", alias = "su_password")]
266        su_password: Option<String>,
267    },
268
269    /// Exibe detalhes de uma VPS (senhas mascaradas).
270    Show {
271        /// Nome da VPS a exibir.
272        nome: String,
273
274        /// Saída em JSON.
275        #[arg(long)]
276        json: bool,
277    },
278
279    /// Exibe o caminho do arquivo de configuração.
280    Path,
281}
282
283/// Ações do subcomando `scp`.
284#[derive(Debug, Subcommand)]
285pub enum AcaoScp {
286    /// Upload de arquivo local para remote.
287    Upload {
288        /// Nome da VPS previamente adicionada via `vps add`.
289        vps_nome: String,
290
291        /// Caminho do arquivo local a enviar.
292        local: PathBuf,
293
294        /// Caminho destino no servidor remote.
295        remote: PathBuf,
296
297        /// Override de senha SSH para esta transferência.
298        #[arg(long)]
299        password: Option<String>,
300    },
301
302    /// Download de arquivo remote para local.
303    Download {
304        /// Nome da VPS previamente adicionada via `vps add`.
305        vps_nome: String,
306
307        /// Caminho do arquivo no servidor remote.
308        remote: PathBuf,
309
310        /// Caminho local de destino.
311        local: PathBuf,
312
313        /// Override de senha SSH para esta transferência.
314        #[arg(long)]
315        password: Option<String>,
316    },
317}
318
319/// Faz parsing dos argumentos da CLI.
320#[must_use]
321pub fn parse_args() -> Argumentos {
322    Argumentos::parse()
323}
324
325/// Inicializa `tracing-subscriber`. Precedência: `RUST_LOG` > `--verbose` > `--quiet` > `info`.
326pub 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
347/// Gera completions de shell para stdout.
348pub 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
354/// Executa o subcomando solicitado.
355pub 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}