mod declare;
use std::fmt;
use crate::command_line::CommandLine;
use crate::name::Name;
use crate::outcome::{Failure, Outcome};
pub struct Task {
about: &'static str,
body: Body,
}
enum Body {
Handler {
augment: fn(clap::Command) -> clap::Command,
handler: Box<Handler>,
},
Children(Vec<(&'static str, Task)>),
}
type Handler = dyn Fn(&CommandLine, &clap::ArgMatches) -> Outcome + Send + Sync;
const _: () = assert!(std::mem::size_of::<Task>() <= 128);
fn parse_arguments<A: clap::Args>(matches: &clap::ArgMatches) -> Result<A, Failure> {
A::from_arg_matches(matches).map_err(|error| Failure::new(error.to_string()))
}
impl Task {
#[must_use]
pub fn new<A>(about: &'static str, run: impl Fn(A) -> Outcome + Send + Sync + 'static) -> Self
where
A: clap::Args + 'static,
{
Self {
about,
body: Body::Handler {
augment: <A as clap::Args>::augment_args,
handler: Box::new(
move |_command_line: &CommandLine, matches: &clap::ArgMatches| {
let arguments = parse_arguments::<A>(matches)?;
run(arguments)
},
),
},
}
}
#[must_use]
pub fn receiving_command_line<A>(
about: &'static str,
run: impl Fn(&CommandLine, A) -> Outcome + Send + Sync + 'static,
) -> Self
where
A: clap::Args + 'static,
{
Self {
about,
body: Body::Handler {
augment: <A as clap::Args>::augment_args,
handler: Box::new(
move |command_line: &CommandLine, matches: &clap::ArgMatches| {
let arguments = parse_arguments::<A>(matches)?;
run(command_line, arguments)
},
),
},
}
}
#[must_use]
pub fn group(
about: &'static str,
children: impl IntoIterator<Item = (&'static str, Self)>,
) -> Self {
let children: Vec<(&'static str, Self)> = children.into_iter().collect();
assert!(
!children.is_empty(),
"a bundle must have at least one child; `Task::group` was given none"
);
for (index, (name, _)) in children.iter().enumerate() {
assert!(
!children[..index].iter().any(|(earlier, _)| earlier == name),
"a bundle's children must have distinct names; `{name}` is named twice"
);
}
for (name, _) in &children {
if let Err(refusal) = Name::new(name) {
#[expect(
clippy::panic,
reason = "a contract violation in the bundle crate's own source, like the \
assertions around it, whose message is the refusal's own reason"
)]
{
panic!("a bundle's children are named like any task: {refusal}");
}
}
assert!(
*name != "help",
"a bundle's children cannot be named `help`: clap adds its own `help` command \
under every group"
);
}
Self {
about,
body: Body::Children(children),
}
}
#[must_use]
pub(crate) fn declare(&self, name: &'static str) -> clap::Command {
declare::declare_tree(self, name)
}
pub(crate) fn invoke(&self, command_line: &CommandLine, matches: &clap::ArgMatches) -> Outcome {
match &self.body {
Body::Handler { handler, .. } => handler(command_line, matches),
Body::Children(_) => {
unreachable!("the dispatcher's descend loop never invokes a bundle directly")
}
}
}
pub(crate) fn children(&self) -> Option<&[(&'static str, Self)]> {
match &self.body {
Body::Children(children) => Some(children),
Body::Handler { .. } => None,
}
}
pub(crate) fn into_children(self) -> Result<Vec<(&'static str, Self)>, Self> {
match self.body {
Body::Children(children) => Ok(children),
Body::Handler { .. } => Err(self),
}
}
}
impl fmt::Debug for Task {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug_struct = formatter.debug_struct("Task");
debug_struct.field("about", &self.about);
if let Body::Children(children) = &self.body {
let child_names: Vec<&'static str> = children.iter().map(|(name, _)| *name).collect();
debug_struct.field("children", &child_names);
}
debug_struct.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::Task;
use crate::command_line::CommandLine;
use crate::identity::Identity;
use crate::outcome::Outcome;
use crate::test_support::run_ok;
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
#[test]
fn task_is_send_and_sync() {
assert_send::<Task>();
assert_sync::<Task>();
}
fn a_command_line() -> CommandLine {
CommandLine::from_dispatch(
Identity::from_macro_expansion("test-package", "test-binary", "0.0.0-test"),
[],
["test-task"],
)
}
#[test]
fn declare_carries_the_about_text_onto_the_command() {
let task = Task::new("say hello", run_ok);
let command = task.declare("greet");
assert_eq!(command.get_name(), "greet");
assert_eq!(
command.get_about().map(ToString::to_string),
Some("say hello".to_string())
);
}
#[derive(clap::Args)]
struct DocCommentedArguments {}
#[expect(
clippy::unnecessary_wraps,
reason = "the handler contract is `Fn(A) -> Outcome`; this test double never fails on \
purpose, but the signature still has to match what a real handler returns"
)]
fn run_doc_commented(_arguments: DocCommentedArguments) -> Outcome {
Ok(())
}
#[test]
fn declare_prefers_task_news_about_over_the_argument_structs_doc_comment() {
let task = Task::new("say hello", run_doc_commented);
let command = task.declare("greet");
assert_eq!(
command.get_about().map(ToString::to_string),
Some("say hello".to_string())
);
}
#[test]
fn invoke_runs_the_handler_against_parsed_arguments() {
let task = Task::new("say hello", run_ok);
let command = task.declare("greet");
let parsed = command.try_get_matches_from(["greet"]);
assert!(
parsed.is_ok(),
"parsing an empty argument list should succeed: {parsed:?}"
);
if let Ok(matches) = parsed {
assert!(task.invoke(&a_command_line(), &matches).is_ok());
}
}
#[test]
fn a_task_built_the_plain_way_ignores_the_command_line_it_is_invoked_with() {
let task = Task::new("say hello", run_ok);
let command = task.declare("greet");
let parsed = command.try_get_matches_from(["greet"]);
assert!(
parsed.is_ok(),
"parsing an empty argument list should succeed: {parsed:?}"
);
if let Ok(matches) = parsed {
let other_command_line = CommandLine::from_dispatch(
Identity::from_macro_expansion("other-package", "other-binary", "9.9.9"),
["build"],
["other-task"],
);
let first = task.invoke(&a_command_line(), &matches);
let second = task.invoke(&other_command_line, &matches);
assert!(first.is_ok());
assert!(second.is_ok());
}
}
#[derive(clap::Args)]
struct GreetingArguments {
who: String,
}
#[test]
fn receiving_command_line_hands_the_handler_the_invoking_command_line() {
let task = Task::receiving_command_line(
"say hello, naming the CLI",
|command_line: &CommandLine, arguments: GreetingArguments| {
let identity = command_line.identity();
assert_eq!(identity.package_name(), "test-package");
assert_eq!(identity.binary_name(), "test-binary");
assert_eq!(identity.version(), "0.0.0-test");
assert_eq!(command_line.flattened_commands(), ["daily"]);
assert_eq!(arguments.who, "world");
Ok(())
},
);
let command = task.declare("greet");
let parsed = command.try_get_matches_from(["greet", "world"]);
assert!(
parsed.is_ok(),
"parsing a single positional argument should succeed: {parsed:?}"
);
if let Ok(matches) = parsed {
let command_line = CommandLine::from_dispatch(
Identity::from_macro_expansion("test-package", "test-binary", "0.0.0-test"),
["daily"],
["greet"],
);
assert!(task.invoke(&command_line, &matches).is_ok());
}
}
#[test]
#[should_panic(expected = "a bundle must have at least one child")]
fn group_panics_on_no_children() {
let _ = Task::group("empty", std::iter::empty::<(&'static str, Task)>());
}
#[test]
#[should_panic(expected = "`add` is named twice")]
fn group_panics_on_a_repeated_child_name() {
let _ = Task::group(
"a bundle with a duplicate",
[
("add", Task::new("first", run_ok)),
("add", Task::new("second", run_ok)),
],
);
}
#[test]
#[should_panic(expected = "cannot be named `help`")]
fn group_panics_on_a_child_named_help() {
let _ = Task::group("a bundle with help", [("help", Task::new("nope", run_ok))]);
}
#[test]
#[should_panic(expected = "named like any task: `db sync` is not a usable name; a name starts")]
fn group_panics_on_a_child_key_with_a_space() {
let _ = Task::group("a bundle", [("db sync", Task::new("sync", run_ok))]);
}
#[test]
#[should_panic(expected = "named like any task: `Sync` is not a usable name; a name starts")]
fn group_panics_on_an_uppercase_child_key() {
let _ = Task::group("a bundle", [("Sync", Task::new("sync", run_ok))]);
}
#[test]
#[should_panic(expected = "named like any task: `db_sync` is not a usable name; a name starts")]
fn group_panics_on_an_underscored_child_key() {
let _ = Task::group("a bundle", [("db_sync", Task::new("sync", run_ok))]);
}
#[test]
#[should_panic(
expected = "named like any task: `crate` is not a usable name; `crate`, `self` and `super` \
cannot be written as a Rust identifier"
)]
fn group_panics_on_a_child_key_named_crate() {
let _ = Task::group("a bundle", [("crate", Task::new("crate", run_ok))]);
}
#[test]
fn group_builds_a_well_formed_bundle_without_panicking() {
let bundle = Task::group(
"a well-formed bundle",
[
("add", Task::new("first", run_ok)),
("regenerate", Task::new("second", run_ok)),
("db-sync2", Task::new("third", run_ok)),
],
);
assert_eq!(bundle.children().map(<[_]>::len), Some(3));
}
}