Skip to main content

ssh_cli/
cli.rs

1//! Definição de argumentos CLI via `clap` derive e dispatcher.
2//!
3//! 1. CRUD de VPS — `vps add|list|remove|edit|show|path|doctor|export|import`
4//! 2. `connect` — grava arquivo irmão `active` (não campo TOML)
5//! 3. Execução one-shot — `exec|sudo-exec|su-exec|scp|tunnel|health-check`
6//! 4. `secrets` — master-key status/init/reencrypt (cifragem at-rest default)
7//! 5. Completions
8//!
9//! ZERO `.env` em runtime. ZERO telemetria. Ciclo one-shot: nascer → executar → morrer.
10
11use anyhow::Result;
12use clap::{Parser, Subcommand};
13use clap_complete::Shell;
14use std::path::PathBuf;
15
16/// Formato de saída suportado pela CLI.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
18pub enum FormatoSaida {
19    /// Texto legível por humanos (padrão).
20    #[default]
21    Text,
22    /// JSON estruturado.
23    Json,
24}
25
26/// Argumentos globais do ssh-cli.
27#[derive(Debug, Parser)]
28#[command(
29    name = "ssh-cli",
30    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
31    about = "CLI Rust one-shot multi-host XDG para LLMs operarem servidores via SSH.",
32    long_about = "ssh-cli: binário leve one-shot (nascer→executar→morrer). Multi-host em storage XDG sem .env. \
33Auth por senha ou chave. Sem telemetria."
34)]
35pub struct Argumentos {
36    /// Força o idioma da CLI (ex.: `pt-BR`, `en-US`).
37    #[arg(long, global = true, value_name = "LOCALE")]
38    pub lang: Option<String>,
39
40    /// Aumenta a verbosidade de logs em stderr.
41    #[arg(short, long, global = true)]
42    pub verbose: bool,
43
44    /// Suprime output não-JSON (modo silencioso).
45    #[arg(short, long, global = true)]
46    pub quiet: bool,
47
48    /// Override do diretório de configuração (útil para testes).
49    #[arg(long, global = true, value_name = "DIR")]
50    pub config_dir: Option<PathBuf>,
51
52    /// Desativa cores no output.
53    #[arg(long, global = true)]
54    pub no_color: bool,
55
56    /// Formato global de saída (text, json). Se omitido: JSON quando stdout não é TTY.
57    #[arg(long, global = true, value_enum)]
58    pub output_format: Option<FormatoSaida>,
59
60    /// Desabilita sudo-exec/su-exec nesta invocação (alias --disableSudo).
61    #[arg(long, global = true, alias = "disableSudo")]
62    pub disable_sudo: bool,
63
64    /// Substitui host key divergente no known_hosts TOFU.
65    #[arg(long, global = true)]
66    pub replace_host_key: bool,
67
68    /// Subcomando a executar.
69    #[command(subcommand)]
70    pub comando: Comando,
71}
72
73/// Subcomandos de primeiro nível.
74#[derive(Debug, Subcommand)]
75pub enum Comando {
76    /// Gerencia VPSs cadastradas.
77    Vps {
78        /// Ação específica do CRUD de VPS.
79        #[command(subcommand)]
80        acao: AcaoVps,
81    },
82
83    /// Define a VPS ativa (grava arquivo irmão `active` no diretório de config).
84    Connect {
85        /// Nome da VPS previamente adicionada via `vps add`.
86        nome: String,
87    },
88
89    /// Executa um comando na VPS via SSH (stdout/stderr capturados).
90    Exec {
91        /// Nome da VPS.
92        vps_nome: String,
93        /// Comando shell a executar.
94        comando: String,
95        /// Saída em JSON.
96        #[arg(long)]
97        json: bool,
98        /// Override de senha SSH.
99        #[arg(long, conflicts_with = "password_stdin")]
100        password: Option<String>,
101        /// Lê senha SSH de stdin.
102        #[arg(long)]
103        password_stdin: bool,
104        /// Override de caminho de chave privada.
105        #[arg(long)]
106        key: Option<String>,
107        /// Passphrase da chave (runtime).
108        #[arg(long, conflicts_with = "key_passphrase_stdin")]
109        key_passphrase: Option<String>,
110        /// Lê passphrase da chave de stdin.
111        #[arg(long)]
112        key_passphrase_stdin: bool,
113        /// Override de timeout em milissegundos.
114        #[arg(long)]
115        timeout: Option<u64>,
116        /// Comentário shell anexado (comentário shell para auditoria).
117        #[arg(long)]
118        description: Option<String>,
119    },
120
121    /// Executa um comando com `sudo` (packing `sh -c` seguro).
122    SudoExec {
123        /// Nome da VPS.
124        vps_nome: String,
125        /// Comando shell.
126        comando: String,
127        /// Saída em JSON.
128        #[arg(long)]
129        json: bool,
130        /// Override de senha SSH.
131        #[arg(long, conflicts_with = "password_stdin")]
132        password: Option<String>,
133        /// Lê senha SSH de stdin.
134        #[arg(long)]
135        password_stdin: bool,
136        /// Override de senha sudo.
137        #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
138        sudo_password: Option<String>,
139        /// Lê senha sudo de stdin.
140        #[arg(long)]
141        sudo_password_stdin: bool,
142        /// Override de chave.
143        #[arg(long)]
144        key: Option<String>,
145        /// Passphrase da chave (runtime).
146        #[arg(long, conflicts_with = "key_passphrase_stdin")]
147        key_passphrase: Option<String>,
148        /// Lê passphrase da chave de stdin.
149        #[arg(long)]
150        key_passphrase_stdin: bool,
151        /// Override de timeout em milissegundos.
152        #[arg(long)]
153        timeout: Option<u64>,
154        /// Comentário shell anexado.
155        #[arg(long)]
156        description: Option<String>,
157    },
158
159    /// Executa um comando com elevação `su -` one-shot.
160    SuExec {
161        /// Nome da VPS.
162        vps_nome: String,
163        /// Comando shell.
164        comando: String,
165        /// Saída em JSON.
166        #[arg(long)]
167        json: bool,
168        /// Override de senha SSH.
169        #[arg(long, conflicts_with = "password_stdin")]
170        password: Option<String>,
171        /// Lê senha SSH de stdin (GAP-SSH-CLI-001).
172        #[arg(long)]
173        password_stdin: bool,
174        /// Override de senha su.
175        #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
176        su_password: Option<String>,
177        /// Lê senha su de stdin.
178        #[arg(long)]
179        su_password_stdin: bool,
180        /// Override de chave.
181        #[arg(long)]
182        key: Option<String>,
183        /// Passphrase da chave (runtime).
184        #[arg(long, conflicts_with = "key_passphrase_stdin")]
185        key_passphrase: Option<String>,
186        /// Lê passphrase da chave de stdin.
187        #[arg(long)]
188        key_passphrase_stdin: bool,
189        /// Override de timeout.
190        #[arg(long)]
191        timeout: Option<u64>,
192        /// Comentário shell anexado.
193        #[arg(long)]
194        description: Option<String>,
195    },
196
197    /// Transferência de arquivos via SCP (upload/download).
198    Scp {
199        /// Ação específica do SCP.
200        #[command(subcommand)]
201        acao: AcaoScp,
202    },
203
204    /// Tunnel SSH com deadline obrigatório (one-shot limitado).
205    Tunnel {
206        /// Nome da VPS.
207        vps_nome: String,
208        /// Porta local.
209        porta_local: u16,
210        /// Host remoto.
211        host_remoto: String,
212        /// Porta remota.
213        porta_remota: u16,
214        /// Timeout obrigatório do tunnel em milissegundos.
215        #[arg(long)]
216        timeout_ms: u64,
217        /// Override de senha SSH.
218        #[arg(long)]
219        password: Option<String>,
220        /// Override de chave.
221        #[arg(long)]
222        key: Option<String>,
223    },
224
225    /// Verifica conectividade SSH com uma VPS.
226    HealthCheck {
227        /// Nome da VPS (usa ativa se omitido).
228        vps_nome: Option<String>,
229        /// Saída em JSON (GAP-SSH-IO-002).
230        #[arg(long)]
231        json: bool,
232        /// Override de senha SSH.
233        #[arg(long)]
234        password: Option<String>,
235        /// Override de timeout SSH em milissegundos (GAP-SSH-CLI-004).
236        #[arg(long)]
237        timeout: Option<u64>,
238    },
239
240    /// Gerencia master-key e cifragem at-rest de secrets (one-shot).
241    Secrets {
242        /// Ação de secrets.
243        #[command(subcommand)]
244        acao: AcaoSecrets,
245    },
246
247    /// Gera completions de shell.
248    Completions {
249        /// Shell alvo.
250        #[arg(value_enum)]
251        shell: Shell,
252    },
253}
254
255/// Ações do subcomando `vps`.
256#[derive(Debug, Subcommand)]
257pub enum AcaoVps {
258    /// Adiciona uma nova VPS ao registro.
259    Add {
260        /// Nome único da VPS.
261        #[arg(long)]
262        name: String,
263        /// Hostname ou IP.
264        #[arg(long)]
265        host: String,
266        /// Porta SSH.
267        #[arg(long, default_value_t = 22)]
268        port: u16,
269        /// Usuário SSH.
270        #[arg(long)]
271        user: String,
272        /// Senha SSH.
273        #[arg(long, conflicts_with = "password_stdin")]
274        password: Option<String>,
275        /// Lê senha de stdin.
276        #[arg(long)]
277        password_stdin: bool,
278        /// Caminho da chave privada OpenSSH.
279        #[arg(long)]
280        key: Option<String>,
281        /// Passphrase da chave.
282        #[arg(long)]
283        key_passphrase: Option<String>,
284        /// Timeout em milissegundos (default 60000).
285        #[arg(long, default_value_t = 60_000)]
286        timeout: u64,
287        /// Limite de caracteres do comando (entrada). Alias legado: maxChars.
288        #[arg(long)]
289        max_command_chars: Option<String>,
290        /// Limite de caracteres de saída.
291        #[arg(long)]
292        max_output_chars: Option<String>,
293        /// Alias legado: mapeia para max_command_chars .
294        #[arg(long, alias = "maxChars")]
295        max_chars: Option<String>,
296        /// Senha para `sudo`.
297        #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
298        sudo_password: Option<String>,
299        /// Lê senha sudo de stdin.
300        #[arg(long)]
301        sudo_password_stdin: bool,
302        /// Senha para `su -`.
303        #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
304        su_password: Option<String>,
305        /// Lê senha su de stdin.
306        #[arg(long)]
307        su_password_stdin: bool,
308        /// Desabilita sudo/su neste host.
309        #[arg(long, default_value_t = false)]
310        disable_sudo: bool,
311        /// Roda health-check após add.
312        #[arg(long)]
313        check: bool,
314    },
315
316    /// Lista todas as VPSs (senhas mascaradas).
317    List {
318        /// Saída em JSON.
319        #[arg(long)]
320        json: bool,
321    },
322
323    /// Remove uma VPS do registro.
324    Remove {
325        /// Nome da VPS a remover.
326        nome: String,
327    },
328
329    /// Edita campos de uma VPS existente.
330    Edit {
331        /// Nome da VPS a editar.
332        nome: String,
333        /// Novo hostname/IP.
334        #[arg(long)]
335        host: Option<String>,
336        /// Nova porta SSH.
337        #[arg(long)]
338        port: Option<u16>,
339        /// Novo usuário.
340        #[arg(long)]
341        user: Option<String>,
342        /// Nova senha.
343        #[arg(long, conflicts_with = "password_stdin")]
344        password: Option<String>,
345        /// Lê senha de stdin.
346        #[arg(long)]
347        password_stdin: bool,
348        /// Nova chave.
349        #[arg(long)]
350        key: Option<String>,
351        /// Nova passphrase.
352        #[arg(long)]
353        key_passphrase: Option<String>,
354        /// Novo timeout.
355        #[arg(long)]
356        timeout: Option<u64>,
357        /// Novo max command chars.
358        #[arg(long)]
359        max_command_chars: Option<String>,
360        /// Novo max output chars.
361        #[arg(long)]
362        max_output_chars: Option<String>,
363        /// Alias legado maxChars → command.
364        #[arg(long, alias = "maxChars")]
365        max_chars: Option<String>,
366        /// Nova senha sudo.
367        #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
368        sudo_password: Option<String>,
369        /// Lê senha sudo de stdin.
370        #[arg(long)]
371        sudo_password_stdin: bool,
372        /// Nova senha su.
373        #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
374        su_password: Option<String>,
375        /// Lê senha su de stdin.
376        #[arg(long)]
377        su_password_stdin: bool,
378        /// Define disable_sudo.
379        #[arg(long)]
380        disable_sudo: Option<bool>,
381    },
382
383    /// Exibe detalhes de uma VPS (senhas mascaradas).
384    Show {
385        /// Nome da VPS.
386        nome: String,
387        /// Saída em JSON.
388        #[arg(long)]
389        json: bool,
390    },
391
392    /// Exibe o caminho do arquivo de configuração.
393    Path,
394
395    /// Diagnóstico de camadas XDG / path / schema.
396    Doctor {
397        /// Saída em JSON.
398        #[arg(long)]
399        json: bool,
400    },
401
402    /// Exporta hosts (senhas redacted por default).
403    Export {
404        /// Inclui segredos no export.
405        #[arg(long)]
406        include_secrets: bool,
407        /// Arquivo de saída (stdout se omitido).
408        #[arg(long, short)]
409        output: Option<String>,
410    },
411
412    /// Importa hosts de um TOML.
413    Import {
414        /// Arquivo de origem.
415        #[arg(long)]
416        file: PathBuf,
417        /// Permite hosts sem auth completa (export redacted / esqueleto) — GAP-SSH-IMP-001.
418        #[arg(long)]
419        allow_incomplete: bool,
420    },
421}
422
423/// Ações do subcomando `scp`.
424#[derive(Debug, Subcommand)]
425pub enum AcaoScp {
426    /// Upload de arquivo local para remote.
427    Upload {
428        /// Nome da VPS.
429        vps_nome: String,
430        /// Caminho local.
431        local: PathBuf,
432        /// Caminho remote.
433        remote: PathBuf,
434        /// Override de senha SSH.
435        #[arg(long)]
436        password: Option<String>,
437    },
438
439    /// Download de arquivo remote para local.
440    Download {
441        /// Nome da VPS.
442        vps_nome: String,
443        /// Caminho remote.
444        remote: PathBuf,
445        /// Caminho local.
446        local: PathBuf,
447        /// Override de senha SSH.
448        #[arg(long)]
449        password: Option<String>,
450    },
451}
452
453/// Ações do subcomando `secrets` (master-key / AEAD).
454#[derive(Debug, Subcommand)]
455pub enum AcaoSecrets {
456    /// Mostra status da cifragem (sem material sensível).
457    Status {
458        /// Saída em JSON.
459        #[arg(long)]
460        json: bool,
461    },
462    /// Gera e grava master-key (`secrets.key` ou keyring). Nunca imprime a chave.
463    Init {
464        /// Grava no OS keyring em vez de `secrets.key`.
465        #[arg(long)]
466        keyring: bool,
467        /// Sobrescreve chave existente.
468        #[arg(long)]
469        force: bool,
470    },
471    /// Regrava `config.toml` re-cifando secrets com a chave atual.
472    Reencrypt,
473}
474
475/// Faz parsing dos argumentos da CLI.
476#[must_use]
477pub fn parse_args() -> Argumentos {
478    Argumentos::parse()
479}
480
481/// Inicializa `tracing-subscriber`.
482///
483/// GAP-SSH-LOG-001 (0.3.9): default **error** (agent-first). `-v` → debug.
484/// `RUST_LOG` vence tudo. Nunca INFO por omissão em JSON/non-TTY.
485pub fn inicializar_logs(args: &Argumentos) {
486    use tracing_subscriber::{fmt, EnvFilter};
487
488    let filter = if std::env::var("RUST_LOG").is_ok() {
489        EnvFilter::from_default_env()
490    } else if args.verbose {
491        EnvFilter::new("debug")
492    } else {
493        // quiet e default humano/agente: error (sem prosa INFO em stderr).
494        let _ = args.quiet;
495        EnvFilter::new("error")
496    };
497
498    let _ = fmt()
499        .with_env_filter(filter)
500        .with_writer(std::io::stderr)
501        .with_target(false)
502        .with_ansi(false)
503        .try_init();
504}
505
506/// Gera completions de shell para stdout.
507///
508/// GAP-SSH-CLI-003: broken pipe (EPIPE) não panic — comportamento Unix de pipe.
509pub fn gerar_completions(shell: Shell) {
510    use clap::CommandFactory;
511    use std::io::Write;
512    let mut cmd = Argumentos::command();
513    let mut buf: Vec<u8> = Vec::new();
514    clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
515    let mut out = std::io::stdout().lock();
516    if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
517        if e.kind() == std::io::ErrorKind::BrokenPipe {
518            return;
519        }
520        // Outros erros: best-effort em stderr sem panic.
521        let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
522    }
523}
524
525fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
526    if flag {
527        Ok(Some(crate::vps::ler_segredo_stdin()?))
528    } else {
529        Ok(valor)
530    }
531}
532
533/// Resolve formato de saída: explícito > `SSH_CLI_FORCE_TEXT` > JSON se não-TTY > Text.
534#[must_use]
535pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
536    if let Some(f) = explicit {
537        return f;
538    }
539    // Isolamento de testes / scripts que forçam prosa humana em pipe.
540    if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
541        return FormatoSaida::Text;
542    }
543    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
544        FormatoSaida::Json
545    } else {
546        FormatoSaida::Text
547    }
548}
549
550/// Executa o subcomando solicitado.
551pub async fn executar(args: Argumentos) -> Result<()> {
552    let config_override = args.config_dir.clone();
553    // Alinha `secrets.key` com `--config-dir` / testes isolados.
554    crate::secrets::definir_diretorio_config(config_override.clone());
555    let formato = resolver_formato(args.output_format);
556    // GAP-SSH-IO-003 / IO-004: política de I/O centralizada.
557    crate::output::definir_quiet(args.quiet);
558    crate::output::definir_json_erros(formato == FormatoSaida::Json);
559    let disable_sudo = args.disable_sudo;
560    let replace_host_key = args.replace_host_key;
561
562    match args.comando {
563        Comando::Vps { acao } => {
564            crate::vps::executar_comando_vps(acao, config_override, formato).await
565        }
566        Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
567        Comando::Exec {
568            vps_nome,
569            comando,
570            json,
571            password,
572            password_stdin,
573            key,
574            key_passphrase,
575            key_passphrase_stdin,
576            timeout,
577            description,
578        } => {
579            let password = ler_stdin_se(password_stdin, password)?;
580            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
581            let opts = crate::vps::OpcoesExec {
582                password,
583                key,
584                key_passphrase,
585                timeout,
586                description,
587                replace_host_key,
588                disable_sudo,
589                ..Default::default()
590            };
591            crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
592                .await
593        }
594        Comando::SudoExec {
595            vps_nome,
596            comando,
597            json,
598            password,
599            password_stdin,
600            sudo_password,
601            sudo_password_stdin,
602            key,
603            key_passphrase,
604            key_passphrase_stdin,
605            timeout,
606            description,
607        } => {
608            let password = ler_stdin_se(password_stdin, password)?;
609            let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
610            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
611            let opts = crate::vps::OpcoesExec {
612                password,
613                sudo_password,
614                key,
615                key_passphrase,
616                timeout,
617                description,
618                replace_host_key,
619                disable_sudo,
620                ..Default::default()
621            };
622            crate::vps::executar_sudo_exec(
623                &vps_nome,
624                &comando,
625                config_override,
626                formato,
627                json,
628                opts,
629            )
630            .await
631        }
632        Comando::SuExec {
633            vps_nome,
634            comando,
635            json,
636            password,
637            password_stdin,
638            su_password,
639            su_password_stdin,
640            key,
641            key_passphrase,
642            key_passphrase_stdin,
643            timeout,
644            description,
645        } => {
646            let password = ler_stdin_se(password_stdin, password)?;
647            let su_password = ler_stdin_se(su_password_stdin, su_password)?;
648            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
649            let opts = crate::vps::OpcoesExec {
650                password,
651                su_password,
652                key,
653                key_passphrase,
654                timeout,
655                description,
656                replace_host_key,
657                disable_sudo,
658                ..Default::default()
659            };
660            crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
661                .await
662        }
663        Comando::Scp { acao } => {
664            let pwd = match &acao {
665                AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
666                    password.clone()
667                }
668            };
669            crate::scp::executar_scp(acao, config_override, pwd).await
670        }
671        Comando::Tunnel {
672            vps_nome,
673            porta_local,
674            host_remoto,
675            porta_remota,
676            timeout_ms,
677            password,
678            key,
679        } => {
680            crate::tunnel::executar_tunnel(
681                &vps_nome,
682                porta_local,
683                &host_remoto,
684                porta_remota,
685                config_override,
686                password,
687                key,
688                timeout_ms,
689                replace_host_key,
690            )
691            .await
692        }
693        Comando::HealthCheck {
694            vps_nome,
695            json,
696            password,
697            timeout,
698        } => {
699            crate::vps::executar_health_check(
700                vps_nome.as_deref(),
701                config_override,
702                formato,
703                json,
704                password,
705                timeout,
706            )
707            .await
708        }
709        Comando::Secrets { acao } => {
710            crate::vps::executar_comando_secrets(acao, config_override, formato).await
711        }
712        Comando::Completions { shell } => {
713            gerar_completions(shell);
714            Ok(())
715        }
716    }
717}
718
719#[cfg(test)]
720mod testes {
721    use super::*;
722    use clap::Parser;
723
724    #[test]
725    fn parser_entende_tunnel_com_timeout() {
726        let args = Argumentos::try_parse_from([
727            "ssh-cli",
728            "tunnel",
729            "vps-a",
730            "8080",
731            "127.0.0.1",
732            "5432",
733            "--timeout-ms",
734            "5000",
735        ])
736        .expect("tunnel");
737        match args.comando {
738            Comando::Tunnel {
739                timeout_ms,
740                porta_local,
741                ..
742            } => {
743                assert_eq!(timeout_ms, 5000);
744                assert_eq!(porta_local, 8080);
745            }
746            _ => panic!("esperado tunnel"),
747        }
748    }
749
750    #[test]
751    fn parser_vps_add_key() {
752        let args = Argumentos::try_parse_from([
753            "ssh-cli",
754            "vps",
755            "add",
756            "--name",
757            "x",
758            "--host",
759            "h",
760            "--user",
761            "u",
762            "--key",
763            "/tmp/id_ed25519",
764        ])
765        .expect("add key");
766        match args.comando {
767            Comando::Vps {
768                acao: AcaoVps::Add { key, password, .. },
769            } => {
770                assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
771                assert!(password.is_none());
772            }
773            _ => panic!("esperado add"),
774        }
775    }
776
777    #[test]
778    fn parser_sudo_exec_description() {
779        let args = Argumentos::try_parse_from([
780            "ssh-cli",
781            "sudo-exec",
782            "v",
783            "id",
784            "--description",
785            "who am i",
786        ])
787        .unwrap();
788        match args.comando {
789            Comando::SudoExec { description, .. } => {
790                assert_eq!(description.as_deref(), Some("who am i"));
791            }
792            _ => panic!("sudo-exec"),
793        }
794    }
795
796    #[test]
797    fn parser_su_exec() {
798        let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
799        assert!(matches!(args.comando, Comando::SuExec { .. }));
800    }
801
802    #[test]
803    fn parser_disable_sudo_global() {
804        let args =
805            Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
806        assert!(args.disable_sudo);
807    }
808
809    #[test]
810    fn parser_doctor() {
811        let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
812        match args.comando {
813            Comando::Vps {
814                acao: AcaoVps::Doctor { json },
815            } => assert!(json),
816            _ => panic!("doctor"),
817        }
818    }
819}