use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_complete::Shell;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum FormatoSaida {
#[default]
Text,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "ssh-cli",
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
about = "CLI Rust para LLMs operarem servidores via SSH.",
long_about = None,
)]
pub struct Argumentos {
#[arg(long, global = true, value_name = "LOCALE")]
pub lang: Option<String>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(long, global = true, value_name = "DIR")]
pub config_dir: Option<PathBuf>,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, global = true, value_enum, default_value_t = FormatoSaida::Text)]
pub output_format: FormatoSaida,
#[command(subcommand)]
pub comando: Comando,
}
#[derive(Debug, Subcommand)]
pub enum Comando {
Vps {
#[command(subcommand)]
acao: AcaoVps,
},
Connect {
nome: String,
},
Exec {
vps_nome: String,
comando: String,
#[arg(long)]
json: bool,
#[arg(long)]
password: Option<String>,
#[arg(long)]
timeout: Option<u64>,
},
SudoExec {
vps_nome: String,
comando: String,
#[arg(long)]
json: bool,
#[arg(long)]
password: Option<String>,
#[arg(long, alias = "sudoPassword", alias = "sudo_password")]
sudo_password: Option<String>,
#[arg(long)]
timeout: Option<u64>,
},
Scp {
#[command(subcommand)]
acao: AcaoScp,
},
Tunnel {
vps_nome: String,
porta_local: u16,
host_remoto: String,
porta_remota: u16,
#[arg(long)]
password: Option<String>,
},
HealthCheck {
vps_nome: Option<String>,
#[arg(long)]
password: Option<String>,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
}
#[derive(Debug, Subcommand)]
pub enum AcaoVps {
Add {
#[arg(long)]
name: String,
#[arg(long)]
host: String,
#[arg(long, default_value_t = 22)]
port: u16,
#[arg(long)]
user: String,
#[arg(long)]
password: Option<String>,
#[arg(long, default_value_t = 30_000)]
timeout: u64,
#[arg(long, default_value = "100000", alias = "maxChars")]
max_chars: String,
#[arg(long, alias = "sudoPassword", alias = "sudo_password")]
sudo_password: Option<String>,
#[arg(long, alias = "suPassword", alias = "su_password")]
su_password: Option<String>,
},
List {
#[arg(long)]
json: bool,
},
Remove {
nome: String,
},
Edit {
nome: String,
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long, alias = "maxChars")]
max_chars: Option<String>,
#[arg(long, alias = "sudoPassword", alias = "sudo_password")]
sudo_password: Option<String>,
#[arg(long, alias = "suPassword", alias = "su_password")]
su_password: Option<String>,
},
Show {
nome: String,
#[arg(long)]
json: bool,
},
Path,
}
#[derive(Debug, Subcommand)]
pub enum AcaoScp {
Upload {
vps_nome: String,
local: PathBuf,
remote: PathBuf,
#[arg(long)]
password: Option<String>,
},
Download {
vps_nome: String,
remote: PathBuf,
local: PathBuf,
#[arg(long)]
password: Option<String>,
},
}
#[must_use]
pub fn parse_args() -> Argumentos {
Argumentos::parse()
}
pub fn inicializar_logs(args: &Argumentos) {
use tracing_subscriber::{fmt, EnvFilter};
let filter = if std::env::var("RUST_LOG").is_ok() {
EnvFilter::from_default_env()
} else if args.verbose {
EnvFilter::new("debug")
} else if args.quiet {
EnvFilter::new("error")
} else {
EnvFilter::new("info")
};
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.with_ansi(false)
.try_init();
}
pub fn gerar_completions(shell: Shell) {
use clap::CommandFactory;
let mut cmd = Argumentos::command();
clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
}
pub async fn executar(args: Argumentos) -> Result<()> {
let config_override = args.config_dir.clone();
let formato = args.output_format;
match args.comando {
Comando::Vps { acao } => {
crate::vps::executar_comando_vps(acao, config_override, formato).await
}
Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
Comando::Exec {
vps_nome,
comando,
json,
password,
timeout,
} => {
crate::vps::executar_exec(
&vps_nome,
&comando,
config_override,
formato,
json,
password,
timeout,
)
.await
}
Comando::SudoExec {
vps_nome,
comando,
json,
password,
sudo_password,
timeout,
} => {
crate::vps::executar_sudo_exec(
&vps_nome,
&comando,
config_override,
formato,
json,
password,
sudo_password,
timeout,
)
.await
}
Comando::Scp { acao } => {
let pwd = match &acao {
AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
password.clone()
}
};
crate::scp::executar_scp(acao, config_override, pwd).await
}
Comando::Tunnel {
vps_nome,
porta_local,
host_remoto,
porta_remota,
password,
} => {
crate::tunnel::executar_tunnel(
&vps_nome,
porta_local,
&host_remoto,
porta_remota,
config_override,
password,
)
.await
}
Comando::HealthCheck { vps_nome, password } => {
crate::vps::executar_health_check(
vps_nome.as_deref(),
config_override,
formato,
password,
)
.await
}
Comando::Completions { shell } => {
gerar_completions(shell);
Ok(())
}
}
}
#[cfg(test)]
mod testes {
use super::*;
use clap::Parser;
use serial_test::serial;
use tempfile::TempDir;
fn argumentos_teste(comando: Comando, config_dir: Option<PathBuf>) -> Argumentos {
Argumentos {
lang: None,
verbose: false,
quiet: false,
config_dir,
no_color: false,
output_format: FormatoSaida::Text,
comando,
}
}
#[test]
fn parser_entende_tunnel() {
let args =
Argumentos::try_parse_from(["ssh-cli", "tunnel", "vps-a", "8080", "127.0.0.1", "5432"])
.expect("parser deve aceitar subcomando tunnel");
match args.comando {
Comando::Tunnel {
vps_nome,
porta_local,
host_remoto,
porta_remota,
..
} => {
assert_eq!(vps_nome, "vps-a");
assert_eq!(porta_local, 8080);
assert_eq!(host_remoto, "127.0.0.1");
assert_eq!(porta_remota, 5432);
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn parser_entende_scp_upload() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"scp",
"upload",
"vps-a",
"./arquivo-local.txt",
"/tmp/arquivo-remoto.txt",
])
.expect("parser deve aceitar scp upload");
match args.comando {
Comando::Scp {
acao:
AcaoScp::Upload {
vps_nome,
local,
remote,
..
},
} => {
assert_eq!(vps_nome, "vps-a");
assert_eq!(local, PathBuf::from("./arquivo-local.txt"));
assert_eq!(remote, PathBuf::from("/tmp/arquivo-remoto.txt"));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
#[serial]
fn inicializar_logs_sem_panic_com_rust_log_definido() {
std::env::set_var("RUST_LOG", "trace");
let args = argumentos_teste(
Comando::Connect {
nome: "vps-a".to_string(),
},
None,
);
inicializar_logs(&args);
std::env::remove_var("RUST_LOG");
}
#[test]
#[serial]
fn inicializar_logs_sem_panic_com_verbose() {
std::env::remove_var("RUST_LOG");
let mut args = argumentos_teste(
Comando::Connect {
nome: "vps-a".to_string(),
},
None,
);
args.verbose = true;
inicializar_logs(&args);
}
#[test]
#[serial]
fn inicializar_logs_sem_panic_com_quiet() {
std::env::remove_var("RUST_LOG");
let mut args = argumentos_teste(
Comando::Connect {
nome: "vps-a".to_string(),
},
None,
);
args.quiet = true;
inicializar_logs(&args);
}
#[test]
#[serial]
fn inicializar_logs_sem_panic_no_padrao_info() {
std::env::remove_var("RUST_LOG");
let args = argumentos_teste(
Comando::Connect {
nome: "vps-a".to_string(),
},
None,
);
inicializar_logs(&args);
}
#[tokio::test]
async fn executar_branch_exec_retorna_erro_para_vps_inexistente() {
let tmp = TempDir::new().expect("tempdir");
let args = argumentos_teste(
Comando::Exec {
vps_nome: "inexistente".to_string(),
comando: "echo ok".to_string(),
json: false,
password: None,
timeout: None,
},
Some(tmp.path().to_path_buf()),
);
let resultado = executar(args).await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn executar_branch_sudo_exec_retorna_erro_para_vps_inexistente() {
let tmp = TempDir::new().expect("tempdir");
let args = argumentos_teste(
Comando::SudoExec {
vps_nome: "inexistente".to_string(),
comando: "id".to_string(),
json: false,
password: None,
sudo_password: None,
timeout: None,
},
Some(tmp.path().to_path_buf()),
);
let resultado = executar(args).await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn executar_branch_scp_retorna_erro_para_vps_inexistente() {
let tmp = TempDir::new().expect("tempdir");
let args = argumentos_teste(
Comando::Scp {
acao: AcaoScp::Upload {
vps_nome: "inexistente".to_string(),
local: PathBuf::from("./arquivo-local.txt"),
remote: PathBuf::from("/tmp/arquivo-remoto.txt"),
password: None,
},
},
Some(tmp.path().to_path_buf()),
);
let resultado = executar(args).await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn executar_branch_tunnel_retorna_erro_para_vps_inexistente() {
let tmp = TempDir::new().expect("tempdir");
let args = argumentos_teste(
Comando::Tunnel {
vps_nome: "inexistente".to_string(),
porta_local: 38080,
host_remoto: "127.0.0.1".to_string(),
porta_remota: 5432,
password: None,
},
Some(tmp.path().to_path_buf()),
);
let resultado = executar(args).await;
assert!(resultado.is_err());
}
#[test]
fn test_parse_no_color() {
let args = Argumentos::try_parse_from(["ssh-cli", "--no-color", "vps", "list"])
.expect("parser deve aceitar --no-color");
assert!(args.no_color);
}
#[test]
fn test_parse_output_format_json() {
let args =
Argumentos::try_parse_from(["ssh-cli", "--output-format", "json", "vps", "list"])
.expect("parser deve aceitar --output-format json");
assert_eq!(args.output_format, FormatoSaida::Json);
}
#[test]
fn test_parse_output_format_default() {
let args = Argumentos::try_parse_from(["ssh-cli", "vps", "list"])
.expect("parser deve aceitar subcomando sem output-format");
assert_eq!(args.output_format, FormatoSaida::Text);
}
#[test]
fn test_parse_completions_bash() {
let args = Argumentos::try_parse_from(["ssh-cli", "completions", "bash"])
.expect("parser deve aceitar completions bash");
assert!(matches!(
args.comando,
Comando::Completions { shell: Shell::Bash }
));
}
#[test]
fn test_parse_health_check_com_nome() {
let args = Argumentos::try_parse_from(["ssh-cli", "health-check", "meu-vps"])
.expect("parser deve aceitar health-check com nome");
match args.comando {
Comando::HealthCheck { vps_nome, .. } => {
assert_eq!(vps_nome, Some("meu-vps".to_string()));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn test_parse_health_check_sem_nome() {
let args = Argumentos::try_parse_from(["ssh-cli", "health-check"])
.expect("parser deve aceitar health-check sem nome");
match args.comando {
Comando::HealthCheck { vps_nome, .. } => {
assert!(vps_nome.is_none());
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn test_parse_exec_json() {
let args = Argumentos::try_parse_from(["ssh-cli", "exec", "vps1", "ls", "--json"])
.expect("parser deve aceitar exec com --json");
match args.comando {
Comando::Exec {
vps_nome,
comando,
json,
..
} => {
assert_eq!(vps_nome, "vps1");
assert_eq!(comando, "ls");
assert!(json);
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn exec_com_password_override() {
let args =
Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--password", "abc123"])
.expect("parser deve aceitar exec com --password");
match args.comando {
Comando::Exec {
vps_nome, password, ..
} => {
assert_eq!(vps_nome, "myvps");
assert_eq!(password, Some("abc123".to_string()));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn exec_com_timeout_override() {
let args =
Argumentos::try_parse_from(["ssh-cli", "exec", "myvps", "ls", "--timeout", "5000"])
.expect("parser deve aceitar exec com --timeout");
match args.comando {
Comando::Exec {
vps_nome, timeout, ..
} => {
assert_eq!(vps_nome, "myvps");
assert_eq!(timeout, Some(5000u64));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn sudo_exec_com_sudo_password() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"sudo-exec",
"myvps",
"apt update",
"--sudo-password",
"abc",
])
.expect("parser deve aceitar sudo-exec com --sudo-password");
match args.comando {
Comando::SudoExec {
vps_nome,
sudo_password,
..
} => {
assert_eq!(vps_nome, "myvps");
assert_eq!(sudo_password, Some("abc".to_string()));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn sudo_exec_alias_camelcase() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"sudo-exec",
"myvps",
"apt update",
"--sudoPassword",
"abc",
])
.expect("parser deve aceitar sudo-exec com --sudoPassword");
match args.comando {
Comando::SudoExec {
vps_nome,
sudo_password,
..
} => {
assert_eq!(vps_nome, "myvps");
assert_eq!(sudo_password, Some("abc".to_string()));
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
#[test]
fn vps_add_alias_camelcase() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"vps",
"add",
"--name",
"x",
"--host",
"1.2.3.4",
"--user",
"root",
"--sudoPassword",
"abc",
"--maxChars",
"5000",
])
.expect("parser deve aceitar vps add com aliases camelCase");
match args.comando {
Comando::Vps {
acao:
AcaoVps::Add {
sudo_password,
max_chars,
..
},
} => {
assert_eq!(sudo_password, Some("abc".to_string()));
assert_eq!(max_chars, "5000");
}
outro => panic!("comando inesperado: {outro:?}"),
}
}
}