use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueHint};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None, after_long_help = GLOBAL_EXAMPLES)]
pub struct Args {
#[arg(
short = 'C',
long,
value_name = "DIR",
env = "VORTIX_CONFIG_DIR",
global = true,
value_hint = ValueHint::DirPath,
)]
pub config_dir: Option<PathBuf>,
#[arg(short = 'j', long, global = true)]
pub json: bool,
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
#[arg(short = 'v', long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
const GLOBAL_EXAMPLES: &str = "\
GLOBAL FLAGS:
-j, --json Machine-readable JSON output
-q, --quiet Suppress all output except errors
-v, --verbose Verbose debug output
-C, --config-dir Override config directory
ENVIRONMENT VARIABLES:
VORTIX_CONFIG_DIR Override config directory
EXIT CODES:
0 Success
1 General error
2 Permission denied (needs sudo)
3 Not found (profile doesn't exist)
4 State conflict (already connected/disconnected)
5 Missing dependency (wg-quick, openvpn)
6 Timeout";
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(visible_alias = "connect")]
Up {
#[arg(value_hint = ValueHint::Other)]
profile: Option<String>,
#[arg(long, default_value = "20", value_name = "SECS")]
timeout: u64,
#[arg(short, long)]
yes: bool,
},
#[command(visible_alias = "disconnect")]
Down {
#[arg(value_hint = ValueHint::Other, conflicts_with = "all")]
profile: Option<String>,
#[arg(long)]
all: bool,
#[arg(short, long)]
force: bool,
},
Reconnect {
#[arg(value_hint = ValueHint::Other)]
profile: Option<String>,
},
Status {
#[arg(short, long)]
watch: bool,
#[arg(long, default_value = "2", value_name = "SECS")]
interval: u64,
#[arg(short, long)]
brief: bool,
#[arg(long)]
no_daemon: bool,
},
#[command(visible_alias = "ls")]
List {
#[arg(short, long, value_name = "FIELD")]
sort: Option<String>,
#[arg(short, long)]
reverse: bool,
#[arg(short, long, value_name = "PROTO")]
protocol: Option<String>,
#[arg(short = '1', long)]
names_only: bool,
},
Import {
#[arg(value_hint = ValueHint::AnyPath)]
file: String,
},
Show {
#[arg(value_hint = ValueHint::Other)]
profile: String,
#[arg(long)]
raw: bool,
},
#[command(visible_alias = "rm")]
Delete {
#[arg(value_hint = ValueHint::Other)]
profile: String,
#[arg(short, long)]
yes: bool,
},
#[command(visible_alias = "mv")]
Rename {
old: String,
new: String,
},
#[command(name = "killswitch")]
KillSwitch {
mode: Option<String>,
},
ReleaseKillSwitch,
Info,
Update,
Report,
Daemon {
#[arg(long)]
socket: Option<std::path::PathBuf>,
},
Audit {
#[arg(long)]
pid: Option<u32>,
#[arg(long)]
vpn_only: bool,
},
Completions {
shell: clap_complete::Shell,
},
}
#[cfg(test)]
mod tests {
use super::{Args, Commands};
use clap::Parser;
fn parse(argv: &[&str]) -> Args {
Args::try_parse_from(argv).unwrap_or_else(|e| panic!("parse failed for {argv:?}: {e}"))
}
fn parse_err(argv: &[&str]) -> clap::Error {
Args::try_parse_from(argv).expect_err("expected parse to fail")
}
#[test]
fn cli_down_no_args_means_all_active() {
let args = parse(&["vortix", "down"]);
match args.command {
Some(Commands::Down {
profile,
all,
force,
}) => {
assert!(profile.is_none());
assert!(!all);
assert!(!force);
}
other => panic!("expected Down, got {other:?}"),
}
}
#[test]
fn cli_down_with_profile_positional() {
let args = parse(&["vortix", "down", "corp"]);
let Some(Commands::Down { profile, all, .. }) = args.command else {
panic!("expected Down");
};
assert_eq!(profile.as_deref(), Some("corp"));
assert!(!all);
}
#[test]
fn cli_down_all_flag_alone_parses() {
let args = parse(&["vortix", "down", "--all"]);
let Some(Commands::Down { profile, all, .. }) = args.command else {
panic!("expected Down");
};
assert!(profile.is_none());
assert!(all);
}
#[test]
fn cli_down_all_flag_conflicts_with_positional() {
let err = parse_err(&["vortix", "down", "corp", "--all"]);
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn cli_down_keeps_force_flag() {
let args = parse(&["vortix", "down", "--force"]);
let Some(Commands::Down {
profile,
all,
force,
}) = args.command
else {
panic!("expected Down");
};
assert!(profile.is_none());
assert!(!all);
assert!(force);
}
#[test]
fn cli_reconnect_no_args() {
let args = parse(&["vortix", "reconnect"]);
let Some(Commands::Reconnect { profile }) = args.command else {
panic!("expected Reconnect");
};
assert!(profile.is_none());
}
#[test]
fn cli_reconnect_with_profile() {
let args = parse(&["vortix", "reconnect", "personal"]);
let Some(Commands::Reconnect { profile }) = args.command else {
panic!("expected Reconnect");
};
assert_eq!(profile.as_deref(), Some("personal"));
}
#[test]
fn cli_up_accepts_yes_flag() {
let args = parse(&["vortix", "up", "corp", "--yes"]);
let Some(Commands::Up {
profile,
timeout,
yes,
}) = args.command
else {
panic!("expected Up");
};
assert_eq!(profile.as_deref(), Some("corp"));
assert_eq!(timeout, 20);
assert!(yes);
}
#[test]
fn cli_up_yes_short_flag() {
let args = parse(&["vortix", "up", "corp", "-y"]);
let Some(Commands::Up { yes, .. }) = args.command else {
panic!("expected Up");
};
assert!(yes);
}
#[test]
fn cli_up_without_yes_defaults_false() {
let args = parse(&["vortix", "up", "corp"]);
let Some(Commands::Up {
profile,
timeout,
yes,
}) = args.command
else {
panic!("expected Up");
};
assert_eq!(profile.as_deref(), Some("corp"));
assert_eq!(timeout, 20);
assert!(
!yes,
"yes must default to false to keep current scripts unaffected"
);
}
}