mod assemble;
use std::fmt;
use std::process::ExitCode;
use assemble::{assemble, flatten};
use crate::command_line::CommandLine;
use crate::identity::Identity;
use crate::outcome::Outcome;
use crate::task::Task;
#[must_use]
pub fn run(
identity: Identity,
imported: impl IntoIterator<Item = (&'static str, Task)>,
) -> ExitCode {
let tasks = assemble(identity.binary_name(), imported);
let top_level = match flatten(identity.binary_name(), tasks) {
Ok(top_level) => top_level,
Err(failure) => {
report_to_stderr(refusal_line(identity.binary_name(), &failure));
return ExitCode::FAILURE;
}
};
let command = build_command(identity, &top_level.mounts);
let matches = command.get_matches();
let (task, matches) = resolve(&top_level.mounts, &matches);
let command_line = CommandLine::from_dispatch(identity, top_level.flattened);
let outcome = task.invoke(&command_line, matches);
if let Err(failure) = &outcome {
report_to_stderr(refusal_line(identity.binary_name(), failure));
}
exit_code_for(&outcome)
}
fn resolve<'a>(
tasks: &'a [(&'static str, Task)],
matches: &'a clap::ArgMatches,
) -> (&'a Task, &'a clap::ArgMatches) {
let Some((subcommand_name, subcommand_matches)) = matches.subcommand() else {
unreachable!("clap enforces subcommand_required(true) before returning matches");
};
let Some((_, task)) = tasks.iter().find(|(name, _)| *name == subcommand_name) else {
unreachable!("clap returned an undeclared subcommand name `{subcommand_name}`");
};
let mut task = task;
let mut matches = subcommand_matches;
while let Some(children) = task.children() {
let Some((child_name, child_matches)) = matches.subcommand() else {
unreachable!("subcommand_required(true) is set on every bundle (declare_tree)");
};
let Some((_, child_task)) = children.iter().find(|(name, _)| *name == child_name) else {
unreachable!("clap returned an undeclared subcommand name `{child_name}`");
};
task = child_task;
matches = child_matches;
}
(task, matches)
}
fn build_command(identity: Identity, tasks: &[(&'static str, Task)]) -> clap::Command {
let mut command = clap::Command::new(identity.binary_name())
.bin_name(identity.binary_name())
.version(identity.version())
.subcommand_required(true)
.arg_required_else_help(true);
for (name, task) in tasks {
command = command.subcommand(task.declare(name));
}
command
}
fn refusal_line(binary_name: &str, message: impl fmt::Display) -> String {
format!("{binary_name}: {message}")
}
const fn exit_code_for(outcome: &Outcome) -> ExitCode {
match outcome {
Ok(()) => ExitCode::SUCCESS,
Err(_) => ExitCode::FAILURE,
}
}
fn report_to_stderr(message: impl AsRef<str>) {
use std::io::Write;
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "{}", message.as_ref());
}
#[cfg(test)]
mod tests {
use std::process::ExitCode;
use super::{build_command, exit_code_for, refusal_line, resolve};
use crate::command_line::CommandLine;
use crate::identity::Identity;
use crate::outcome::{Failure, Outcome};
use crate::task::Task;
use crate::test_support::{NoArguments, run_ok};
fn an_identity(binary_name: &'static str, version: &'static str) -> Identity {
Identity::from_macro_expansion("a-package", binary_name, version)
}
#[test]
fn exit_code_for_ok_is_success() {
assert_eq!(exit_code_for(&Ok(())), ExitCode::SUCCESS);
}
#[test]
fn exit_code_for_err_is_failure() {
let outcome: Outcome = Err(Failure::new("boom"));
assert_eq!(exit_code_for(&outcome), ExitCode::FAILURE);
}
#[test]
fn refusal_line_carries_the_bin_prefix_exactly_once() {
let failure = Failure::new("tasks/lint already exists");
let line = refusal_line("myapp-ritual", &failure);
assert_eq!(line, "myapp-ritual: tasks/lint already exists");
assert_eq!(line.matches("myapp-ritual:").count(), 1);
}
#[test]
fn version_names_the_compiled_bin_not_the_package() {
let identity = an_identity("ritual", "0.0.0");
let command = build_command(identity, &[]);
let rendered = command.render_version();
assert!(
rendered.starts_with("ritual "),
"expected --version to start with the bin name: {rendered:?}"
);
assert!(
!rendered.contains("a-package"),
"expected the package name to be absent from --version: {rendered:?}"
);
}
#[test]
fn version_names_the_bin_when_bin_and_package_share_a_name() {
let identity = Identity::from_macro_expansion("demo-ritual", "demo-ritual", "0.1.0");
let command = build_command(identity, &[]);
let rendered = command.render_version();
assert!(
rendered.starts_with("demo-ritual "),
"expected --version to start with the shared name: {rendered:?}"
);
}
#[test]
fn usage_line_names_the_compiled_bin_not_argv0() {
let identity = an_identity("ritual", "0.0.0");
let command = build_command(identity, &[]);
let parsed = command.try_get_matches_from(["/some/where/renamed-probe", "nosuch"]);
assert!(parsed.is_err(), "an unknown subcommand must be refused");
if let Err(error) = parsed {
let rendered = error.render().to_string();
assert!(
rendered.contains("Usage: ritual"),
"expected the Usage: line to name the compiled bin: {rendered:?}"
);
assert!(
!rendered.contains("renamed-probe"),
"expected the Usage: line to never name argv[0]: {rendered:?}"
);
}
}
#[test]
fn the_descend_loop_reaches_a_leaf_two_levels_down() {
fn run_and_report_which_leaf_ran(_arguments: NoArguments) -> Outcome {
Err(Failure::new("the leaf two levels down ran"))
}
let leaf = Task::new("a leaf, two levels down", run_and_report_which_leaf_ran);
let inner = Task::group("an inner bundle", [("leaf", leaf)]);
let sibling = Task::new("an unrelated top-level task", run_ok);
let tasks = [("outer", inner), ("sibling", sibling)];
let identity = an_identity("demo", "0.0.0");
let command = build_command(identity, &tasks);
let parsed = command.try_get_matches_from(["demo", "outer", "leaf"]);
assert!(
parsed.is_ok(),
"parsing a two-level path should succeed: {parsed:?}"
);
if let Ok(matches) = parsed {
let (task, task_matches) = resolve(&tasks, &matches);
let command_line = CommandLine::from_dispatch(identity, []);
let outcome = task.invoke(&command_line, task_matches);
let Err(failure) = outcome else {
unreachable!("the resolved leaf's own handler always returns Err");
};
assert_eq!(failure.to_string(), "the leaf two levels down ran");
}
}
}