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
227    /// Edita campos de uma VPS existente.
228    Edit {
229        /// Nome da VPS a editar.
230        nome: String,
231
232        /// Novo hostname/IP.
233        #[arg(long)]
234        host: Option<String>,
235
236        /// Nova porta SSH.
237        #[arg(long)]
238        port: Option<u16>,
239
240        /// Novo usuário.
241        #[arg(long)]
242        user: Option<String>,
243
244        /// Nova senha.
245        #[arg(long)]
246        password: Option<String>,
247
248        /// Novo timeout.
249        #[arg(long)]
250        timeout: Option<u64>,
251
252        /// Novo limite de caracteres.
253        #[arg(long, alias = "maxChars")]
254        max_chars: Option<String>,
255
256        /// Nova senha sudo.
257        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
258        sudo_password: Option<String>,
259
260        /// Nova senha su.
261        #[arg(long, alias = "suPassword", alias = "su_password")]
262        su_password: Option<String>,
263    },
264
265    /// Exibe detalhes de uma VPS (senhas mascaradas).
266    Show {
267        /// Nome da VPS a exibir.
268        nome: String,
269
270        /// Saída em JSON.
271        #[arg(long)]
272        json: bool,
273    },
274
275    /// Exibe o caminho do arquivo de configuração.
276    Path,
277}
278
279/// Ações do subcomando `scp`.
280#[derive(Debug, Subcommand)]
281pub enum AcaoScp {
282    /// Upload de arquivo local para remote.
283    Upload {
284        /// Nome da VPS previamente adicionada via `vps add`.
285        vps_nome: String,
286
287        /// Caminho do arquivo local a enviar.
288        local: PathBuf,
289
290        /// Caminho destino no servidor remote.
291        remote: PathBuf,
292
293        /// Override de senha SSH para esta transferência.
294        #[arg(long)]
295        password: Option<String>,
296    },
297
298    /// Download de arquivo remote para local.
299    Download {
300        /// Nome da VPS previamente adicionada via `vps add`.
301        vps_nome: String,
302
303        /// Caminho do arquivo no servidor remote.
304        remote: PathBuf,
305
306        /// Caminho local de destino.
307        local: PathBuf,
308
309        /// Override de senha SSH para esta transferência.
310        #[arg(long)]
311        password: Option<String>,
312    },
313}
314
315/// Faz parsing dos argumentos da CLI.
316#[must_use]
317pub fn parse_args() -> Argumentos {
318    Argumentos::parse()
319}
320
321/// Inicializa `tracing-subscriber`. Precedência: `RUST_LOG` > `--verbose` > `--quiet` > `info`.
322pub 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
343/// Gera completions de shell para stdout.
344pub 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
350/// Executa o subcomando solicitado.
351pub 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}