use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::process::ExitCode;
use clap::{ArgMatches, Command, CommandFactory};
use cerulion_cli_engine::error::{render_user_error, CliError, CliResult};
use crate::cli::{Cli, VerbLogClass};
use crate::network_switch::{self, NetworkRefusal, SwitchEnv};
mod doctor;
mod nested;
mod rcp;
mod schema;
mod service;
pub(crate) trait VerbFamily: Sync {
fn name(&self) -> &'static str;
fn listed_after(&self) -> Option<&'static str>;
fn command(&self) -> Command;
fn log_class(&self, matches: &ArgMatches) -> VerbLogClass;
fn network_refusal(&self, matches: &ArgMatches, env: &SwitchEnv) -> Option<NetworkRefusal>;
fn needs_identity(&self, matches: &ArgMatches) -> bool;
fn run(&self, matches: &ArgMatches) -> ExitCode;
}
pub(crate) static FAMILIES: &[&dyn VerbFamily] = &[
&schema::Schema,
&service::Service,
&rcp::Rcp,
&doctor::Doctor,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RegistryError {
DuplicateName(&'static str),
ShadowsBuiltin(&'static str),
AliasTaken { family: &'static str, alias: String },
UnknownAnchor {
family: &'static str,
anchor: &'static str,
},
WrongVerb { family: &'static str, verb: String },
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::DuplicateName(name) => write!(
f,
"two verb families are registered as `cerulion {name}`; each top-level verb \
must be registered once"
),
RegistryError::ShadowsBuiltin(name) => write!(
f,
"the verb family `{name}` has the name or alias of a built-in verb (or clap's \
`help`); rename the family or remove the built-in verb"
),
RegistryError::AliasTaken { family, alias } => write!(
f,
"the verb family `{family}` has the alias `{alias}`, which already selects \
another verb; drop or rename the alias"
),
RegistryError::UnknownAnchor { family, anchor } => write!(
f,
"the verb family `{family}` is listed after `{anchor}`, which is not a verb"
),
RegistryError::WrongVerb { family, verb } => write!(
f,
"the verb family `{family}` defines the command `{verb}`; its command must be \
named `{family}`, with exactly one variant in its derived `Subcommand` enum"
),
}
}
}
impl std::error::Error for RegistryError {}
pub(crate) fn exit_code_of(result: CliResult<()>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if !matches!(e, CliError::WorkerNodeFatalAnnounced) {
eprintln!("Error: {}", render_user_error(&e));
}
ExitCode::from(crate::cli_error_exit_code(&e))
}
}
}
pub(crate) fn derived_verb<S: clap::Subcommand>() -> Command {
let scratch = S::augment_subcommands(Command::new("scratch"));
let mut verbs = scratch.get_subcommands();
match (verbs.next(), verbs.next()) {
(Some(verb), None) => verb.clone(),
_ => Command::new(""),
}
}
pub(crate) fn build(base: Command, families: &[&dyn VerbFamily]) -> Result<Command, RegistryError> {
let mut order: Vec<String> = base
.get_subcommands()
.map(|c| c.get_name().to_string())
.collect();
let builtin_words: BTreeSet<String> = base
.get_subcommands()
.flat_map(|c| std::iter::once(c.get_name()).chain(c.get_all_aliases()))
.chain(std::iter::once("help"))
.map(str::to_string)
.collect();
let mut family_words: BTreeSet<String> = BTreeSet::new();
let mut anchors: BTreeMap<&'static str, Option<&'static str>> = BTreeMap::new();
let mut cmd = base;
for family in families {
let name = family.name();
if family_words.contains(name) {
return Err(RegistryError::DuplicateName(name));
}
if builtin_words.contains(name) {
return Err(RegistryError::ShadowsBuiltin(name));
}
let verb = family.command();
if verb.get_name() != name {
return Err(RegistryError::WrongVerb {
family: name,
verb: verb.get_name().to_string(),
});
}
let aliases: Vec<String> = verb.get_all_aliases().map(str::to_string).collect();
for alias in &aliases {
if builtin_words.contains(alias) || family_words.contains(alias) || alias == name {
return Err(RegistryError::AliasTaken {
family: name,
alias: alias.clone(),
});
}
}
cmd = cmd.subcommand(verb);
match family.listed_after() {
Some(anchor) => {
let Some(at) = order.iter().position(|n| n == anchor) else {
return Err(RegistryError::UnknownAnchor {
family: name,
anchor,
});
};
let mut at = at + 1;
while at < order.len() && listed_behind(&anchors, &order[at], anchor) {
at += 1;
}
order.insert(at, name.to_string());
}
None => order.push(name.to_string()),
}
anchors.insert(name, family.listed_after());
family_words.insert(name.to_string());
family_words.extend(aliases);
}
for (position, name) in order.iter().enumerate() {
cmd = cmd.mut_subcommand(name, |sub| sub.display_order(position));
}
Ok(cmd)
}
fn listed_behind(
anchors: &BTreeMap<&'static str, Option<&'static str>>,
verb: &str,
anchor: &str,
) -> bool {
let mut at = anchors.get(verb).copied().flatten();
while let Some(parent) = at {
if parent == anchor {
return true;
}
at = anchors.get(parent).copied().flatten();
}
false
}
pub(crate) fn cli_command() -> Command {
let cmd = match build(Cli::command(), FAMILIES) {
Ok(cmd) => cmd,
Err(e) => panic!("verb-family registry: {e}"),
};
match nested::attach(cmd, nested::NESTED) {
Ok(cmd) => cmd,
Err(e) => panic!("nested-verb registry: {e}"),
}
}
pub(crate) fn find<'a>(
families: &[&'a dyn VerbFamily],
matches: &'a ArgMatches,
) -> Option<(&'a dyn VerbFamily, &'a ArgMatches)> {
let (name, sub) = matches.subcommand()?;
families
.iter()
.find(|f| f.name() == name)
.map(|f| (*f, sub))
}
pub(crate) fn route(
families: &[&dyn VerbFamily],
matches: &ArgMatches,
switch: &SwitchEnv,
setup: impl FnOnce(VerbLogClass),
gate: impl FnOnce() -> Option<ExitCode>,
) -> Option<ExitCode> {
route_with(families, nested::NESTED, matches, switch, setup, gate)
}
#[cfg(test)]
pub(crate) fn network_refusal(matches: &ArgMatches, switch: &SwitchEnv) -> Option<NetworkRefusal> {
if let Some((verbs, sub)) = nested::find(nested::NESTED, matches) {
return verbs.network_refusal(sub, switch);
}
if let Some((family, sub)) = find(FAMILIES, matches) {
return family.network_refusal(sub, switch);
}
let cli = <Cli as clap::FromArgMatches>::from_arg_matches(matches)
.expect("a built-in verb converts to `Commands`");
crate::command_network_refusal(&cli.command, switch)
}
#[cfg(test)]
pub(crate) fn needs_identity(matches: &ArgMatches) -> bool {
if let Some((verbs, sub)) = nested::find(nested::NESTED, matches) {
return verbs.needs_identity(sub);
}
if let Some((family, sub)) = find(FAMILIES, matches) {
return family.needs_identity(sub);
}
let cli = <Cli as clap::FromArgMatches>::from_arg_matches(matches)
.expect("a built-in verb converts to `Commands`");
crate::command_needs_identity(&cli.command)
}
fn route_with(
families: &[&dyn VerbFamily],
nested: &[&dyn nested::NestedVerbs],
matches: &ArgMatches,
switch: &SwitchEnv,
setup: impl FnOnce(VerbLogClass),
gate: impl FnOnce() -> Option<ExitCode>,
) -> Option<ExitCode> {
type Run<'a> = Box<dyn FnOnce() -> ExitCode + 'a>;
let (class, refusal, needs_identity, run): (VerbLogClass, _, bool, Run<'_>) =
if let Some((verbs, sub)) = nested::find(nested, matches) {
(
verbs.log_class(sub),
verbs.network_refusal(sub, switch),
verbs.needs_identity(sub),
Box::new(move || verbs.run(sub)),
)
} else {
let (family, sub) = find(families, matches)?;
(
family.log_class(sub),
family.network_refusal(sub, switch),
family.needs_identity(sub),
Box::new(move || family.run(sub)),
)
};
setup(class);
if let Some(refused) = network_switch::refuse_or_gate(refusal, needs_identity, gate) {
return Some(refused);
}
Some(run())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::Mutex;
use clap::{FromArgMatches, Subcommand};
use clap_complete::engine::ArgValueCandidates;
#[test]
fn run_and_listing_help_pin_paired_access_without_open_default_promises() {
for args in [
["cerulion", "node", "run", "--help"],
["cerulion", "topic", "list", "--help"],
] {
let help = || {
let error = cli_command()
.try_get_matches_from(args)
.expect_err("help exits before command execution");
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
error.to_string()
};
let first = help();
assert!(first.contains("paired"), "{first}");
assert!(!first.contains("without pairing"), "{first}");
assert!(!first.contains("no pairing"), "{first}");
assert_eq!(first, help(), "help is deterministic");
}
}
static DUMMY_RUNS: Mutex<Vec<String>> = Mutex::new(Vec::new());
#[derive(Subcommand)]
enum DummyCmd {
Dummy {
#[command(subcommand)]
action: DummyAction,
},
}
#[derive(Subcommand)]
enum DummyAction {
Echo {
#[arg(add = ArgValueCandidates::new(|| vec!["alpha".into(), "beta".into()]))]
word: String,
},
Wait,
}
struct Dummy {
name: &'static str,
after: Option<&'static str>,
gated: bool,
refused: bool,
aliases: &'static [&'static str],
}
const DUMMY: Dummy = Dummy {
name: "dummy",
after: None,
gated: false,
refused: false,
aliases: &[],
};
const fn dummy(name: &'static str) -> Dummy {
Dummy {
name,
after: None,
gated: false,
refused: false,
aliases: &[],
}
}
impl VerbFamily for Dummy {
fn name(&self) -> &'static str {
self.name
}
fn listed_after(&self) -> Option<&'static str> {
self.after
}
fn command(&self) -> Command {
derived_verb::<DummyCmd>()
.name(self.name)
.aliases(self.aliases)
}
fn log_class(&self, matches: &ArgMatches) -> VerbLogClass {
match matches.subcommand_name() {
Some("wait") => VerbLogClass::LongRunning,
_ => VerbLogClass::OneShot,
}
}
fn network_refusal(&self, _: &ArgMatches, env: &SwitchEnv) -> Option<NetworkRefusal> {
(self.refused && env.network_engaged())
.then(|| NetworkRefusal::error("the dummy reaches the network", 9))
}
fn needs_identity(&self, _matches: &ArgMatches) -> bool {
self.gated
}
fn run(&self, matches: &ArgMatches) -> ExitCode {
match DummyAction::from_arg_matches(matches) {
Ok(DummyAction::Echo { word }) => {
DUMMY_RUNS
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(word);
ExitCode::from(42)
}
Ok(DummyAction::Wait) => ExitCode::from(43),
Err(_) => ExitCode::FAILURE,
}
}
}
struct Commands {
name: &'static str,
command: fn() -> Command,
}
impl VerbFamily for Commands {
fn name(&self) -> &'static str {
self.name
}
fn listed_after(&self) -> Option<&'static str> {
None
}
fn command(&self) -> Command {
(self.command)()
}
fn log_class(&self, _: &ArgMatches) -> VerbLogClass {
VerbLogClass::OneShot
}
fn network_refusal(&self, _: &ArgMatches, _: &SwitchEnv) -> Option<NetworkRefusal> {
None
}
fn needs_identity(&self, _: &ArgMatches) -> bool {
true
}
fn run(&self, _: &ArgMatches) -> ExitCode {
ExitCode::SUCCESS
}
}
#[derive(Subcommand)]
enum TwoVerbs {
Mine,
Extra,
}
#[derive(Subcommand)]
enum NoVerb {}
#[derive(Subcommand)]
enum Titled {
Titled,
}
fn with(families: &[&dyn VerbFamily]) -> Command {
build(Cli::command(), families).expect("families register")
}
fn listing(cmd: &mut Command) -> Vec<String> {
let help = cmd.render_help().to_string();
help.lines()
.skip_while(|l| *l != "Commands:")
.skip(1)
.take_while(|l| !l.is_empty())
.filter_map(|l| l.split_whitespace().next().map(str::to_string))
.collect()
}
fn complete(cmd: &mut Command, words: &[&str]) -> Vec<String> {
let args: Vec<OsString> = words.iter().map(OsString::from).collect();
let index = args.len() - 1;
clap_complete::engine::complete(cmd, args, index, None)
.expect("completion engine")
.into_iter()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_value().to_string_lossy().into_owned())
.filter(|c| !c.starts_with('-'))
.collect()
}
#[test]
fn a_registered_family_is_listed_in_root_help() {
let mut cmd = with(&[&DUMMY]);
let help = cmd.render_help().to_string();
assert!(
help.contains(" dummy A test-only family\n"),
"{help}"
);
let verbs = listing(&mut cmd);
assert_eq!(verbs[verbs.len() - 2..], ["dummy", "help"]);
}
#[test]
fn a_registered_family_completes_its_name_its_verbs_and_its_values() {
let mut cmd = with(&[&DUMMY]);
assert_eq!(complete(&mut cmd, &["cerulion", "dum"]), ["dummy"]);
assert_eq!(
complete(&mut cmd, &["cerulion", "dummy", ""]),
["echo", "wait", "help"]
);
assert_eq!(
complete(&mut cmd, &["cerulion", "dummy", "echo", ""]),
["alpha", "beta"]
);
}
#[test]
fn a_registered_family_is_dispatched_with_its_own_matches() {
let families: [&dyn VerbFamily; 1] = [&DUMMY];
let matches = with(&families)
.try_get_matches_from(["cerulion", "dummy", "echo", "gamma"])
.expect("parse");
let (family, sub) = find(&families, &matches).expect("dummy is a family");
assert_eq!(family.name(), "dummy");
assert!(!family.needs_identity(sub));
assert_eq!(family.log_class(sub), VerbLogClass::OneShot);
assert_eq!(crate::telemetry::exit_code_number(family.run(sub)), 42);
assert!(DUMMY_RUNS
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains(&"gamma".to_string()));
}
fn routed(
families: &[&dyn VerbFamily],
argv: &[&str],
refuse: Option<u8>,
) -> (Option<VerbLogClass>, usize, Option<i64>) {
routed_under(families, argv, refuse, &SwitchEnv::default())
}
fn routed_under(
families: &[&dyn VerbFamily],
argv: &[&str],
refuse: Option<u8>,
switch: &SwitchEnv,
) -> (Option<VerbLogClass>, usize, Option<i64>) {
let matches = with(families).try_get_matches_from(argv).expect("parse");
let mut class = None;
let mut gate_runs = 0;
let code = route(
families,
&matches,
switch,
|c| class = Some(c),
|| {
gate_runs += 1;
refuse.map(ExitCode::from)
},
);
(
class,
gate_runs,
code.map(crate::telemetry::exit_code_number),
)
}
#[test]
fn main_routes_a_registered_family_to_its_handler() {
let (class, gate_runs, code) =
routed(&[&DUMMY], &["cerulion", "dummy", "echo", "routed"], Some(7));
assert_eq!(class, Some(VerbLogClass::OneShot));
assert_eq!(gate_runs, 0);
assert_eq!(code, Some(42));
assert!(DUMMY_RUNS
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains(&"routed".to_string()));
let (class, _, code) = routed(&[&DUMMY], &["cerulion", "dummy", "wait"], None);
assert_eq!(class, Some(VerbLogClass::LongRunning));
assert_eq!(code, Some(43));
}
struct UnderDummy;
impl nested::NestedVerbs for UnderDummy {
fn parent(&self) -> &'static str {
"dummy"
}
fn commands(&self) -> Vec<Command> {
vec![Command::new("probe")]
}
fn log_class(&self, _: &ArgMatches) -> VerbLogClass {
VerbLogClass::LongRunning
}
fn network_refusal(&self, _: &ArgMatches, env: &SwitchEnv) -> Option<NetworkRefusal> {
env.network_engaged()
.then(|| NetworkRefusal::error("the probe reaches the network", 8))
}
fn needs_identity(&self, _: &ArgMatches) -> bool {
true
}
fn run(&self, matches: &ArgMatches) -> ExitCode {
match matches.subcommand_name() {
Some("probe") => ExitCode::from(44),
_ => ExitCode::FAILURE,
}
}
}
#[test]
fn a_nested_verb_under_a_registered_family_reaches_its_own_handler() {
let families: [&dyn VerbFamily; 1] = [&DUMMY];
let sets: [&dyn nested::NestedVerbs; 1] = [&UnderDummy];
let cmd = nested::attach(with(&families), &sets).expect("nested verbs attach");
let route = |argv: &[&str], switch: &SwitchEnv| {
let matches = cmd.clone().try_get_matches_from(argv).expect("parse");
let mut class = None;
let mut gate_runs = 0;
let code = route_with(
&families,
&sets,
&matches,
switch,
|c| class = Some(c),
|| {
gate_runs += 1;
None
},
);
(
class,
gate_runs,
code.map(crate::telemetry::exit_code_number),
)
};
let unset = SwitchEnv::default();
assert_eq!(
route(&["cerulion", "dummy", "probe"], &unset),
(Some(VerbLogClass::LongRunning), 1, Some(44))
);
assert_eq!(
route(&["cerulion", "dummy", "wait"], &unset),
(Some(VerbLogClass::LongRunning), 0, Some(43))
);
assert_eq!(
route(
&["cerulion", "dummy", "probe"],
&SwitchEnv::network(Some("off"))
),
(Some(VerbLogClass::LongRunning), 0, Some(8))
);
}
#[test]
fn a_family_the_switch_refuses_exits_before_the_gate_and_its_handler() {
let refused = Dummy {
gated: true,
refused: true,
..dummy("dummy")
};
for value in ["off", " OFF ", "on"] {
let (class, gate_runs, code) = routed_under(
&[&refused],
&["cerulion", "dummy", "echo", "switched-off"],
Some(7),
&SwitchEnv::network(Some(value)),
);
assert_eq!(
(class, gate_runs, code),
(Some(VerbLogClass::OneShot), 0, Some(9)),
"CERULION_NETWORK={value:?}"
);
}
assert!(!DUMMY_RUNS
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains(&"switched-off".to_string()));
for value in [None, Some(""), Some(" ")] {
let (_, gate_runs, code) = routed_under(
&[&refused],
&["cerulion", "dummy", "wait"],
None,
&SwitchEnv::network(value),
);
assert_eq!(
(gate_runs, code),
(1, Some(43)),
"CERULION_NETWORK={value:?}"
);
}
}
#[test]
fn main_runs_the_gate_before_a_family_that_needs_an_identity() {
let gated = Dummy {
gated: true,
..dummy("dummy")
};
let (class, gate_runs, code) = routed(&[&gated], &["cerulion", "dummy", "wait"], None);
assert_eq!(
(class, gate_runs, code),
(Some(VerbLogClass::LongRunning), 1, Some(43))
);
let (class, gate_runs, code) = routed(
&[&gated],
&["cerulion", "dummy", "echo", "refused-word"],
Some(7),
);
assert_eq!(
(class, gate_runs, code),
(Some(VerbLogClass::OneShot), 1, Some(7))
);
assert!(!DUMMY_RUNS
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains(&"refused-word".to_string()));
}
#[test]
fn main_leaves_a_built_in_verb_to_the_generic_dispatch() {
let (class, gate_runs, code) = routed(&[&DUMMY], &["cerulion", "graph", "list"], Some(7));
assert_eq!((class, gate_runs, code), (None, 0, None));
}
#[test]
fn a_family_decides_its_log_class_per_verb() {
let families: [&dyn VerbFamily; 1] = [&DUMMY];
let matches = with(&families)
.try_get_matches_from(["cerulion", "dummy", "wait"])
.expect("parse");
let (family, sub) = find(&families, &matches).expect("family");
assert_eq!(family.log_class(sub), VerbLogClass::LongRunning);
assert_eq!(crate::telemetry::exit_code_number(family.run(sub)), 43);
}
#[test]
fn a_family_takes_the_global_verbose_flag() {
let families: [&dyn VerbFamily; 1] = [&DUMMY];
let matches = with(&families)
.try_get_matches_from(["cerulion", "dummy", "wait", "-v"])
.expect("parse");
assert!(matches.get_flag("verbose"));
}
#[test]
fn a_built_in_verb_is_not_a_family() {
let families: [&dyn VerbFamily; 1] = [&DUMMY];
let matches = with(&families)
.try_get_matches_from(["cerulion", "graph", "list"])
.expect("parse");
assert!(find(&families, &matches).is_none());
assert!(Cli::from_arg_matches(&matches).is_ok());
}
#[test]
fn an_empty_registry_leaves_the_listing_as_the_enum_declares_it() {
let mut cmd = with(&[]);
let mut plain = Cli::command();
assert_eq!(listing(&mut cmd), listing(&mut plain));
}
#[test]
fn a_family_listed_after_a_verb_sits_directly_behind_it() {
let a = Dummy {
name: "dummy",
after: Some("viz"),
..dummy("")
};
let b = Dummy {
name: "dummy2",
after: Some("viz"),
..dummy("")
};
let mut cmd = with(&[&a, &b]);
let verbs = listing(&mut cmd);
let at = verbs.iter().position(|v| v == "viz").expect("viz");
assert_eq!(verbs[at..at + 4], ["viz", "dummy", "dummy2", "connect"]);
}
fn listed_from(families: &[&dyn VerbFamily], from: &str, len: usize) -> Vec<String> {
let verbs = listing(&mut with(families));
let at = verbs.iter().position(|v| v == from).expect("anchor listed");
verbs[at..at + len].to_vec()
}
#[test]
fn a_family_follows_its_own_anchor_not_every_earlier_family() {
let after = |name, anchor| Dummy {
after: Some(anchor),
..dummy(name)
};
let (a, b, c) = (after("a", "topic"), after("b", "topic"), after("c", "a"));
assert_eq!(
listed_from(&[&a, &b, &c], "topic", 5),
["topic", "a", "c", "b", "ros2"]
);
assert_eq!(
listed_from(&[&a, &c, &b], "topic", 5),
["topic", "a", "c", "b", "ros2"]
);
let d = after("d", "c");
assert_eq!(
listed_from(&[&a, &c, &d, &b], "topic", 6),
["topic", "a", "c", "d", "b", "ros2"]
);
}
#[test]
fn two_families_with_one_name_are_refused() {
let err = build(Cli::command(), &[&DUMMY, &DUMMY]).expect_err("duplicate");
assert_eq!(err, RegistryError::DuplicateName("dummy"));
assert_eq!(
err.to_string(),
"two verb families are registered as `cerulion dummy`; each top-level verb must \
be registered once"
);
}
#[test]
fn a_family_with_a_built_in_name_is_refused() {
let graph = Dummy {
name: "graph",
after: None,
..dummy("")
};
let err = build(Cli::command(), &[&graph]).expect_err("shadow");
assert_eq!(err, RegistryError::ShadowsBuiltin("graph"));
}
#[test]
fn a_family_listed_after_a_missing_verb_is_refused() {
let lost = Dummy {
name: "dummy",
after: Some("nope"),
..dummy("")
};
let err = build(Cli::command(), &[&lost]).expect_err("anchor");
assert_eq!(
err,
RegistryError::UnknownAnchor {
family: "dummy",
anchor: "nope"
}
);
}
#[test]
fn a_family_whose_enum_has_no_verb_is_refused() {
let none = Commands {
name: "mine",
command: derived_verb::<NoVerb>,
};
let err = build(Cli::command(), &[&none]).expect_err("no verb");
assert_eq!(
err,
RegistryError::WrongVerb {
family: "mine",
verb: String::new()
}
);
}
#[test]
fn a_family_whose_verb_has_another_name_is_refused() {
let other = Commands {
name: "mine",
command: || Command::new("other"),
};
let err = build(Cli::command(), &[&other]).expect_err("wrong verb");
assert_eq!(
err,
RegistryError::WrongVerb {
family: "mine",
verb: "other".into()
}
);
assert_eq!(
err.to_string(),
"the verb family `mine` defines the command `other`; its command must be named \
`mine`, with exactly one variant in its derived `Subcommand` enum"
);
}
#[test]
fn a_family_whose_enum_has_two_verbs_is_refused() {
let two = Commands {
name: "mine",
command: derived_verb::<TwoVerbs>,
};
let err = build(Cli::command(), &[&two]).expect_err("two verbs");
assert_eq!(
err,
RegistryError::WrongVerb {
family: "mine",
verb: String::new()
}
);
}
fn built_ins(cmd: &Command) -> Vec<String> {
let mut cmd = cmd.clone().disable_help_subcommand(true);
cmd.build();
let mut out = vec![
format!("{:?}", cmd.get_about()),
format!("{:?}", cmd.get_long_about()),
];
out.extend(cmd.get_arguments().map(|a| format!("{a:?}")));
let base: BTreeSet<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.collect();
for sub in cmd.get_subcommands_mut() {
if !base.contains(sub.get_name()) {
continue;
}
let head = format!(
"{} {:?} hidden={}",
sub.get_name(),
sub.get_all_aliases().collect::<Vec<_>>(),
sub.is_hide_set()
);
out.push(format!("{head} {}", sub.render_long_help()));
}
out
}
#[test]
fn registering_families_leaves_every_built_in_verb_and_root_arg_as_it_was() {
let titled = Commands {
name: "titled",
command: derived_verb::<Titled>,
};
let aliased = Dummy {
aliases: &["dm"],
after: Some("topic"),
..dummy("dummy")
};
let cmd = with(&[&aliased, &titled]);
assert_eq!(built_ins(&cmd), built_ins(&Cli::command()));
}
#[test]
fn a_doc_commented_family_enum_leaves_the_root_help_alone() {
let titled = Commands {
name: "titled",
command: derived_verb::<Titled>,
};
let mut cmd = with(&[&titled]);
let mut plain = Cli::command();
assert_eq!(cmd.get_about(), plain.get_about());
let help = cmd.render_help().to_string();
assert!(help.contains(" titled A titled family\n"), "{help}");
assert!(help.starts_with(
plain
.render_help()
.to_string()
.lines()
.next()
.unwrap_or_default()
));
}
#[test]
fn a_family_named_help_is_refused() {
let help = dummy("help");
let err = build(Cli::command(), &[&help]).expect_err("shadow");
assert_eq!(err, RegistryError::ShadowsBuiltin("help"));
assert_eq!(
err.to_string(),
"the verb family `help` has the name or alias of a built-in verb (or clap's \
`help`); rename the family or remove the built-in verb"
);
}
#[test]
fn a_family_named_like_a_built_in_alias_is_refused() {
let base = Cli::command().subcommand(Command::new("aliased").alias("al"));
let al = dummy("al");
let err = build(base, &[&al]).expect_err("shadow");
assert_eq!(err, RegistryError::ShadowsBuiltin("al"));
}
#[test]
fn a_family_alias_that_selects_another_verb_is_refused() {
let cases: [(&'static [&'static str], Option<&'static str>); 3] = [
(&["graph"], None),
(&["help"], None),
(&["dummy2"], Some("dummy2")),
];
for (aliases, earlier) in cases {
let aliased = Dummy {
aliases,
..dummy("dummy")
};
let first = earlier.map(dummy);
let mut families: Vec<&dyn VerbFamily> = Vec::new();
if let Some(first) = &first {
families.push(first);
}
families.push(&aliased);
let err = build(Cli::command(), &families).expect_err("alias");
assert_eq!(
err,
RegistryError::AliasTaken {
family: "dummy",
alias: aliases[0].to_string()
},
"alias {aliases:?}"
);
}
let err = RegistryError::AliasTaken {
family: "dummy",
alias: "graph".into(),
};
assert_eq!(
err.to_string(),
"the verb family `dummy` has the alias `graph`, which already selects another \
verb; drop or rename the alias"
);
}
#[test]
fn a_family_named_like_an_earlier_family_alias_is_refused() {
let aliased = Dummy {
aliases: &["d2"],
..dummy("dummy")
};
let second = dummy("d2");
let err = build(Cli::command(), &[&aliased, &second]).expect_err("duplicate");
assert_eq!(err, RegistryError::DuplicateName("d2"));
}
#[test]
fn a_family_alias_that_is_free_registers_and_parses() {
let aliased = Dummy {
aliases: &["dm"],
..dummy("dummy")
};
let families: [&dyn VerbFamily; 1] = [&aliased];
let matches = with(&families)
.try_get_matches_from(["cerulion", "dm", "wait"])
.expect("parse");
let (family, _) = find(&families, &matches).expect("the alias selects the family");
assert_eq!(family.name(), "dummy");
}
#[test]
fn building_twice_gives_the_same_help() {
let render = || with(&[&DUMMY]).render_long_help().to_string();
assert_eq!(render(), render());
}
#[test]
fn the_registered_families_build() {
build(Cli::command(), FAMILIES).expect("the production registry builds");
}
}