1use anyhow::Result;
14use clap::{Parser, Subcommand};
15use clap_complete::Shell;
16use std::path::PathBuf;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
20pub enum FormatoSaida {
21 #[default]
23 Text,
24 Json,
26}
27
28#[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 #[arg(long, global = true, value_name = "LOCALE")]
39 pub lang: Option<String>,
40
41 #[arg(short, long, global = true)]
43 pub verbose: bool,
44
45 #[arg(short, long, global = true)]
47 pub quiet: bool,
48
49 #[arg(long, global = true, value_name = "DIR")]
51 pub config_dir: Option<PathBuf>,
52
53 #[arg(long, global = true)]
55 pub no_color: bool,
56
57 #[arg(long, global = true, value_enum, default_value_t = FormatoSaida::Text)]
59 pub output_format: FormatoSaida,
60
61 #[command(subcommand)]
63 pub comando: Comando,
64}
65
66#[derive(Debug, Subcommand)]
68pub enum Comando {
69 Vps {
71 #[command(subcommand)]
73 acao: AcaoVps,
74 },
75
76 Connect {
78 nome: String,
80 },
81
82 Exec {
84 vps_nome: String,
86
87 comando: String,
89
90 #[arg(long)]
92 json: bool,
93 },
94
95 SudoExec {
97 vps_nome: String,
99
100 comando: String,
102
103 #[arg(long)]
105 json: bool,
106 },
107
108 Scp {
110 #[command(subcommand)]
112 acao: AcaoScp,
113 },
114
115 Tunnel {
117 vps_nome: String,
119
120 porta_local: u16,
122
123 host_remoto: String,
125
126 porta_remota: u16,
128 },
129
130 HealthCheck {
132 vps_nome: Option<String>,
134 },
135
136 Completions {
138 #[arg(value_enum)]
140 shell: Shell,
141 },
142}
143
144#[derive(Debug, Subcommand)]
146pub enum AcaoVps {
147 Add {
149 #[arg(long)]
151 name: String,
152
153 #[arg(long)]
155 host: String,
156
157 #[arg(long, default_value_t = 22)]
159 port: u16,
160
161 #[arg(long)]
163 user: String,
164
165 #[arg(long)]
167 password: Option<String>,
168
169 #[arg(long, default_value_t = 30_000)]
171 timeout: u64,
172
173 #[arg(long, default_value = "100000")]
175 max_chars: String,
176
177 #[arg(long)]
179 sudo_password: Option<String>,
180
181 #[arg(long)]
183 su_password: Option<String>,
184 },
185
186 List {
188 #[arg(long)]
190 json: bool,
191 },
192
193 Remove {
195 nome: String,
197 },
198
199 Edit {
201 nome: String,
203
204 #[arg(long)]
206 host: Option<String>,
207
208 #[arg(long)]
210 port: Option<u16>,
211
212 #[arg(long)]
214 user: Option<String>,
215
216 #[arg(long)]
218 password: Option<String>,
219
220 #[arg(long)]
222 timeout: Option<u64>,
223
224 #[arg(long)]
226 max_chars: Option<String>,
227
228 #[arg(long)]
230 sudo_password: Option<String>,
231
232 #[arg(long)]
234 su_password: Option<String>,
235 },
236
237 Show {
239 nome: String,
241
242 #[arg(long)]
244 json: bool,
245 },
246
247 Path,
249}
250
251#[derive(Debug, Subcommand)]
253pub enum AcaoScp {
254 Upload {
256 vps_nome: String,
258
259 local: PathBuf,
261
262 remote: PathBuf,
264 },
265
266 Download {
268 vps_nome: String,
270
271 remote: PathBuf,
273
274 local: PathBuf,
276 },
277}
278
279#[must_use]
281pub fn parse_args() -> Argumentos {
282 Argumentos::parse()
283}
284
285pub 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
307pub 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
314pub 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}