use std::{any::TypeId, collections::HashSet};
use clap::{Arg, ArgAction, Command, Id};
use schemars::JsonSchema;
use serde_json::Value;
use crate::{
model::{
ArgumentGroupInfo, ArgumentInfo, ArgumentSyntax, ArgumentValue, CliContract, CommandSyntax,
DiscoveryNode, ExecutableData, SubcommandRouting,
},
schema::{
ExtendedSchemaFactory, SchemaFactory, compose_extended_schemas, extended_schema_factory,
output_schema_factory,
},
};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("unknown clap command path: {path}", path = format_path(.path))]
UnknownCommand {
path: Vec<String>,
},
#[error("duplicate executable command registration: {path}", path = format_path(.path))]
DuplicateCommandRegistration {
path: Vec<String>,
},
#[error("application-wide extension schema may only be declared once")]
DuplicateApplicationExtension,
#[error(
"executable command registration targets a command that requires a subcommand: {path}",
path = format_path(.path)
)]
ExecutableCommandRequiresSubcommand {
path: Vec<String>,
},
#[error(
"command-specific extension requires an executable command: {path}",
path = format_path(.path)
)]
CommandExtensionRequiresExecutable {
path: Vec<String>,
},
#[error("unsupported clap command framing mode: {mode}")]
UnsupportedCommandFraming {
mode: &'static str,
},
#[error("derived CommandSchema registration does not match clap subcommands for `{type_name}`")]
DerivedCommandMismatch {
type_name: &'static str,
},
#[error(
"command {path} has nested clap subcommands; derive CommandSchema for its Args payload",
path = format_path(.path)
)]
UnregisteredSubcommands {
path: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub(crate) struct PendingCommandRegistration {
path: Vec<String>,
id: TypeId,
output: Option<SchemaFactory>,
extended: Option<ExtendedSchemaFactory>,
}
#[derive(Debug, Default)]
pub(crate) struct RegistrationState {
registrations: Vec<PendingCommandRegistration>,
extended: Vec<ExtendedSchemaFactory>,
}
impl RegistrationState {
pub(crate) fn command<T>(&mut self, path: Vec<String>, extended: Option<ExtendedSchemaFactory>)
where
T: crate::__private::HandlerContract,
{
self.registrations.push(PendingCommandRegistration {
path,
id: TypeId::of::<T>(),
output: output_schema_factory::<T>(),
extended,
});
}
pub(crate) fn command_extension<T>(
&mut self,
path: &[String],
extended: ExtendedSchemaFactory,
) -> Result<()>
where
T: 'static,
{
let id = TypeId::of::<T>();
let Some(registration) = self
.registrations
.iter_mut()
.rev()
.find(|registration| registration.id == id && registration.path == path)
else {
return Err(Error::CommandExtensionRequiresExecutable { path: path.to_vec() });
};
registration.extended = Some(extended);
Ok(())
}
pub(crate) fn extend(&mut self, extended: ExtendedSchemaFactory) {
self.extended.push(extended);
}
}
#[derive(Debug)]
pub struct ContractBuilder {
root: Command,
registration: RegistrationState,
}
impl ContractBuilder {
#[must_use]
pub const fn new(root: Command) -> Self {
Self {
root,
registration: RegistrationState { registrations: Vec::new(), extended: Vec::new() },
}
}
pub(crate) const fn with_registration(root: Command, registration: RegistrationState) -> Self {
Self { root, registration }
}
#[must_use]
pub fn command<T>(mut self, path: impl IntoIterator<Item = impl Into<String>>) -> Self
where
T: crate::__private::HandlerContract,
{
self.registration.command::<T>(path.into_iter().map(Into::into).collect(), None);
self
}
#[must_use]
pub fn command_with_extension<T, E>(
mut self,
path: impl IntoIterator<Item = impl Into<String>>,
) -> Self
where
T: crate::__private::HandlerContract,
E: JsonSchema,
{
self.registration.command::<T>(
path.into_iter().map(Into::into).collect(),
Some(extended_schema_factory::<E>()),
);
self
}
#[must_use]
pub fn extend<T>(mut self) -> Self
where
T: JsonSchema,
{
self.registration.extend(extended_schema_factory::<T>());
self
}
pub fn build(self) -> Result<CliContract> {
let Self { mut root, registration } = self;
let RegistrationState { registrations, extended } = registration;
let extended = unique_application_extension(&extended)?;
root.build();
reject_unsupported_command_framing(&root)?;
reject_duplicate_paths(®istrations)?;
let application_extended_schema = extended.map(ExtendedSchemaFactory::root);
let mut registrations = registrations;
let discovery = discovery_tree(&root, &mut registrations, extended)?;
if let Some(registration) = registrations.first() {
return Err(Error::UnknownCommand { path: registration.path.clone() });
}
Ok(CliContract { discovery, extended_schema: application_extended_schema })
}
}
const fn unique_application_extension(
extended: &[ExtendedSchemaFactory],
) -> Result<Option<ExtendedSchemaFactory>> {
match extended {
[] => Ok(None),
[extended] => Ok(Some(*extended)),
_ => Err(Error::DuplicateApplicationExtension),
}
}
fn reject_unsupported_command_framing(root: &Command) -> Result<()> {
if root.is_multicall_set() {
return Err(Error::UnsupportedCommandFraming { mode: "multicall" });
}
if root.is_no_binary_name_set() {
return Err(Error::UnsupportedCommandFraming { mode: "no_binary_name" });
}
Ok(())
}
fn reject_duplicate_paths(registrations: &[PendingCommandRegistration]) -> Result<()> {
let mut seen = HashSet::with_capacity(registrations.len());
for registration in registrations {
if !seen.insert(registration.path.clone()) {
return Err(Error::DuplicateCommandRegistration { path: registration.path.clone() });
}
}
Ok(())
}
fn discovery_tree(
root: &Command,
registrations: &mut Vec<PendingCommandRegistration>,
application_extension: Option<ExtendedSchemaFactory>,
) -> Result<DiscoveryNode> {
Ok(build_discovery_node(root, Vec::new(), registrations, application_extension)?
.expect("the root discovery node is always retained"))
}
fn build_discovery_node(
command: &Command,
path: Vec<String>,
registrations: &mut Vec<PendingCommandRegistration>,
application_extension: Option<ExtendedSchemaFactory>,
) -> Result<Option<DiscoveryNode>> {
let pending = registrations
.iter()
.position(|registration| registration.path == path)
.map(|index| registrations.remove(index));
if pending.is_some() && command.is_subcommand_required_set() {
return Err(Error::ExecutableCommandRequiresSubcommand { path });
}
let mut children = Vec::new();
for child in command.get_subcommands() {
let mut child_path = path.clone();
child_path.push(child.get_name().to_owned());
if let Some(child) =
build_discovery_node(child, child_path, registrations, application_extension)?
{
children.push(child);
}
}
children.sort_by(|left, right| left.name.cmp(&right.name));
let executable = pending.map(|registration| {
let extended_schema = registration.extended.map(|extension| {
application_extension.map_or_else(
|| extension.root(),
|application| compose_extended_schemas(application, extension),
)
});
ExecutableData {
id: registration.id,
output: registration.output.map(|factory| factory()),
extended_schema,
}
});
if !path.is_empty() && executable.is_none() && children.is_empty() {
return Ok(None);
}
let arguments = reflected_positionals(command);
let options = reflected_options(command);
let groups = reflected_groups(command);
Ok(Some(DiscoveryNode {
name: command.get_name().to_owned(),
path,
aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
description: command
.get_about()
.or_else(|| command.get_long_about())
.map(ToString::to_string),
arguments,
options,
groups,
syntax: CommandSyntax {
allow_missing_positionals: command.is_allow_missing_positional_set(),
dont_delimit_trailing_values: command.is_dont_delimit_trailing_values_set(),
},
subcommand_routing: SubcommandRouting {
args_conflict_with_subcommands: command.is_args_conflicts_with_subcommands_set(),
subcommand_precedence_over_arg: command.is_subcommand_precedence_over_arg_set(),
subcommand_negates_requirements: command.is_subcommand_negates_reqs_set(),
},
executable,
children,
}))
}
fn reflected_positionals(command: &Command) -> Vec<ArgumentInfo> {
command
.get_positionals()
.filter(|argument| reflected_argument(argument))
.map(|argument| argument_info(command, argument))
.collect()
}
fn reflected_options(command: &Command) -> Vec<ArgumentInfo> {
command
.get_arguments()
.filter(|argument| !argument.is_positional())
.filter(|argument| reflected_argument(argument))
.map(|argument| argument_info(command, argument))
.collect()
}
fn reflected_argument(argument: &Arg) -> bool {
!matches!(
argument.get_action(),
ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version
)
}
fn argument_info(command: &Command, argument: &Arg) -> ArgumentInfo {
let action = argument.get_action();
let takes_values = action.takes_values();
let value = takes_values.then(|| argument_value(argument));
let conflicts_with = reflected_argument_conflicts(command, argument);
ArgumentInfo {
name: canonical_argument_name(argument),
position: argument.get_index(),
description: argument
.get_help()
.or_else(|| argument.get_long_help())
.map(ToString::to_string),
required: argument.is_required_set(),
global: argument.is_global_set(),
value,
repeatable: matches!(action, ArgAction::Append | ArgAction::Count),
conflicts_with,
syntax: ArgumentSyntax {
require_equals: takes_values
&& !argument.is_positional()
&& argument.is_require_equals_set(),
requires_double_dash: argument.is_positional() && argument.is_last_set(),
trailing_var_arg: argument.is_positional() && argument.is_trailing_var_arg_set(),
},
exclusive: argument.is_exclusive_set(),
}
}
fn argument_value(argument: &Arg) -> ArgumentValue {
let (min_values, max_values) = argument.get_num_args().map_or((1, Some(1)), |range| {
let max = range.max_values();
(range.min_values(), (max != usize::MAX).then_some(max))
});
let values = argument
.get_possible_values()
.into_iter()
.map(|value| value.get_name().to_owned())
.collect();
ArgumentValue {
min_values,
max_values,
values,
default: argument_default(argument),
delimiter: argument.get_value_delimiter(),
terminator: argument.get_value_terminator().map(ToString::to_string),
allow_hyphen_values: argument.is_allow_hyphen_values_set(),
allow_negative_numbers: argument.is_allow_negative_numbers_set(),
ignore_case: argument.is_ignore_case_set(),
}
}
fn canonical_argument_name(argument: &Arg) -> String {
argument.get_long().map_or_else(
|| {
argument
.get_short()
.map_or_else(|| argument.get_id().to_string(), |short| format!("-{short}"))
},
|long| format!("--{long}"),
)
}
fn argument_default(argument: &Arg) -> Option<Value> {
lexical_values(argument.get_default_values())
}
fn lexical_values(values: &[clap::builder::OsStr]) -> Option<Value> {
if values.is_empty() {
return None;
}
lexical_value_set(values)
}
fn lexical_value_set(values: &[clap::builder::OsStr]) -> Option<Value> {
let values = values
.iter()
.map(|value| value.to_str().map(ToOwned::to_owned))
.collect::<Option<Vec<_>>>()?;
match values.as_slice() {
[value] => Some(Value::String(value.clone())),
values => Some(Value::Array(values.iter().cloned().map(Value::String).collect())),
}
}
fn reflected_argument_name(command: &Command, id: &Id) -> Option<String> {
command
.get_arguments()
.find(|argument| argument.get_id() == id && reflected_argument(argument))
.map(canonical_argument_name)
}
fn reflected_argument_conflicts(command: &Command, argument: &Arg) -> Vec<String> {
let mut conflicts = Vec::new();
let mut push = |candidate: &Arg| {
if candidate.get_id() == argument.get_id() || !reflected_argument(candidate) {
return;
}
let name = canonical_argument_name(candidate);
if !conflicts.contains(&name) {
conflicts.push(name);
}
};
for conflict in command.get_arg_conflicts_with(argument) {
push(conflict);
}
for candidate in command.get_arguments() {
if command
.get_arg_conflicts_with(candidate)
.into_iter()
.any(|conflict| conflict.get_id() == argument.get_id())
{
push(candidate);
}
}
conflicts
}
fn reflected_groups(command: &Command) -> Vec<ArgumentGroupInfo> {
command
.get_groups()
.filter_map(|group| {
let members = group
.get_args()
.filter_map(|id| reflected_argument_name(command, id))
.collect::<Vec<_>>();
if members.is_empty() {
return None;
}
let mut owned_group = group.clone();
let group = ArgumentGroupInfo {
name: group.get_id().to_string(),
members,
required: group.is_required_set(),
multiple: owned_group.is_multiple(),
};
(group.required || (!group.multiple && group.members.len() > 1)).then_some(group)
})
.collect()
}
fn format_path(path: &[String]) -> String {
if path.is_empty() { "<root>".to_owned() } else { path.join(" ") }
}