use std::net::IpAddr;
use std::path::PathBuf;
#[cfg(test)]
const HELP_GROUPS: &[(&str, &[&str])] = &[
(
"Run things",
&[
"start", "serve", "stop", "restart", "reload", "delete", "stock",
],
),
(
"See what's up",
&["flock", "describe", "bleats", "lookout", "fold", "barks"],
),
(
"Survive reboots",
&["save", "muster", "startup", "unstartup"],
),
("Talk to a sheep", &["trigger", "signal", "whisper"]),
(
"The shepherd",
&["ping", "kill", "reopen", "flush", "set", "get", "unset"],
),
(
"Dogs and agents",
&["dogs", "enable", "disable", "adopt", "rehome", "whistle"],
),
("Foreground runs", &["runtime", "dev"]),
("Coming from pm2", &["import"]),
("Help", &["welcome", "init", "help", "completions", "style"]),
];
const HELP_TEMPLATE: &str = "\
{about}
{usage-heading} {usage}
Getting started
shep start server.js start it and keep it alive
shep flock see what's running
shep bleats server follow its output
shep save remember this flock across reboots
shep startup bring it back after a reboot
Run things start serve stop restart reload delete stock
See what's up flock describe bleats lookout fold barks
Survive reboots save muster startup unstartup
Talk to a sheep trigger signal whisper
The shepherd ping kill reopen flush set get unset
Dogs and agents dogs enable disable adopt rehome whistle
Foreground runs runtime dev
Coming from pm2 import
Help welcome init help completions style
Aliases flock: list, ls bleats: logs lookout: dash stock: scale whisper: sendline
Upgrading cargo install shep replaces the binary, not the running shepherd: shep daemon reload
{options}{after-help}";
#[derive(Debug, clap::Parser)]
#[command(
name = "shep",
bin_name = "shep",
version,
about = "A process manager for your flock",
propagate_version = true,
help_template = HELP_TEMPLATE,
after_help = "Run `shep help <command>` for one command, or `shep welcome` for the tour."
)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, clap::Args)]
pub struct GlobalArgs {
#[arg(long, global = true, value_enum, default_value_t = Format::Table)]
pub format: Format,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(long, global = true, value_enum)]
pub style: Option<crate::style::StyleLevel>,
#[arg(long, global = true, env = "SHEP_HOME")]
pub home: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Format {
Table,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum Init {
Systemd,
Openrc,
Launchd,
FreebsdRc,
OpenbsdRc,
}
#[derive(Debug, clap::Subcommand)]
pub enum Commands {
Start(StartArgs),
Serve(ServeArgs),
Stop(SelectorArgs),
Restart(SelectorArgs),
Reload(SelectorArgs),
Delete(SelectorArgs),
#[command(visible_alias = "scale")]
Stock(StockArgs),
#[command(visible_aliases = ["list", "ls"])]
Flock,
Dogs(DogsArgs),
Enable(EnableArgs),
Disable(DogArgs),
Adopt(AdoptArgs),
Rehome(DogArgs),
Describe(SelectorArgs),
Trigger(TriggerArgs),
Signal(SignalArgs),
#[command(visible_alias = "sendline")]
Whisper(WhisperArgs),
Fold(FoldArgs),
#[command(visible_alias = "logs")]
Bleats(BleatsArgs),
#[command(visible_alias = "dash")]
Lookout(LookoutArgs),
Whistle,
Reopen(ReopenArgs),
Flush(FlushArgs),
Barks(BarksArgs),
Set(KvSetArgs),
Get(KvGetArgs),
Unset(KvUnsetArgs),
Ping,
Kill,
Save,
#[command(alias = "resurrect")]
Muster,
Init(InitArgs),
Runtime(RuntimeArgs),
Dev(DevArgs),
Import(ImportArgs),
Startup(StartupArgs),
Unstartup(StartupArgs),
Completions(CompletionArgs),
Welcome,
Style(StyleArgs),
#[command(hide = true)]
Thatlldo(SelectorArgs),
#[command(hide = true)]
Daemon(DaemonArgs),
#[command(hide = true)]
Dog(DogArgs),
#[command(hide = true)]
Schema,
}
#[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,
}
#[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,
}
#[derive(Debug, clap::Args)]
pub struct FlushArgs {
#[arg(required_unless_present = "daemon", conflicts_with = "daemon")]
pub selector: Option<String>,
#[arg(long)]
pub daemon: bool,
}
#[derive(Debug, clap::Args)]
pub struct BarksArgs {
#[arg(long)]
pub tail: Option<usize>,
}
#[derive(Debug, clap::Args)]
pub struct KvSetArgs {
pub key: String,
pub value: String,
}
#[derive(Debug, clap::Args)]
pub struct KvGetArgs {
pub key: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct KvUnsetArgs {
#[arg(required_unless_present = "all", conflicts_with = "all")]
pub key: Option<String>,
#[arg(long)]
pub all: bool,
}
#[derive(Debug, clap::Args)]
pub struct FoldArgs {
pub name: String,
}
#[derive(Debug, clap::Args)]
pub struct DogsArgs {
#[arg(long)]
pub available: bool,
#[arg(value_name = "FILTER")]
pub filter: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct DogArgs {
pub name: String,
}
#[derive(Debug, clap::Args)]
pub struct EnableArgs {
pub name: String,
#[arg(long, hide = true)]
pub exec: Option<PathBuf>,
}
#[derive(Debug, clap::Args)]
pub struct AdoptArgs {
pub path: PathBuf,
#[arg(long)]
pub name: Option<String>,
}
const DEFAULT_SELECTOR: &str = "all";
pub const DEFAULT_BLEAT_LINES: usize = 15;
#[derive(Debug, clap::Args)]
pub struct BleatsArgs {
#[arg(default_value = DEFAULT_SELECTOR)]
pub selector: String,
#[arg(long)]
pub no_follow: bool,
#[arg(long, default_value_t = DEFAULT_BLEAT_LINES, value_name = "N")]
pub lines: usize,
#[arg(long, conflicts_with = "out")]
pub err: bool,
#[arg(long, conflicts_with = "err")]
pub out: bool,
}
#[derive(Debug, clap::Args)]
pub struct LookoutArgs {
#[arg(long)]
pub allow_control: bool,
}
#[derive(Debug, clap::Args)]
pub struct ReopenArgs {
#[arg(default_value = DEFAULT_SELECTOR)]
pub selector: String,
}
#[derive(Debug, clap::Args)]
pub struct ImportArgs {
#[arg(long)]
pub from: Option<PathBuf>,
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, clap::Args)]
pub struct StartupArgs {
#[arg(long)]
pub user: Option<String>,
#[arg(long, value_enum)]
pub init: Option<Init>,
}
#[derive(Debug, clap::Args)]
pub struct CompletionArgs {
#[arg(value_enum)]
pub shell: clap_complete::aot::Shell,
}
#[derive(Debug, clap::Args)]
pub struct StyleArgs {
#[arg(value_enum)]
pub level: Option<crate::style::StyleLevel>,
}
#[derive(Debug, clap::Args)]
pub struct DaemonArgs {
#[command(subcommand)]
pub cmd: Option<DaemonCmd>,
#[arg(long)]
pub no_restore: bool,
#[arg(long)]
pub foreground: bool,
#[arg(
long,
value_name = "BOOL",
num_args = 0..=1,
default_missing_value = "true",
value_parser = bool_flag
)]
pub log_json: Option<bool>,
#[arg(long, value_name = "LEVEL", value_parser = log_level_flag)]
pub log_level: Option<shep_core::config::LogLevel>,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(long, value_name = "DURATION", value_parser = duration_flag)]
pub max_cron_sleep: Option<shep_core::values::UpDuration>,
}
#[derive(Debug, clap::Subcommand)]
pub enum DaemonCmd {
Reload,
}
#[derive(Debug, clap::Args)]
pub struct InitArgs {
#[arg(value_name = "PATH")]
pub path: Option<PathBuf>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, clap::Args)]
pub struct RuntimeArgs {
pub target: Option<String>,
#[arg(long, hide = true)]
pub supervise: bool,
}
#[derive(Debug, clap::Args)]
pub struct DevArgs {
pub target: Option<String>,
#[arg(long)]
pub name: Option<String>,
}
fn bool_flag(value: &str) -> Result<bool, String> {
shep_core::config::parse_daemon_bool(value)
.ok_or_else(|| format!("expected one of 1, 0, true, false; got `{value}`"))
}
fn log_level_flag(value: &str) -> Result<shep_core::config::LogLevel, String> {
shep_core::config::LogLevel::from_name(value).ok_or_else(|| {
format!("expected one of off, error, warn, info, debug, trace; got `{value}`")
})
}
fn duration_flag(value: &str) -> Result<shep_core::values::UpDuration, String> {
value
.parse::<shep_core::values::UpDuration>()
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
fn workspace_web_dir() -> Option<PathBuf> {
let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../web"));
dir.is_dir().then(|| dir.to_path_buf())
}
fn read_workspace_web_file(relative: &str) -> Option<String> {
let dir = workspace_web_dir()?;
Some(
std::fs::read_to_string(dir.join(relative)).unwrap_or_else(|err| {
panic!("web/{relative} exists in the workspace but could not be read: {err}")
}),
)
}
#[test]
fn the_command_tree_parses_and_is_internally_consistent() {
use clap::CommandFactory;
Cli::command().debug_assert(); }
#[test]
fn every_visible_verb_reaches_the_docs_site_generator() {
use clap::CommandFactory;
let Some(generator) = read_workspace_web_file("scripts/generate-cli-reference.sh") else {
return;
};
const NOT_DOCUMENTED: &[&str] = &["help"];
let (_, rest) = generator
.split_once("VERBS=(")
.expect("the generator declares a VERBS array");
let (block, _) = rest.split_once(')').expect("the VERBS array closes");
let listed: Vec<&str> = block.split_whitespace().collect();
let command = Cli::command();
let missing: Vec<&str> = command
.get_subcommands()
.filter(|verb| !verb.is_hide_set())
.map(clap::Command::get_name)
.filter(|name| !NOT_DOCUMENTED.contains(name) && !listed.contains(name))
.collect();
assert!(
missing.is_empty(),
"these verbs would be missing from the published CLI reference: {missing:?}\n\
add them to VERBS in web/scripts/generate-cli-reference.sh and re-run it"
);
}
#[test]
fn every_visible_verb_appears_in_exactly_one_help_group() {
use clap::CommandFactory;
let command = Cli::command();
let visible: Vec<String> = command
.get_subcommands()
.filter(|s| !s.is_hide_set())
.map(|s| s.get_name().to_string())
.collect();
let filed: Vec<&str> = HELP_GROUPS
.iter()
.flat_map(|(_, verbs)| verbs.iter().copied())
.collect();
for verb in &visible {
let times = filed.iter().filter(|f| *f == verb).count();
assert_eq!(
times, 1,
"`{verb}` appears in {times} help groups; it must appear in exactly one"
);
}
for name in &filed {
assert!(
*name == "help" || visible.iter().any(|v| v == name),
"a help group names `{name}`, which is not a visible verb"
);
}
}
#[test]
fn the_help_template_names_every_visible_alias() {
use clap::CommandFactory;
let command = Cli::command();
let mut expected: Vec<String> = command
.get_subcommands()
.filter(|s| !s.is_hide_set())
.filter_map(|s| {
let aliases: Vec<&str> = s.get_visible_aliases().collect();
(!aliases.is_empty()).then(|| format!("{}: {}", s.get_name(), aliases.join(", ")))
})
.collect();
expected.sort();
let line = HELP_TEMPLATE
.lines()
.find(|l| l.starts_with("Aliases"))
.expect("HELP_TEMPLATE has an Aliases line");
for entry in &expected {
assert!(
line.contains(entry.as_str()),
"`--help`'s Aliases line does not name `{entry}`: {line}"
);
}
for token in line.trim_start_matches("Aliases").split_whitespace() {
if let Some(verb) = token.strip_suffix(':') {
assert!(
expected.iter().any(|e| e.starts_with(&format!("{verb}:"))),
"`--help` names aliases for `{verb}`, which has none"
);
}
}
}
#[test]
fn the_help_template_names_the_upgrade_path() {
let line = HELP_TEMPLATE
.lines()
.find(|l| l.starts_with("Upgrading"))
.expect("HELP_TEMPLATE has an Upgrading line");
assert_eq!(
line,
"Upgrading cargo install shep replaces the binary, not the running shepherd: shep daemon reload"
);
}
#[test]
fn the_help_template_and_the_group_table_agree() {
for (heading, verbs) in HELP_GROUPS {
let line = HELP_TEMPLATE
.lines()
.find(|l| l.starts_with(heading))
.unwrap_or_else(|| panic!("`{heading}` is missing from HELP_TEMPLATE"));
for verb in *verbs {
assert!(
line.split_whitespace().any(|w| w == *verb),
"`{verb}` is filed under `{heading}` but is not on that line: {line}"
);
}
}
}
#[test]
fn the_help_opens_with_a_worked_example() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
assert!(
help.contains("Getting started"),
"no getting-started block:\n{help}"
);
assert!(
help.contains("shep start server.js"),
"no worked example:\n{help}"
);
}
#[test]
fn home_is_the_last_global_option_a_reader_meets() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
let home = help.find("--home").expect("--home is still documented");
let format = help.find("--format").expect("--format is documented");
let quiet = help.find("--quiet").expect("--quiet is documented");
assert!(
home > format && home > quiet,
"--home must come after the options people actually choose:\n{help}"
);
}
#[test]
fn the_top_level_help_carries_no_implementation_notes() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
for leak in [
"bin_name",
"Phase 15",
"load-bearing",
"argv[0]",
"{options}",
"{all-args}",
"help_heading",
] {
assert!(
!help.contains(leak),
"`shep --help` still contains the internal note {leak:?}:\n{help}"
);
}
}
#[test]
fn the_top_level_help_has_no_dashes_or_doc_link_syntax() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
assert!(
!help.contains('\u{2014}'),
"an em dash reached --help, which this project's copy rules forbid:\n{help}"
);
assert!(
!help.contains('\u{2013}'),
"an en dash reached --help, which this project's copy rules forbid:\n{help}"
);
assert!(
!help.contains("[`"),
"Rust intra-doc-link syntax reached --help -- an aside meant for a reader of the \
source, not the terminal, belongs on a `//` comment rather than a `///` one:\n{help}"
);
}
#[test]
fn log_json_has_three_states() {
use clap::Parser;
let cases = [
(vec!["shep", "daemon"], None),
(vec!["shep", "daemon", "--log-json"], Some(true)),
(vec!["shep", "daemon", "--log-json=false"], Some(false)),
(vec!["shep", "daemon", "--log-json=1"], Some(true)),
];
for (argv, expected) in cases {
match Cli::try_parse_from(&argv).unwrap().command {
Commands::Daemon(args) => assert_eq!(args.log_json, expected, "{argv:?}"),
other => panic!("expected Daemon, got {other:?}"),
}
}
}
#[test]
fn a_bare_shep_daemon_still_boots_and_is_not_a_subcommand_error() {
use clap::Parser;
let parsed =
Cli::try_parse_from(["shep", "daemon"]).expect("`shep daemon` must still parse");
let Commands::Daemon(args) = parsed.command else {
panic!("`shep daemon` must still parse as the daemon verb");
};
assert!(
args.cmd.is_none(),
"bare `shep daemon` must remain the boot path"
);
}
#[test]
fn the_daemon_flags_still_parse_alongside_the_subcommand() {
use clap::Parser;
let argv = [
"shep",
"daemon",
"--foreground",
"--no-restore",
"--log-json=false",
"--log-level",
"info",
"--socket",
"run/shep.sock",
"--max-cron-sleep",
"30s",
];
let parsed = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed: {e}"));
let Commands::Daemon(args) = parsed.command else {
panic!("expected the daemon verb")
};
assert!(args.foreground);
assert!(args.no_restore);
assert_eq!(args.log_json, Some(false));
assert_eq!(args.log_level, Some(shep_core::config::LogLevel::Info));
assert_eq!(args.socket.as_deref(), Some(Path::new("run/shep.sock")));
assert_eq!(
args.max_cron_sleep
.map(shep_core::values::UpDuration::as_duration),
Some(std::time::Duration::from_secs(30))
);
assert!(
args.cmd.is_none(),
"flags alone must not select a subcommand"
);
}
#[test]
fn daemon_reload_parses_as_the_reload_subcommand() {
use clap::Parser;
let parsed = Cli::try_parse_from(["shep", "daemon", "reload"])
.expect("`shep daemon reload` must parse");
let Commands::Daemon(args) = parsed.command else {
panic!("expected the daemon verb")
};
assert!(
matches!(args.cmd, Some(DaemonCmd::Reload)),
"got {:?}",
args.cmd
);
}
#[test]
fn the_flag_bool_grammar_matches_the_env_grammar() {
use clap::Parser;
for wider in ["--log-json=yes", "--log-json=on", "--log-json=TRUE"] {
assert!(
Cli::try_parse_from(["shep", "daemon", wider]).is_err(),
"{wider} must not parse"
);
}
}
#[test]
fn runtime_parses_and_its_supervise_flag_is_hidden() {
use clap::Parser;
let bare = Cli::try_parse_from(["shep", "runtime"]).unwrap();
let Commands::Runtime(args) = bare.command else {
panic!("expected runtime")
};
assert_eq!(args.target, None, "no target means discover");
assert!(!args.supervise, "a person never sets this");
let with_target = Cli::try_parse_from(["shep", "runtime", "./Flockfile.toml"]).unwrap();
let Commands::Runtime(args) = with_target.command else {
panic!("expected runtime")
};
assert_eq!(args.target.as_deref(), Some("./Flockfile.toml"));
let supervised = Cli::try_parse_from(["shep", "runtime", "--supervise"]).unwrap();
let Commands::Runtime(args) = supervised.command else {
panic!("expected runtime")
};
assert!(args.supervise, "the init passes --supervise to its child");
use clap::CommandFactory;
let cmd = Cli::command();
let runtime = cmd.find_subcommand("runtime").unwrap();
assert!(!runtime.is_hide_set(), "runtime is a real, documented verb");
let supervise_arg = runtime
.get_arguments()
.find(|a| a.get_id().as_str() == "supervise")
.expect("RuntimeArgs must still carry a hidden `supervise` field");
assert!(
supervise_arg.is_hide_set(),
"--supervise must stay hidden from --help"
);
}
#[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 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 list_and_ls_both_reach_flock() {
use clap::Parser;
for argv in [["shep", "flock"], ["shep", "list"], ["shep", "ls"]] {
assert!(matches!(
Cli::try_parse_from(argv).unwrap().command,
Commands::Flock
));
}
}
#[test]
fn logs_reaches_bleats() {
use clap::Parser;
assert!(matches!(
Cli::try_parse_from(["shep", "logs"]).unwrap().command,
Commands::Bleats(_)
));
}
#[test]
fn bleats_and_reopen_default_to_every_sheep() {
use clap::Parser;
let bare = Cli::try_parse_from(["shep", "reopen"]).unwrap().command;
let Commands::Reopen(args) = bare else {
panic!("`shep reopen` must parse with no selector")
};
assert_eq!(args.selector, "all");
let bare = Cli::try_parse_from(["shep", "bleats"]).unwrap().command;
let Commands::Bleats(args) = bare else {
panic!("`shep bleats` must parse with no selector")
};
assert_eq!(args.selector, "all");
let named = Cli::try_parse_from(["shep", "reopen", "web"])
.unwrap()
.command;
let Commands::Reopen(args) = named else {
panic!("expected reopen")
};
assert_eq!(args.selector, "web");
}
#[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 stock_and_scale_both_reach_stock() {
use clap::Parser;
for argv in [["shep", "stock", "web", "3"], ["shep", "scale", "web", "3"]] {
assert!(matches!(
Cli::try_parse_from(argv).unwrap().command,
Commands::Stock(_)
));
}
}
#[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"
);
}
#[test]
fn flush_refuses_to_run_without_a_selector() {
use clap::Parser;
assert!(
Cli::try_parse_from(["shep", "flush"]).is_err(),
"`shep flush` with no selector must be a usage error, never the \
whole flock"
);
let named = Cli::try_parse_from(["shep", "flush", "all"])
.unwrap()
.command;
let Commands::Flush(args) = named else {
panic!("expected flush")
};
assert_eq!(args.selector.as_deref(), Some("all"));
assert!(
!args.daemon,
"a plain flush must not reach the shepherd's own logs"
);
}
#[test]
fn the_daemon_flag_replaces_the_selector_rather_than_riding_along_with_it() {
use clap::Parser;
let bare = Cli::try_parse_from(["shep", "flush", "--daemon"])
.expect("`shep flush --daemon` is the only spelling there is")
.command;
let Commands::Flush(args) = bare else {
panic!("expected flush")
};
assert!(args.daemon);
assert_eq!(args.selector, None);
assert!(
Cli::try_parse_from(["shep", "flush", "all", "--daemon"]).is_err(),
"the shepherd's own logs are a separate act, never a rider on a \
flock-wide flush"
);
}
#[test]
fn barks_takes_no_selector_and_tail_defaults_to_everything() {
use clap::Parser;
let bare = Cli::try_parse_from(["shep", "barks"]).unwrap().command;
let Commands::Barks(args) = bare else {
panic!("`shep barks` must parse with no selector")
};
assert_eq!(args.tail, None);
let tailed = Cli::try_parse_from(["shep", "barks", "--tail", "20"])
.unwrap()
.command;
let Commands::Barks(args) = tailed else {
panic!("expected barks")
};
assert_eq!(args.tail, Some(20));
}
#[test]
fn unset_needs_a_key_or_the_all_flag() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "unset"]).is_err());
assert!(Cli::try_parse_from(["shep", "unset", "a"]).is_ok());
assert!(Cli::try_parse_from(["shep", "unset", "--all"]).is_ok());
}
#[test]
fn unset_refuses_a_key_and_all_together() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "unset", "a", "--all"]).is_err());
}
#[test]
fn get_takes_an_optional_key() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "get"]).is_ok());
assert!(Cli::try_parse_from(["shep", "get", "a"]).is_ok());
}
#[test]
fn set_needs_both_a_key_and_a_value() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "set", "a"]).is_err());
assert!(Cli::try_parse_from(["shep", "set", "a", "1"]).is_ok());
}
#[test]
fn format_defaults_to_table_and_accepts_json() {
use clap::Parser;
let cli = Cli::try_parse_from(["shep", "flock"]).unwrap();
assert_eq!(cli.global.format, Format::Table);
let cli = Cli::try_parse_from(["shep", "--format", "json", "flock"]).unwrap();
assert_eq!(cli.global.format, Format::Json);
}
#[test]
fn style_flag_defaults_to_unset_and_accepts_the_three_levels() {
use crate::style::StyleLevel;
use clap::Parser;
let cli = Cli::try_parse_from(["shep", "flock"]).unwrap();
assert_eq!(cli.global.style, None);
for (raw, expected) in [
("full", StyleLevel::Full),
("plain", StyleLevel::Plain),
("bare", StyleLevel::Bare),
] {
let cli = Cli::try_parse_from(["shep", "--style", raw, "flock"]).unwrap();
assert_eq!(cli.global.style, Some(expected), "--style {raw}");
}
assert!(Cli::try_parse_from(["shep", "--style", "loud", "flock"]).is_err());
}
#[test]
fn style_verb_parses_the_same_grammar_as_the_style_flag() {
use crate::style::StyleLevel;
use clap::Parser;
let cli = Cli::try_parse_from(["shep", "style"]).unwrap();
match cli.command {
Commands::Style(args) => {
assert_eq!(args.level, None, "bare `shep style` still reports")
}
other => panic!("expected Style, got {other:?}"),
}
for (raw, expected) in [
("full", StyleLevel::Full),
("plain", StyleLevel::Plain),
("bare", StyleLevel::Bare),
] {
let cli = Cli::try_parse_from(["shep", "style", raw]).unwrap();
match cli.command {
Commands::Style(args) => assert_eq!(args.level, Some(expected), "style {raw}"),
other => panic!("expected Style, got {other:?}"),
}
}
let bad_flag = Cli::try_parse_from(["shep", "--style", "loud", "flock"]).unwrap_err();
let bad_verb = Cli::try_parse_from(["shep", "style", "loud"]).unwrap_err();
assert_eq!(
bad_flag.kind(),
bad_verb.kind(),
"a bad value fails the same way through either spelling"
);
}
#[test]
fn home_flag_is_wired_to_the_shep_home_env_var() {
use clap::CommandFactory;
let cmd = Cli::command();
let home_arg = cmd
.get_arguments()
.find(|a| a.get_id().as_str() == "home")
.expect("GlobalArgs::home must still be a flattened argument named `home`");
assert_eq!(home_arg.get_env(), Some(std::ffi::OsStr::new("SHEP_HOME")));
}
#[test]
fn alias_visibility_and_hiding_are_pinned() {
use clap::CommandFactory;
let cmd = Cli::command();
let flock = cmd.find_subcommand("flock").unwrap();
assert_eq!(
flock.get_visible_aliases().collect::<Vec<_>>(),
["list", "ls"]
);
let bleats = cmd.find_subcommand("bleats").unwrap();
assert_eq!(bleats.get_visible_aliases().collect::<Vec<_>>(), ["logs"]);
let stock = cmd.find_subcommand("stock").unwrap();
assert_eq!(stock.get_visible_aliases().collect::<Vec<_>>(), ["scale"]);
let whisper = cmd.find_subcommand("whisper").unwrap();
assert_eq!(
whisper.get_visible_aliases().collect::<Vec<_>>(),
["sendline"]
);
let lookout = cmd.find_subcommand("lookout").unwrap();
assert_eq!(lookout.get_visible_aliases().collect::<Vec<_>>(), ["dash"]);
for hidden in ["thatlldo", "daemon", "dog"] {
assert!(
cmd.find_subcommand(hidden).unwrap().is_hide_set(),
"{hidden} must stay hidden from --help"
);
}
for visible in [
"start",
"flock",
"bleats",
"lookout",
"reload",
"reopen",
"flush",
"barks",
"trigger",
"stock",
"whisper",
"enable",
"disable",
"adopt",
"rehome",
"ping",
"kill",
"save",
"muster",
"startup",
"unstartup",
"completions",
] {
assert!(
!cmd.find_subcommand(visible).unwrap().is_hide_set(),
"{visible} must stay visible in --help"
);
}
}
#[test]
fn the_dog_subcommand_parses_and_stays_hidden() {
use clap::{CommandFactory, Parser};
let parsed = Cli::try_parse_from(["shep", "dog", "metrics"])
.unwrap()
.command;
let Commands::Dog(args) = parsed else {
panic!("expected dog")
};
assert_eq!(args.name, "metrics");
let cmd = Cli::command();
assert!(
cmd.find_subcommand("dog").unwrap().is_hide_set(),
"dog must stay hidden from --help"
);
}
#[test]
fn the_exec_alias_stays_hidden_from_help() {
use clap::CommandFactory;
let cmd = Cli::command();
let enable = cmd.find_subcommand("enable").unwrap();
let exec_arg = enable
.get_arguments()
.find(|a| a.get_id().as_str() == "exec")
.expect("EnableArgs must still carry a hidden `exec` field");
assert!(
exec_arg.is_hide_set(),
"--exec must stay hidden from --help"
);
}
#[test]
fn sendline_is_spelled_one_word() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "sendline", "web", "gc"]).is_ok());
assert!(Cli::try_parse_from(["shep", "send-line", "web", "gc"]).is_err());
}
#[test]
fn sendline_reaches_whisper() {
use clap::Parser;
for argv in [
["shep", "whisper", "web", "gc"],
["shep", "sendline", "web", "gc"],
] {
assert!(matches!(
Cli::try_parse_from(argv).unwrap().command,
Commands::Whisper(_)
));
}
}
#[test]
fn dash_and_lookout_resolve_to_the_same_verb() {
use clap::Parser;
assert!(matches!(
Cli::try_parse_from(["shep", "dash"]).unwrap().command,
Commands::Lookout(_)
));
assert!(matches!(
Cli::try_parse_from(["shep", "lookout"]).unwrap().command,
Commands::Lookout(_)
));
}
#[test]
fn actions_are_off_unless_the_flag_says_otherwise() {
use clap::Parser;
let Commands::Lookout(default) = Cli::try_parse_from(["shep", "lookout"]).unwrap().command
else {
panic!("lookout parses to its own variant")
};
assert!(!default.allow_control);
let Commands::Lookout(flagged) =
Cli::try_parse_from(["shep", "lookout", "--allow-control"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(flagged.allow_control);
}
#[test]
fn whistle_takes_no_arguments_and_has_no_control_flag() {
use clap::Parser;
assert!(matches!(
Cli::try_parse_from(["shep", "whistle"]).unwrap().command,
Commands::Whistle
));
assert!(
Cli::try_parse_from(["shep", "whistle", "--allow-control"]).is_err(),
"whistle's gate is `[whistle] allow_control` in shep.toml, and a flag would \
let an agent host's own config open it in the same line that adds the server"
);
}
}