use std::{any::TypeId, collections::HashSet};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliContract {
pub(crate) discovery: DiscoveryNode,
pub(crate) extended_schema: Option<Value>,
}
impl CliContract {
#[must_use]
pub fn command_for<T>(&self) -> Option<CommandInfo>
where
T: 'static,
{
let node = self.unique_command_node::<T>()?;
Some(self.command_info(node))
}
#[must_use]
pub const fn extended_schema(&self) -> Option<&Value> {
self.extended_schema.as_ref()
}
pub fn extended_schema_for(&self, path: &[&str]) -> crate::Result<Option<&Value>> {
let node = self.discovery.resolve(path)?;
Ok(self.extended_schema_for_node(node))
}
#[must_use]
pub fn extended_schema_for_command<T>(&self) -> Option<&Value>
where
T: 'static,
{
let node = self.unique_command_node::<T>()?;
self.extended_schema_for_node(node)
}
pub fn schema(&self, request: &SchemaRequest) -> crate::Result<SchemaDocument> {
let path = request.path.iter().map(String::as_str).collect::<Vec<_>>();
let node = self.discovery.resolve(&path)?;
Ok(self.schema_document(node, request.full))
}
pub fn command(&self, path: &[&str]) -> crate::Result<CommandInfo> {
let node = self.discovery.resolve(path)?;
Ok(self.command_info(node))
}
fn command_info(&self, node: &DiscoveryNode) -> CommandInfo {
let (ancestors, inherited_globals) = self.ancestor_contexts(node);
CommandInfo {
name: node.name.clone(),
path: node.path.clone(),
ancestors,
description: node.description.clone(),
arguments: owned_arguments(&node.arguments, &inherited_globals),
options: owned_arguments(&node.options, &inherited_globals),
groups: node.groups.clone(),
syntax: node.syntax,
subcommand_routing: node.subcommand_routing,
invocable: node.executable.is_some(),
output: node.executable.as_ref().and_then(|executable| executable.output.clone()),
}
}
fn ancestor_contexts(&self, node: &DiscoveryNode) -> (Vec<CommandContext>, HashSet<String>) {
let mut current = &self.discovery;
let mut ancestors = Vec::with_capacity(node.path.len());
let mut inherited_globals = HashSet::new();
for segment in &node.path {
ancestors.push(CommandContext::from_node(current, &inherited_globals));
remember_globals(current, &mut inherited_globals);
current = current
.children
.iter()
.find(|child| child.name == *segment)
.expect("canonical discovery paths resolve through their ancestors");
}
(ancestors, inherited_globals)
}
fn schema_document(&self, node: &DiscoveryNode, full: bool) -> SchemaDocument {
let command = self.command_info(node);
let subcommands = node
.children
.iter()
.map(|child| {
if full {
SchemaSubcommand::Resolved(Box::new(self.schema_document(child, true)))
} else {
let command = self.command_info(child);
SchemaSubcommand::Summary(SchemaCommandSummary::from_command(
&command,
!child.children.is_empty(),
))
}
})
.collect();
SchemaDocument { command, subcommands }
}
fn unique_command_node<T>(&self) -> Option<&DiscoveryNode>
where
T: 'static,
{
self.discovery.unique_command(TypeId::of::<T>())
}
fn extended_schema_for_node<'a>(&'a self, node: &'a DiscoveryNode) -> Option<&'a Value> {
node.executable
.as_ref()
.and_then(|executable| executable.extended_schema.as_ref())
.or(self.extended_schema.as_ref())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SchemaRequest {
pub path: Vec<String>,
pub full: bool,
}
impl SchemaRequest {
#[must_use]
pub fn new<I, S>(path: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self { path: path.into_iter().map(Into::into).collect(), full: false }
}
#[must_use]
pub const fn with_full(mut self, full: bool) -> Self {
self.full = full;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SchemaDocument {
#[serde(flatten)]
pub command: CommandInfo,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub subcommands: Vec<SchemaSubcommand>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(untagged)]
#[non_exhaustive]
pub enum SchemaSubcommand {
Summary(SchemaCommandSummary),
Resolved(Box<SchemaDocument>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExecutableData {
pub(crate) id: TypeId,
pub(crate) output: Option<Value>,
pub(crate) extended_schema: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CommandContext {
pub name: String,
pub path: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<ArgumentInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub options: Vec<ArgumentInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<ArgumentGroupInfo>,
#[serde(flatten)]
pub syntax: CommandSyntax,
#[serde(flatten)]
pub subcommand_routing: SubcommandRouting,
}
impl CommandContext {
fn from_node(node: &DiscoveryNode, inherited_globals: &HashSet<String>) -> Self {
Self {
name: node.name.clone(),
path: node.path.clone(),
arguments: owned_arguments(&node.arguments, inherited_globals),
options: owned_arguments(&node.options, inherited_globals),
groups: node.groups.clone(),
syntax: node.syntax,
subcommand_routing: node.subcommand_routing,
}
}
}
fn owned_arguments(
arguments: &[ArgumentInfo],
inherited_globals: &HashSet<String>,
) -> Vec<ArgumentInfo> {
arguments
.iter()
.filter(|argument| !argument.global || !inherited_globals.contains(&argument.name))
.cloned()
.collect()
}
fn remember_globals(node: &DiscoveryNode, globals: &mut HashSet<String>) {
globals.extend(
node.arguments
.iter()
.chain(&node.options)
.filter(|argument| argument.global)
.map(|argument| argument.name.clone()),
);
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CommandInfo {
pub name: String,
pub path: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ancestors: Vec<CommandContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<ArgumentInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub options: Vec<ArgumentInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<ArgumentGroupInfo>,
#[serde(flatten)]
pub syntax: CommandSyntax,
#[serde(flatten)]
pub subcommand_routing: SubcommandRouting,
#[serde(default, skip_serializing_if = "is_false")]
pub invocable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<Value>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CommandSyntax {
#[serde(default, skip_serializing_if = "is_false")]
pub allow_missing_positionals: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub dont_delimit_trailing_values: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SubcommandRouting {
#[serde(default, skip_serializing_if = "is_false")]
pub args_conflict_with_subcommands: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub subcommand_precedence_over_arg: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub subcommand_negates_requirements: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SchemaCommandSummary {
pub path: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub invocable: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub has_subcommands: bool,
}
impl SchemaCommandSummary {
fn from_command(command: &CommandInfo, has_subcommands: bool) -> Self {
Self {
path: command.path.clone(),
description: command.description.clone(),
invocable: command.invocable,
has_subcommands,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
#[expect(
clippy::struct_excessive_bools,
reason = "independent boolean properties are part of the serialized invocation contract"
)]
pub struct ArgumentInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub required: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub global: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<ArgumentValue>,
#[serde(default, skip_serializing_if = "is_false")]
pub repeatable: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub conflicts_with: Vec<String>,
#[serde(flatten)]
pub syntax: ArgumentSyntax,
#[serde(default, skip_serializing_if = "is_false")]
pub exclusive: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArgumentSyntax {
#[serde(default, skip_serializing_if = "is_false")]
pub require_equals: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub requires_double_dash: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub trailing_var_arg: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArgumentValue {
pub min_values: usize,
pub max_values: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter: Option<char>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminator: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_hyphen_values: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_negative_numbers: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub ignore_case: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArgumentGroupInfo {
pub name: String,
pub members: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub required: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub multiple: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DiscoveryNode {
pub(crate) name: String,
pub(crate) path: Vec<String>,
pub(crate) aliases: Vec<String>,
pub(crate) description: Option<String>,
pub(crate) arguments: Vec<ArgumentInfo>,
pub(crate) options: Vec<ArgumentInfo>,
pub(crate) groups: Vec<ArgumentGroupInfo>,
pub(crate) syntax: CommandSyntax,
pub(crate) subcommand_routing: SubcommandRouting,
pub(crate) executable: Option<ExecutableData>,
pub(crate) children: Vec<Self>,
}
impl DiscoveryNode {
pub(crate) fn unique_command(&self, id: TypeId) -> Option<&Self> {
fn visit<'a>(
node: &'a DiscoveryNode,
id: TypeId,
found: &mut Option<&'a DiscoveryNode>,
ambiguous: &mut bool,
) {
if node.executable.as_ref().is_some_and(|executable| executable.id == id) {
if found.is_some() {
*ambiguous = true;
return;
}
*found = Some(node);
}
for child in &node.children {
if *ambiguous {
return;
}
visit(child, id, found, ambiguous);
}
}
let mut found = None;
let mut ambiguous = false;
visit(self, id, &mut found, &mut ambiguous);
if ambiguous { None } else { found }
}
pub(crate) fn resolve(&self, path: &[&str]) -> crate::Result<&Self> {
let mut node = self;
for segment in path {
node = node
.children
.iter()
.find(|candidate| {
candidate.name == *segment
|| candidate.aliases.iter().any(|alias| alias == *segment)
})
.ok_or_else(|| crate::Error::UnknownCommand {
path: path.iter().map(|segment| (*segment).to_owned()).collect(),
})?;
}
Ok(node)
}
}
const fn is_false(value: &bool) -> bool {
!*value
}