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
95    /// Executa um comando com `sudo` na VPS via SSH.
96    SudoExec {
97        /// Nome da VPS previamente adicionada via `vps add`.
98        vps_nome: String,
99
100        /// Comando shell a executar com privilégios sudo.
101        comando: String,
102
103        /// Saída em JSON.
104        #[arg(long)]
105        json: bool,
106    },
107
108    /// Transferência de arquivos via SCP (upload/download).
109    Scp {
110        /// Ação específica do SCP.
111        #[command(subcommand)]
112        acao: AcaoScp,
113    },
114
115    /// Cria um tunnel SSH (port-forward local).
116    Tunnel {
117        /// Nome da VPS previamente adicionada via `vps add`.
118        vps_nome: String,
119
120        /// Porta local para escuta (ex.: 8080).
121        porta_local: u16,
122
123        /// Host remoto accesible via SSH (ex.: 127.0.0.1).
124        host_remoto: String,
125
126        /// Porta remota (ex.: 5432).
127        porta_remota: u16,
128    },
129
130    /// Verifica conectividade SSH com uma VPS.
131    HealthCheck {
132        /// Nome da VPS a verificar (usa VPS ativa se omitido).
133        vps_nome: Option<String>,
134    },
135
136    /// Gera completions de shell (bash, zsh, fish, powershell, elvish).
137    Completions {
138        /// Shell para gerar completions.
139        #[arg(value_enum)]
140        shell: Shell,
141    },
142}
143
144/// Ações do subcomando `vps`.
145#[derive(Debug, Subcommand)]
146pub enum AcaoVps {
147    /// Adiciona uma nova VPS ao registro.
148    Add {
149        /// Nome único da VPS.
150        #[arg(long)]
151        name: String,
152
153        /// Hostname ou IP.
154        #[arg(long)]
155        host: String,
156
157        /// Porta SSH.
158        #[arg(long, default_value_t = 22)]
159        port: u16,
160
161        /// Usuário SSH.
162        #[arg(long)]
163        user: String,
164
165        /// Senha SSH.
166        #[arg(long)]
167        password: Option<String>,
168
169        /// Timeout em milissegundos para comandos.
170        #[arg(long, default_value_t = 30_000)]
171        timeout: u64,
172
173        /// Limite de caracteres por output (`"none"` ou `"0"` = ilimitado).
174        #[arg(long, default_value = "100000")]
175        max_chars: String,
176
177        /// Senha para `sudo`.
178        #[arg(long)]
179        sudo_password: Option<String>,
180
181        /// Senha para `su -`.
182        #[arg(long)]
183        su_password: Option<String>,
184    },
185
186    /// Lista todas as VPSs (senhas mascaradas).
187    List {
188        /// Saída em JSON (útil para pipes).
189        #[arg(long)]
190        json: bool,
191    },
192
193    /// Remove uma VPS do registro.
194    Remove {
195        /// Nome da VPS a remover.
196        nome: String,
197    },
198
199    /// Edita campos de uma VPS existente.
200    Edit {
201        /// Nome da VPS a editar.
202        nome: String,
203
204        /// Novo hostname/IP.
205        #[arg(long)]
206        host: Option<String>,
207
208        /// Nova porta SSH.
209        #[arg(long)]
210        port: Option<u16>,
211
212        /// Novo usuário.
213        #[arg(long)]
214        user: Option<String>,
215
216        /// Nova senha.
217        #[arg(long)]
218        password: Option<String>,
219
220        /// Novo timeout.
221        #[arg(long)]
222        timeout: Option<u64>,
223
224        /// Novo limite de caracteres.
225        #[arg(long)]
226        max_chars: Option<String>,
227
228        /// Nova senha sudo.
229        #[arg(long)]
230        sudo_password: Option<String>,
231
232        /// Nova senha su.
233        #[arg(long)]
234        su_password: Option<String>,
235    },
236
237    /// Exibe detalhes de uma VPS (senhas mascaradas).
238    Show {
239        /// Nome da VPS a exibir.
240        nome: String,
241
242        /// Saída em JSON.
243        #[arg(long)]
244        json: bool,
245    },
246
247    /// Exibe o caminho do arquivo de configuração.
248    Path,
249}
250
251/// Ações do subcomando `scp`.
252#[derive(Debug, Subcommand)]
253pub enum AcaoScp {
254    /// Upload de arquivo local para remote.
255    Upload {
256        /// Nome da VPS previamente adicionada via `vps add`.
257        vps_nome: String,
258
259        /// Caminho do arquivo local a enviar.
260        local: PathBuf,
261
262        /// Caminho destino no servidor remote.
263        remote: PathBuf,
264    },
265
266    /// Download de arquivo remote para local.
267    Download {
268        /// Nome da VPS previamente adicionada via `vps add`.
269        vps_nome: String,
270
271        /// Caminho do arquivo no servidor remote.
272        remote: PathBuf,
273
274        /// Caminho local de destino.
275        local: PathBuf,
276    },
277}
278
279/// Faz parsing dos argumentos da CLI.
280#[must_use]
281pub fn parse_args() -> Argumentos {
282    Argumentos::parse()
283}
284
285/// Inicializa `tracing-subscriber`. Precedência: `RUST_LOG` > `--verbose` > `--quiet` > `info`.
286pub fn inicializar_logs(args: &Argumentos) {
287    use tracing_subscriber::{fmt, EnvFilter};
288
289    let filter = if std::env::var("RUST_LOG").is_ok() {
290        EnvFilter::from_default_env()
291    } else if args.verbose {
292        EnvFilter::new("debug")
293    } else if args.quiet {
294        EnvFilter::new("error")
295    } else {
296        EnvFilter::new("info")
297    };
298
299    let _ = fmt()
300        .with_env_filter(filter)
301        .with_writer(std::io::stderr)
302        .with_target(false)
303        .with_ansi(false)
304        .try_init();
305}
306
307/// Gera completions de shell para stdout.
308pub fn gerar_completions(shell: Shell) {
309    use clap::CommandFactory;
310    let mut cmd = Argumentos::command();
311    clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
312}
313
314/// Executa o subcomando solicitado.
315pub async fn executar(args: Argumentos) -> Result<()> {
316    let config_override = args.config_dir.clone();
317    let formato = args.output_format;
318
319    match args.comando {
320        Comando::Vps { acao } => {
321            crate::vps::executar_comando_vps(acao, config_override, formato).await
322        }
323        Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
324        Comando::Exec {
325            vps_nome,
326            comando,
327            json,
328        } => crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json).await,
329        Comando::SudoExec {
330            vps_nome,
331            comando,
332            json,
333        } => {
334            crate::vps::executar_sudo_exec(&vps_nome, &comando, config_override, formato, json)
335                .await
336        }
337        Comando::Scp { acao } => crate::scp::executar_scp(acao, config_override).await,
338        Comando::Tunnel {
339            vps_nome,
340            porta_local,
341            host_remoto,
342            porta_remota,
343        } => {
344            crate::tunnel::executar_tunnel(
345                &vps_nome,
346                porta_local,
347                &host_remoto,
348                porta_remota,
349                config_override,
350            )
351            .await
352        }
353        Comando::HealthCheck { vps_nome } => {
354            crate::vps::executar_health_check(vps_nome.as_deref(), config_override, formato).await
355        }
356        Comando::Completions { shell } => {
357            gerar_completions(shell);
358            Ok(())
359        }
360    }
361}
362
363#[cfg(test)]
364mod testes {
365    use super::*;
366    use clap::Parser;
367    use serial_test::serial;
368    use tempfile::TempDir;
369
370    fn argumentos_teste(comando: Comando, config_dir: Option<PathBuf>) -> Argumentos {
371        Argumentos {
372            lang: None,
373            verbose: false,
374            quiet: false,
375            config_dir,
376            no_color: false,
377            output_format: FormatoSaida::Text,
378            comando,
379        }
380    }
381
382    #[test]
383    fn parser_entende_tunnel() {
384        let args =
385            Argumentos::try_parse_from(["ssh-cli", "tunnel", "vps-a", "8080", "127.0.0.1", "5432"])
386                .expect("parser deve aceitar subcomando tunnel");
387
388        match args.comando {
389            Comando::Tunnel {
390                vps_nome,
391                porta_local,
392                host_remoto,
393                porta_remota,
394            } => {
395                assert_eq!(vps_nome, "vps-a");
396                assert_eq!(porta_local, 8080);
397                assert_eq!(host_remoto, "127.0.0.1");
398                assert_eq!(porta_remota, 5432);
399            }
400            outro => panic!("comando inesperado: {outro:?}"),
401        }
402    }
403
404    #[test]
405    fn parser_entende_scp_upload() {
406        let args = Argumentos::try_parse_from([
407            "ssh-cli",
408            "scp",
409            "upload",
410            "vps-a",
411            "./arquivo-local.txt",
412            "/tmp/arquivo-remoto.txt",
413        ])
414        .expect("parser deve aceitar scp upload");
415
416        match args.comando {
417            Comando::Scp {
418                acao:
419                    AcaoScp::Upload {
420                        vps_nome,
421                        local,
422                        remote,
423                    },
424            } => {
425                assert_eq!(vps_nome, "vps-a");
426                assert_eq!(local, PathBuf::from("./arquivo-local.txt"));
427                assert_eq!(remote, PathBuf::from("/tmp/arquivo-remoto.txt"));
428            }
429            outro => panic!("comando inesperado: {outro:?}"),
430        }
431    }
432
433    #[test]
434    #[serial]
435    fn inicializar_logs_sem_panic_com_rust_log_definido() {
436        std::env::set_var("RUST_LOG", "trace");
437        let args = argumentos_teste(
438            Comando::Connect {
439                nome: "vps-a".to_string(),
440            },
441            None,
442        );
443        inicializar_logs(&args);
444        std::env::remove_var("RUST_LOG");
445    }
446
447    #[test]
448    #[serial]
449    fn inicializar_logs_sem_panic_com_verbose() {
450        std::env::remove_var("RUST_LOG");
451        let mut args = argumentos_teste(
452            Comando::Connect {
453                nome: "vps-a".to_string(),
454            },
455            None,
456        );
457        args.verbose = true;
458        inicializar_logs(&args);
459    }
460
461    #[test]
462    #[serial]
463    fn inicializar_logs_sem_panic_com_quiet() {
464        std::env::remove_var("RUST_LOG");
465        let mut args = argumentos_teste(
466            Comando::Connect {
467                nome: "vps-a".to_string(),
468            },
469            None,
470        );
471        args.quiet = true;
472        inicializar_logs(&args);
473    }
474
475    #[test]
476    #[serial]
477    fn inicializar_logs_sem_panic_no_padrao_info() {
478        std::env::remove_var("RUST_LOG");
479        let args = argumentos_teste(
480            Comando::Connect {
481                nome: "vps-a".to_string(),
482            },
483            None,
484        );
485        inicializar_logs(&args);
486    }
487
488    #[tokio::test]
489    async fn executar_branch_exec_retorna_erro_para_vps_inexistente() {
490        let tmp = TempDir::new().expect("tempdir");
491        let args = argumentos_teste(
492            Comando::Exec {
493                vps_nome: "inexistente".to_string(),
494                comando: "echo ok".to_string(),
495                json: false,
496            },
497            Some(tmp.path().to_path_buf()),
498        );
499
500        let resultado = executar(args).await;
501        assert!(resultado.is_err());
502    }
503
504    #[tokio::test]
505    async fn executar_branch_sudo_exec_retorna_erro_para_vps_inexistente() {
506        let tmp = TempDir::new().expect("tempdir");
507        let args = argumentos_teste(
508            Comando::SudoExec {
509                vps_nome: "inexistente".to_string(),
510                comando: "id".to_string(),
511                json: false,
512            },
513            Some(tmp.path().to_path_buf()),
514        );
515
516        let resultado = executar(args).await;
517        assert!(resultado.is_err());
518    }
519
520    #[tokio::test]
521    async fn executar_branch_scp_retorna_erro_para_vps_inexistente() {
522        let tmp = TempDir::new().expect("tempdir");
523        let args = argumentos_teste(
524            Comando::Scp {
525                acao: AcaoScp::Upload {
526                    vps_nome: "inexistente".to_string(),
527                    local: PathBuf::from("./arquivo-local.txt"),
528                    remote: PathBuf::from("/tmp/arquivo-remoto.txt"),
529                },
530            },
531            Some(tmp.path().to_path_buf()),
532        );
533
534        let resultado = executar(args).await;
535        assert!(resultado.is_err());
536    }
537
538    #[tokio::test]
539    async fn executar_branch_tunnel_retorna_erro_para_vps_inexistente() {
540        let tmp = TempDir::new().expect("tempdir");
541        let args = argumentos_teste(
542            Comando::Tunnel {
543                vps_nome: "inexistente".to_string(),
544                porta_local: 38080,
545                host_remoto: "127.0.0.1".to_string(),
546                porta_remota: 5432,
547            },
548            Some(tmp.path().to_path_buf()),
549        );
550
551        let resultado = executar(args).await;
552        assert!(resultado.is_err());
553    }
554
555    #[test]
556    fn test_parse_no_color() {
557        let args = Argumentos::try_parse_from(["ssh-cli", "--no-color", "vps", "list"])
558            .expect("parser deve aceitar --no-color");
559        assert!(args.no_color);
560    }
561
562    #[test]
563    fn test_parse_output_format_json() {
564        let args =
565            Argumentos::try_parse_from(["ssh-cli", "--output-format", "json", "vps", "list"])
566                .expect("parser deve aceitar --output-format json");
567        assert_eq!(args.output_format, FormatoSaida::Json);
568    }
569
570    #[test]
571    fn test_parse_output_format_default() {
572        let args = Argumentos::try_parse_from(["ssh-cli", "vps", "list"])
573            .expect("parser deve aceitar subcomando sem output-format");
574        assert_eq!(args.output_format, FormatoSaida::Text);
575    }
576
577    #[test]
578    fn test_parse_completions_bash() {
579        let args = Argumentos::try_parse_from(["ssh-cli", "completions", "bash"])
580            .expect("parser deve aceitar completions bash");
581        assert!(matches!(
582            args.comando,
583            Comando::Completions { shell: Shell::Bash }
584        ));
585    }
586
587    #[test]
588    fn test_parse_health_check_com_nome() {
589        let args = Argumentos::try_parse_from(["ssh-cli", "health-check", "meu-vps"])
590            .expect("parser deve aceitar health-check com nome");
591        match args.comando {
592            Comando::HealthCheck { vps_nome } => {
593                assert_eq!(vps_nome, Some("meu-vps".to_string()));
594            }
595            outro => panic!("comando inesperado: {outro:?}"),
596        }
597    }
598
599    #[test]
600    fn test_parse_health_check_sem_nome() {
601        let args = Argumentos::try_parse_from(["ssh-cli", "health-check"])
602            .expect("parser deve aceitar health-check sem nome");
603        match args.comando {
604            Comando::HealthCheck { vps_nome } => {
605                assert!(vps_nome.is_none());
606            }
607            outro => panic!("comando inesperado: {outro:?}"),
608        }
609    }
610
611    #[test]
612    fn test_parse_exec_json() {
613        let args = Argumentos::try_parse_from(["ssh-cli", "exec", "vps1", "ls", "--json"])
614            .expect("parser deve aceitar exec com --json");
615        match args.comando {
616            Comando::Exec {
617                vps_nome,
618                comando,
619                json,
620            } => {
621                assert_eq!(vps_nome, "vps1");
622                assert_eq!(comando, "ls");
623                assert!(json);
624            }
625            outro => panic!("comando inesperado: {outro:?}"),
626        }
627    }
628}