use std::net::IpAddr;
use std::path::PathBuf;
use shep_core::config::ResetDepth;
#[derive(Debug, clap::Args)]
pub struct StartArgs {
#[arg(num_args = 0..)]
pub targets: Vec<String>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub fold: Option<String>,
#[arg(long)]
pub cwd: Option<String>,
#[arg(long)]
pub interpreter: Option<String>,
#[arg(long)]
pub flockfile: bool,
#[arg(
long,
value_enum,
num_args = 0..=1,
require_equals = true,
default_missing_value = ""
)]
pub reset: Option<ResetMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[value(rename_all = "snake_case")]
pub enum ResetMode {
File,
Policy,
Env,
All,
}
impl ResetMode {
#[must_use]
pub fn to_depth(self) -> ResetDepth {
match self {
Self::File => ResetDepth::File,
Self::Policy => ResetDepth::Policy,
Self::Env => ResetDepth::Env,
Self::All => ResetDepth::All,
}
}
}
#[derive(Debug, PartialEq, Eq, clap::Args)]
pub struct ServeArgs {
pub root: PathBuf,
#[arg(long, default_value_t = 8080)]
pub port: u16,
#[arg(long, default_value = "127.0.0.1")]
pub bind: IpAddr,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub fold: Option<String>,
#[arg(long)]
pub spa: bool,
#[arg(long)]
pub listing: bool,
#[arg(long)]
pub hidden: bool,
#[arg(long)]
pub follow_symlinks: bool,
#[arg(long)]
pub auth: Option<PathBuf>,
#[arg(long)]
pub foreground: bool,
}
#[derive(Debug, clap::Args)]
pub struct SelectorArgs {
#[arg(required = true, num_args = 1..)]
pub selectors: Vec<String>,
}
#[derive(Debug, clap::Args)]
pub struct StockArgs {
pub name: String,
#[arg(value_parser = clap::value_parser!(u32).range(1..))]
pub count: u32,
}
#[derive(Debug, clap::Args)]
pub struct TriggerArgs {
pub selector: String,
pub action: String,
pub params: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct SignalArgs {
pub selector: String,
pub signal: String,
}
#[derive(Debug, clap::Args)]
pub struct WhisperArgs {
pub selector: String,
pub line: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, Commands};
#[test]
fn start_takes_a_flockfile_flag_and_defaults_it_off() {
use clap::Parser;
let plain = Cli::try_parse_from(["shep", "start", "srv.js"]).unwrap();
let flagged = Cli::try_parse_from(["shep", "start", "srv.js", "--flockfile"]).unwrap();
match (plain.command, flagged.command) {
(Commands::Start(a), Commands::Start(b)) => {
assert!(!a.flockfile, "absent means script form");
assert!(b.flockfile);
}
other => panic!("expected two Start commands, got {other:?}"),
}
}
#[test]
fn every_reset_mode_parses_from_its_argv_spelling() {
use clap::Parser;
fn parse_start(argv: &[&str]) -> StartArgs {
match Cli::try_parse_from(argv).unwrap().command {
Commands::Start(args) => args,
other => panic!("expected start, got {other:?}"),
}
}
assert_eq!(parse_start(&["shep", "start", "F.toml"]).reset, None);
for (spelling, mode) in [
("file", ResetMode::File),
("policy", ResetMode::Policy),
("env", ResetMode::Env),
("all", ResetMode::All),
] {
let flag = format!("--reset={spelling}");
assert_eq!(
parse_start(&["shep", "start", "F.toml", &flag]).reset,
Some(mode),
"argv spelling {spelling:?}"
);
}
}
#[test]
fn reset_with_no_value_is_a_usage_error_naming_every_mode() {
use clap::Parser;
let err = Cli::try_parse_from(["shep", "start", "F.toml", "--reset"]).unwrap_err();
assert_eq!(
err.to_string(),
"error: a value is required for '--reset[=<RESET>]' but none was supplied\n \
[possible values: file, policy, env, all]\n\n\
For more information, try '--help'.\n"
);
}
#[test]
fn a_reset_mode_does_not_swallow_the_target() {
use clap::Parser;
let parsed = Cli::try_parse_from(["shep", "start", "F.toml", "--reset=file"]).unwrap();
match parsed.command {
Commands::Start(args) => {
assert_eq!(args.targets, vec!["F.toml".to_string()]);
assert_eq!(args.reset, Some(ResetMode::File));
}
other => panic!("expected start, got {other:?}"),
}
assert!(
Cli::try_parse_from(["shep", "start", "F.toml", "--reset", "file"]).is_err(),
"the space-separated form must be refused, not resolved"
);
}
#[test]
fn an_unknown_reset_mode_is_the_same_refusal_as_no_value() {
use clap::Parser;
let err = Cli::try_parse_from(["shep", "start", "F.toml", "--reset=banana"]).unwrap_err();
assert_eq!(
err.to_string(),
"error: invalid value 'banana' for '--reset[=<RESET>]'\n \
[possible values: file, policy, env, all]\n\n\
For more information, try '--help'.\n"
);
}
#[test]
fn serve_binds_loopback_on_port_8080_unless_told_otherwise() {
use clap::Parser;
use std::net::{IpAddr, Ipv4Addr};
let cli = Cli::try_parse_from(["shep", "serve", "./x"]).unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected serve")
};
assert_eq!(args.bind, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_eq!(args.port, 8080);
assert!(!args.listing, "decision 9");
assert!(!args.hidden, "decision 4");
assert!(
!args.follow_symlinks,
"decision 5 — the refusal is the safe default"
);
}
#[test]
fn stock_refuses_a_count_of_zero_before_it_reaches_the_wire() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "stock", "web", "0"]).is_err());
assert!(Cli::try_parse_from(["shep", "stock", "web", "1"]).is_ok());
}
#[test]
fn stock_requires_both_the_name_and_the_count() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "stock"]).is_err());
assert!(Cli::try_parse_from(["shep", "stock", "web"]).is_err());
}
#[test]
fn a_selector_taking_verb_refuses_to_run_without_one() {
use clap::{CommandFactory, Parser};
for verb in [
"stop", "restart", "reload", "delete", "describe", "thatlldo",
] {
assert!(
Cli::try_parse_from(["shep", verb]).is_err(),
"`shep {verb}` with no selector must be a usage error, never \
the whole flock"
);
assert!(
Cli::try_parse_from(["shep", verb, "web"]).is_ok(),
"`shep {verb} web` must still parse"
);
}
assert!(
Cli::try_parse_from(["shep", "trigger"]).is_err(),
"`shep trigger` with neither selector nor action must be a usage error"
);
assert!(
Cli::try_parse_from(["shep", "trigger", "web", "reload-config"]).is_ok(),
"`shep trigger web reload-config` (selector, then action) must parse"
);
let cmd = Cli::command();
let trigger = cmd.find_subcommand("trigger").unwrap();
let selector_arg = trigger
.get_arguments()
.find(|a| a.get_id().as_str() == "selector")
.expect("TriggerArgs must still carry a `selector` field");
assert!(
selector_arg.is_required_set(),
"trigger's selector must stay required, never default to the whole flock"
);
}
}