use super::{
CommandBody, CommandIndex, CommandNode, Constraint, Globals, OptionShape, OptionSpec,
Positional, Ref, Tool, ToolMiddleware, ToolMiddlewareScope,
};
use crate::schema::graph::{RefResolutionError, SchemaGraph};
use crate::schema::metadata::TypeId;
use crate::schema::schema_type::SchemaType;
use crate::schema::schema_value::SchemaValue;
use crate::schema::validation::value::{ValueError, validate_value};
use crate::schema::validation::well_formedness::{SchemaError, validate_graph, validate_root_type};
use std::collections::HashSet;
use std::fmt::{self, Display, Formatter};
pub fn is_valid_identifier(s: &str) -> bool {
s.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
&& s.split('-').all(|part| {
!part.is_empty()
&& part
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToolValidationError {
EmptyVersion,
EmptyCommandTree,
CommandIndexOutOfBounds { index: i32, len: usize },
InvalidIdentifier { kind: &'static str, value: String },
DuplicateSubcommandName { parent: String, name: String },
DuplicateName { command: String, name: String },
DuplicateShort { command: String, short: char },
UnresolvedConstraintRef { command: String, name: String },
ValueIsTypeMismatch { command: String, name: String },
DefaultTypeMismatch { command: String, name: String },
RepeatableMapTypeNotMap { command: String, name: String },
UnresolvedTypeRef {
command: String,
position: String,
id: String,
},
IllFormedSchema {
command: String,
position: String,
detail: String,
},
DuplicateTypeId { id: String },
UnresolvedDefaultFormatter { command: String, formatter: String },
VerbatimWithoutSeparator { command: String, positional: String },
VariantInInputPosition { command: String, position: String },
UnreachableCommandNode { index: i32 },
CommandTreeCycle { index: i32 },
DuplicateCommandParent { index: i32 },
}
impl Display for ToolValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
ToolValidationError::EmptyVersion => write!(f, "the tool version is empty"),
ToolValidationError::EmptyCommandTree => {
write!(f, "the command tree is empty")
}
ToolValidationError::CommandIndexOutOfBounds { index, len } => {
write!(
f,
"command index {index} is out of bounds (tree has {len} nodes)"
)
}
ToolValidationError::InvalidIdentifier { kind, value } => {
write!(f, "invalid {kind}: {value:?}")
}
ToolValidationError::DuplicateSubcommandName { parent, name } => {
write!(
f,
"duplicate subcommand name {name:?} under command {parent:?}"
)
}
ToolValidationError::DuplicateName { command, name } => {
write!(f, "duplicate name {name:?} in command {command:?}")
}
ToolValidationError::DuplicateShort { command, short } => {
write!(f, "duplicate short form {short:?} in command {command:?}")
}
ToolValidationError::UnresolvedConstraintRef { command, name } => {
write!(
f,
"constraint references unknown name {name:?} in command {command:?}"
)
}
ToolValidationError::ValueIsTypeMismatch { command, name } => {
write!(
f,
"value-is literal for {name:?} is not valid for its type in command {command:?}"
)
}
ToolValidationError::DefaultTypeMismatch { command, name } => {
write!(
f,
"default literal for {name:?} is not valid for its type in command {command:?}"
)
}
ToolValidationError::RepeatableMapTypeNotMap { command, name } => {
write!(
f,
"repeatable-map option {name:?} in command {command:?} does not collect into a map type"
)
}
ToolValidationError::UnresolvedTypeRef {
command,
position,
id,
} => {
write!(
f,
"type reference {id:?} at position {position:?} in command {command:?} does not resolve to a definition in the tool schema"
)
}
ToolValidationError::IllFormedSchema {
command,
position,
detail,
} => {
write!(
f,
"type at position {position:?} in command {command:?} is not well-formed: {detail}"
)
}
ToolValidationError::DuplicateTypeId { id } => {
write!(f, "duplicate schema definition id {id:?}")
}
ToolValidationError::UnresolvedDefaultFormatter { command, formatter } => {
write!(
f,
"default-formatter {formatter:?} is not declared in command {command:?}"
)
}
ToolValidationError::VerbatimWithoutSeparator {
command,
positional,
} => {
write!(
f,
"tail positional {positional:?} in command {command:?} is verbatim but has no separator"
)
}
ToolValidationError::VariantInInputPosition { command, position } => {
write!(
f,
"a variant type is reachable from input position {position:?} in command {command:?}"
)
}
ToolValidationError::UnreachableCommandNode { index } => {
write!(f, "command node {index} is not reachable from the root")
}
ToolValidationError::CommandTreeCycle { index } => {
write!(f, "the command tree contains a cycle at node {index}")
}
ToolValidationError::DuplicateCommandParent { index } => {
write!(f, "command node {index} has more than one parent")
}
}
}
}
impl std::error::Error for ToolValidationError {}
pub fn validate_tool(tool: &Tool) -> Result<(), Vec<ToolValidationError>> {
let mut ctx = Validator::new(tool);
if tool.version.trim().is_empty() {
ctx.errors.push(ToolValidationError::EmptyVersion);
}
ctx.run();
if ctx.errors.is_empty() {
Ok(())
} else {
Err(ctx.errors)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToolMiddlewareValidationError {
EmptyVersion,
InvalidIdentifier { kind: &'static str, value: String },
DuplicateIdentity { value: String },
InvalidParameterSchema(Vec<SchemaError>),
ForbiddenParameterType(&'static str),
InvalidPresentedTool(Vec<ToolValidationError>),
InvalidExpectedTool(Vec<ToolValidationError>),
}
impl Display for ToolMiddlewareValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyVersion => write!(f, "the tool middleware version is empty"),
Self::InvalidIdentifier { kind, value } => write!(f, "invalid {kind}: {value:?}"),
Self::DuplicateIdentity { value } => {
write!(f, "duplicate tool middleware name or alias: {value:?}")
}
Self::InvalidParameterSchema(errors) => {
write!(f, "invalid tool middleware parameter schema: {errors:?}")
}
Self::ForbiddenParameterType(kind) => {
write!(
f,
"tool middleware parameter schema contains forbidden type {kind}"
)
}
Self::InvalidPresentedTool(errors) => {
write!(f, "invalid presented tool descriptor: {errors:?}")
}
Self::InvalidExpectedTool(errors) => {
write!(f, "invalid expected tool descriptor: {errors:?}")
}
}
}
}
impl std::error::Error for ToolMiddlewareValidationError {}
pub fn validate_tool_middleware(
middleware: &ToolMiddleware,
) -> Result<(), Vec<ToolMiddlewareValidationError>> {
let mut errors = Vec::new();
if middleware.version.trim().is_empty() {
errors.push(ToolMiddlewareValidationError::EmptyVersion);
}
let mut identities = HashSet::new();
for (kind, identity) in std::iter::once(("tool middleware name", &middleware.name)).chain(
middleware
.aliases
.iter()
.map(|alias| ("tool middleware alias", alias)),
) {
if !is_valid_identifier(identity) {
errors.push(ToolMiddlewareValidationError::InvalidIdentifier {
kind,
value: identity.clone(),
});
}
if !identities.insert(identity) {
errors.push(ToolMiddlewareValidationError::DuplicateIdentity {
value: identity.clone(),
});
}
}
if let Err(schema_errors) = validate_graph(&middleware.parameter_schema) {
errors.push(ToolMiddlewareValidationError::InvalidParameterSchema(
schema_errors,
));
}
for ty in std::iter::once(&middleware.parameter_schema.root)
.chain(middleware.parameter_schema.defs.iter().map(|def| &def.body))
{
collect_forbidden_parameter_types(ty, &mut errors);
}
if let ToolMiddlewareScope::Monomorphic(scope) = &middleware.scope {
if let Err(tool_errors) = validate_tool(&scope.presented) {
errors.push(ToolMiddlewareValidationError::InvalidPresentedTool(
tool_errors,
));
}
if let Some(expected) = &scope.expected
&& let Err(tool_errors) = validate_tool(expected)
{
errors.push(ToolMiddlewareValidationError::InvalidExpectedTool(
tool_errors,
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn collect_forbidden_parameter_types(
ty: &SchemaType,
errors: &mut Vec<ToolMiddlewareValidationError>,
) {
match ty {
SchemaType::Secret { .. } => errors.push(
ToolMiddlewareValidationError::ForbiddenParameterType("secret"),
),
SchemaType::QuotaToken { .. } => errors.push(
ToolMiddlewareValidationError::ForbiddenParameterType("quota-token"),
),
SchemaType::PermissionCard { .. } => errors.push(
ToolMiddlewareValidationError::ForbiddenParameterType("permission-card"),
),
SchemaType::Future { .. } => errors.push(
ToolMiddlewareValidationError::ForbiddenParameterType("future"),
),
SchemaType::Stream { .. } => errors.push(
ToolMiddlewareValidationError::ForbiddenParameterType("stream"),
),
SchemaType::Record { fields, .. } => fields
.iter()
.for_each(|field| collect_forbidden_parameter_types(&field.body, errors)),
SchemaType::Variant { cases, .. } => cases.iter().for_each(|case| {
if let Some(payload) = &case.payload {
collect_forbidden_parameter_types(payload, errors);
}
}),
SchemaType::Tuple { elements, .. } => elements
.iter()
.for_each(|element| collect_forbidden_parameter_types(element, errors)),
SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
collect_forbidden_parameter_types(element, errors)
}
SchemaType::Map { key, value, .. } => {
collect_forbidden_parameter_types(key, errors);
collect_forbidden_parameter_types(value, errors);
}
SchemaType::Option { inner, .. } => collect_forbidden_parameter_types(inner, errors),
SchemaType::Result { spec, .. } => {
if let Some(ok) = &spec.ok {
collect_forbidden_parameter_types(ok, errors);
}
if let Some(err) = &spec.err {
collect_forbidden_parameter_types(err, errors);
}
}
SchemaType::Union { spec, .. } => spec
.branches
.iter()
.for_each(|branch| collect_forbidden_parameter_types(&branch.body, errors)),
SchemaType::Ref { .. }
| SchemaType::Bool { .. }
| SchemaType::S8 { .. }
| SchemaType::S16 { .. }
| SchemaType::S32 { .. }
| SchemaType::S64 { .. }
| SchemaType::U8 { .. }
| SchemaType::U16 { .. }
| SchemaType::U32 { .. }
| SchemaType::U64 { .. }
| SchemaType::F32 { .. }
| SchemaType::F64 { .. }
| SchemaType::Char { .. }
| SchemaType::String { .. }
| SchemaType::Enum { .. }
| SchemaType::Flags { .. }
| SchemaType::Text { .. }
| SchemaType::Binary { .. }
| SchemaType::Path { .. }
| SchemaType::Url { .. }
| SchemaType::Uuid { .. }
| SchemaType::Datetime { .. }
| SchemaType::Duration { .. }
| SchemaType::Quantity { .. } => {}
}
}
struct InputRoot<'a> {
command: String,
position: String,
ty: &'a SchemaType,
}
struct Validator<'a> {
tool: &'a Tool,
errors: Vec<ToolValidationError>,
input_roots: Vec<InputRoot<'a>>,
used_ref_ids: HashSet<TypeId>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ValueIsMode {
WholeOrOnePeel,
Exact,
}
#[derive(Clone, Copy)]
enum ValueComparand<'a> {
Type(&'a SchemaType, ValueIsMode),
BlockedByTypeError,
}
enum ValueIsOutcome {
Compatible,
Mismatch,
BlockedByDanglingRef,
}
#[derive(Default)]
struct NameScope<'a> {
names: HashSet<String>,
typed: std::collections::HashMap<String, ValueComparand<'a>>,
}
impl<'a> Validator<'a> {
fn new(tool: &'a Tool) -> Self {
Self {
tool,
errors: Vec::new(),
input_roots: Vec::new(),
used_ref_ids: HashSet::new(),
}
}
fn run(&mut self) {
self.check_duplicate_type_ids();
if self.tool.commands.nodes.is_empty() {
self.errors.push(ToolValidationError::EmptyCommandTree);
}
if !self.tool.commands.nodes.is_empty() {
let mut visited = HashSet::new();
let mut on_stack = HashSet::new();
self.visit_command(CommandIndex(0), &[], &mut visited, &mut on_stack);
for i in 0..self.tool.commands.nodes.len() {
if !visited.contains(&(i as i32)) {
self.errors
.push(ToolValidationError::UnreachableCommandNode { index: i as i32 });
}
}
}
self.check_no_variant_in_input();
self.check_def_refs();
}
fn command_node(&self, index: CommandIndex) -> Option<&'a CommandNode> {
index
.as_usize()
.and_then(|i| self.tool.commands.nodes.get(i))
}
fn check_identifier(&mut self, kind: &'static str, value: &str) {
if !is_valid_identifier(value) {
self.errors.push(ToolValidationError::InvalidIdentifier {
kind,
value: value.to_string(),
});
}
}
fn visit_command(
&mut self,
index: CommandIndex,
ancestor_globals: &[&'a Globals],
visited: &mut HashSet<i32>,
on_stack: &mut HashSet<i32>,
) {
if on_stack.contains(&index.0) {
self.errors
.push(ToolValidationError::CommandTreeCycle { index: index.0 });
return;
}
if !visited.insert(index.0) {
self.errors
.push(ToolValidationError::DuplicateCommandParent { index: index.0 });
return;
}
let Some(node) = self.command_node(index) else {
return;
};
on_stack.insert(index.0);
self.check_identifier("command name", &node.name);
for alias in &node.aliases {
self.check_identifier("command alias", alias);
}
self.check_globals(&node.name, &node.globals);
self.check_global_scope_uniqueness(&node.name, ancestor_globals, &node.globals);
let mut in_scope: Vec<&'a Globals> = ancestor_globals.to_vec();
in_scope.push(&node.globals);
if let Some(body) = &node.body {
self.check_body(&node.name, body, &in_scope);
}
self.check_subcommand_uniqueness(node);
for sub in &node.subcommands {
let len = self.tool.commands.nodes.len();
if sub.as_usize().filter(|i| *i < len).is_none() {
self.errors
.push(ToolValidationError::CommandIndexOutOfBounds { index: sub.0, len });
continue;
}
self.visit_command(*sub, &in_scope, visited, on_stack);
}
on_stack.remove(&index.0);
}
fn check_globals(&mut self, command: &str, globals: &'a Globals) {
for opt in &globals.options {
self.check_option_identifiers(opt);
self.check_repeatable_map(command, opt);
self.check_option_default(command, opt);
self.check_type_well_formed(command, &opt.long, option_input_type(opt));
self.input_roots.push(InputRoot {
command: command.to_string(),
position: opt.long.clone(),
ty: option_input_type(opt),
});
}
for flag in &globals.flags {
self.check_identifier("flag long name", &flag.long);
for alias in &flag.aliases {
self.check_identifier("flag alias", alias);
}
}
}
fn check_global_scope_uniqueness(
&mut self,
command: &str,
ancestors: &[&'a Globals],
own: &Globals,
) {
let mut names: HashSet<String> = HashSet::new();
let mut shorts: HashSet<char> = HashSet::new();
for globals in ancestors {
for opt in &globals.options {
names.insert(opt.long.clone());
names.extend(opt.aliases.iter().cloned());
if let Some(short) = opt.short {
shorts.insert(short);
}
}
for flag in &globals.flags {
names.insert(flag.long.clone());
names.extend(flag.aliases.iter().cloned());
if let Some(short) = flag.short {
shorts.insert(short);
}
}
}
let mut add_name = |this: &mut Self, name: &str| {
if !names.insert(name.to_string()) {
this.errors.push(ToolValidationError::DuplicateName {
command: command.to_string(),
name: name.to_string(),
});
}
};
for opt in &own.options {
add_name(self, &opt.long);
for alias in &opt.aliases {
add_name(self, alias);
}
if let Some(short) = opt.short
&& !shorts.insert(short)
{
self.errors.push(ToolValidationError::DuplicateShort {
command: command.to_string(),
short,
});
}
}
for flag in &own.flags {
add_name(self, &flag.long);
for alias in &flag.aliases {
add_name(self, alias);
}
if let Some(short) = flag.short
&& !shorts.insert(short)
{
self.errors.push(ToolValidationError::DuplicateShort {
command: command.to_string(),
short,
});
}
}
}
fn check_option_identifiers(&mut self, opt: &OptionSpec) {
self.check_identifier("option long name", &opt.long);
for alias in &opt.aliases {
self.check_identifier("option alias", alias);
}
}
fn check_repeatable_map(&mut self, command: &str, opt: &OptionSpec) {
if let OptionShape::RepeatableMap(shape) = &opt.shape {
let not_a_map = match self.tool.schema.resolve_ref(&shape.map_type) {
Ok(SchemaType::Map { .. }) => false,
Ok(_) | Err(RefResolutionError::RecursiveRef(_)) => true,
Err(RefResolutionError::DanglingRef(_)) => false,
};
if not_a_map {
self.errors
.push(ToolValidationError::RepeatableMapTypeNotMap {
command: command.to_string(),
name: opt.long.clone(),
});
}
}
}
fn check_option_default(&mut self, command: &str, opt: &OptionSpec) {
let Some(default) = &opt.default else {
return;
};
let graph = &self.tool.schema;
let result = match &opt.shape {
OptionShape::Scalar(ty) | OptionShape::OptionalScalar(ty) => {
validate_value(graph, ty, default)
}
OptionShape::RepeatableList(shape) => {
let list_ty = SchemaType::list(shape.item_type.clone());
validate_value(graph, &list_ty, default)
}
OptionShape::RepeatableMap(shape) => validate_value(graph, &shape.map_type, default),
};
if let Err(errors) = result
&& !value_mismatch_is_only_dangling(&errors)
{
self.errors.push(ToolValidationError::DefaultTypeMismatch {
command: command.to_string(),
name: opt.long.clone(),
});
}
}
fn check_positional_default(&mut self, command: &str, positional: &Positional) {
if let Some(default) = &positional.default
&& let Err(errors) = validate_value(&self.tool.schema, &positional.type_, default)
&& !value_mismatch_is_only_dangling(&errors)
{
self.errors.push(ToolValidationError::DefaultTypeMismatch {
command: command.to_string(),
name: positional.name.clone(),
});
}
}
fn check_type_well_formed(&mut self, command: &str, position: &str, ty: &SchemaType) {
collect_structural_ref_ids(ty, &mut self.used_ref_ids);
if let Err(errors) = validate_root_type(&self.tool.schema, ty) {
self.errors.extend(map_schema_errors(
command.to_string(),
position.to_string(),
errors,
));
}
}
fn check_duplicate_type_ids(&mut self) {
let mut seen: HashSet<String> = HashSet::new();
for def in &self.tool.schema.defs {
if !seen.insert(def.id.to_string()) {
self.errors.push(ToolValidationError::DuplicateTypeId {
id: def.id.to_string(),
});
}
}
}
fn check_def_refs(&mut self) {
let mut seeds = self.used_ref_ids.clone();
for def in &self.tool.schema.defs {
if !matches!(def.body, SchemaType::Ref { .. }) {
collect_structural_ref_ids(&def.body, &mut seeds);
}
}
let covered = pure_alias_closure(&self.tool.schema, &seeds);
let mut seen_ids: HashSet<&TypeId> = HashSet::new();
let mut duplicate_ids: HashSet<&TypeId> = HashSet::new();
for def in &self.tool.schema.defs {
if !seen_ids.insert(&def.id) {
duplicate_ids.insert(&def.id);
}
}
let mut reported: Vec<ToolValidationError> = Vec::new();
for def in &self.tool.schema.defs {
if covered.contains(&def.id)
&& matches!(def.body, SchemaType::Ref { .. })
&& !duplicate_ids.contains(&def.id)
{
continue;
}
if let Err(errors) = validate_root_type(&self.tool.schema, &def.body) {
let def_id = def.id.to_string();
reported.extend(map_schema_errors(format!("<def {def_id}>"), def_id, errors));
}
}
self.errors.extend(reported);
}
fn check_subcommand_uniqueness(&mut self, node: &'a CommandNode) {
let mut seen: HashSet<&str> = HashSet::new();
for sub in &node.subcommands {
let Some(child) = self.command_node(*sub) else {
continue;
};
for token in std::iter::once(&child.name).chain(child.aliases.iter()) {
if !seen.insert(token.as_str()) {
self.errors
.push(ToolValidationError::DuplicateSubcommandName {
parent: node.name.clone(),
name: token.clone(),
});
}
}
}
}
fn check_body(&mut self, command: &str, body: &'a CommandBody, in_scope: &[&'a Globals]) {
let graph: &'a SchemaGraph = &self.tool.schema;
let mut global_names: HashSet<String> = HashSet::new();
let mut global_shorts: HashSet<char> = HashSet::new();
for globals in in_scope {
for opt in &globals.options {
global_names.insert(opt.long.clone());
global_names.extend(opt.aliases.iter().cloned());
if let Some(short) = opt.short {
global_shorts.insert(short);
}
}
for flag in &globals.flags {
global_names.insert(flag.long.clone());
global_names.extend(flag.aliases.iter().cloned());
if let Some(short) = flag.short {
global_shorts.insert(short);
}
}
}
let mut names = global_names.clone();
let mut shorts = global_shorts.clone();
let mut add_name = |this: &mut Self, name: &str| {
if !names.insert(name.to_string()) {
this.errors.push(ToolValidationError::DuplicateName {
command: command.to_string(),
name: name.to_string(),
});
}
};
let mut scope = NameScope::default();
for globals in in_scope {
for opt in &globals.options {
register_option(graph, &mut scope, opt);
}
for flag in &globals.flags {
scope.names.insert(flag.long.clone());
scope.names.extend(flag.aliases.iter().cloned());
}
}
for opt in &body.options {
self.check_option_identifiers(opt);
add_name(self, &opt.long);
for alias in &opt.aliases {
add_name(self, alias);
}
if let Some(short) = opt.short
&& !shorts.insert(short)
{
self.errors.push(ToolValidationError::DuplicateShort {
command: command.to_string(),
short,
});
}
register_option(graph, &mut scope, opt);
self.check_repeatable_map(command, opt);
self.check_option_default(command, opt);
self.check_type_well_formed(command, &opt.long, option_input_type(opt));
self.input_roots.push(InputRoot {
command: command.to_string(),
position: opt.long.clone(),
ty: option_input_type(opt),
});
}
for flag in &body.flags {
self.check_identifier("flag long name", &flag.long);
for alias in &flag.aliases {
self.check_identifier("flag alias", alias);
}
add_name(self, &flag.long);
for alias in &flag.aliases {
add_name(self, alias);
}
if let Some(short) = flag.short
&& !shorts.insert(short)
{
self.errors.push(ToolValidationError::DuplicateShort {
command: command.to_string(),
short,
});
}
scope.names.insert(flag.long.clone());
scope.names.extend(flag.aliases.iter().cloned());
}
for positional in &body.positionals.fixed {
self.check_identifier("positional name", &positional.name);
add_name(self, &positional.name);
scope.names.insert(positional.name.clone());
scope.typed.insert(
positional.name.clone(),
ValueComparand::Type(&positional.type_, ValueIsMode::WholeOrOnePeel),
);
self.check_positional_default(command, positional);
self.check_type_well_formed(command, &positional.name, &positional.type_);
self.input_roots.push(InputRoot {
command: command.to_string(),
position: positional.name.clone(),
ty: &positional.type_,
});
}
if let Some(tail) = &body.positionals.tail {
self.check_identifier("positional name", &tail.name);
add_name(self, &tail.name);
scope.names.insert(tail.name.clone());
scope.typed.insert(
tail.name.clone(),
ValueComparand::Type(&tail.item_type, ValueIsMode::Exact),
);
self.check_type_well_formed(command, &tail.name, &tail.item_type);
self.input_roots.push(InputRoot {
command: command.to_string(),
position: tail.name.clone(),
ty: &tail.item_type,
});
if tail.verbatim && tail.separator.is_none() {
self.errors
.push(ToolValidationError::VerbatimWithoutSeparator {
command: command.to_string(),
positional: tail.name.clone(),
});
}
}
for constraint in &body.constraints {
self.check_constraint(command, constraint, &scope);
}
if let Some(result) = &body.result {
for formatter in &result.formatters {
self.check_identifier("formatter name", &formatter.name);
}
let resolved = result
.formatters
.iter()
.any(|f| f.name == result.default_formatter);
if !resolved {
self.errors
.push(ToolValidationError::UnresolvedDefaultFormatter {
command: command.to_string(),
formatter: result.default_formatter.clone(),
});
}
self.check_type_well_formed(command, "result", &result.type_);
}
for error_case in &body.errors {
self.check_identifier("error-case name", &error_case.name);
if let Some(payload) = &error_case.payload {
self.check_type_well_formed(command, &error_case.name, payload);
}
}
}
fn check_constraint(&mut self, command: &str, constraint: &Constraint, scope: &NameScope) {
let refs = collect_refs(constraint);
for r in refs {
match r {
Ref::Present(name) => {
if !scope.names.contains(name) {
self.errors
.push(ToolValidationError::UnresolvedConstraintRef {
command: command.to_string(),
name: name.clone(),
});
}
}
Ref::ValueIs(value_is) => {
if !scope.names.contains(&value_is.name) {
self.errors
.push(ToolValidationError::UnresolvedConstraintRef {
command: command.to_string(),
name: value_is.name.clone(),
});
continue;
}
match scope.typed.get(&value_is.name) {
Some(ValueComparand::Type(declared, mode)) => {
match self.value_is_outcome(declared, *mode, &value_is.value) {
ValueIsOutcome::Compatible => {}
ValueIsOutcome::Mismatch => {
self.errors.push(ToolValidationError::ValueIsTypeMismatch {
command: command.to_string(),
name: value_is.name.clone(),
});
}
ValueIsOutcome::BlockedByDanglingRef => {
}
}
}
Some(ValueComparand::BlockedByTypeError) => {
}
None => {
self.errors.push(ToolValidationError::ValueIsTypeMismatch {
command: command.to_string(),
name: value_is.name.clone(),
});
}
}
}
}
}
}
fn value_is_outcome(
&self,
declared: &SchemaType,
mode: ValueIsMode,
value: &SchemaValue,
) -> ValueIsOutcome {
let graph = &self.tool.schema;
let direct = validate_value(graph, declared, value);
if direct.is_ok() {
return ValueIsOutcome::Compatible;
}
if mode == ValueIsMode::Exact {
return match direct {
Err(errors) if value_mismatch_is_only_dangling(&errors) => {
ValueIsOutcome::BlockedByDanglingRef
}
_ => ValueIsOutcome::Mismatch,
};
}
if let Ok(mut peeled) = graph.resolve_ref(declared) {
while let SchemaType::Option { inner, .. } = peeled {
match graph.resolve_ref(inner) {
Ok(next) => peeled = next,
Err(RefResolutionError::DanglingRef(_)) => {
return ValueIsOutcome::BlockedByDanglingRef;
}
Err(RefResolutionError::RecursiveRef(_)) => {
return ValueIsOutcome::Mismatch;
}
}
}
let relaxed = match peeled {
SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
Some(element.as_ref())
}
SchemaType::Map { value: v, .. } => Some(v.as_ref()),
_ => None,
};
if let Some(inner_ty) = relaxed {
return match validate_value(graph, inner_ty, value) {
Ok(()) => ValueIsOutcome::Compatible,
Err(errors) if value_mismatch_is_only_dangling(&errors) => {
ValueIsOutcome::BlockedByDanglingRef
}
Err(_) => ValueIsOutcome::Mismatch,
};
}
}
match direct {
Err(errors) if value_mismatch_is_only_dangling(&errors) => {
ValueIsOutcome::BlockedByDanglingRef
}
_ => ValueIsOutcome::Mismatch,
}
}
fn check_no_variant_in_input(&mut self) {
let graph = &self.tool.schema;
let mut errors = Vec::new();
for root in &self.input_roots {
let mut visited: HashSet<String> = HashSet::new();
if type_reaches_variant(graph, root.ty, &mut visited) {
errors.push(ToolValidationError::VariantInInputPosition {
command: root.command.clone(),
position: root.position.clone(),
});
}
}
self.errors.extend(errors);
}
}
fn option_value_type<'a>(graph: &'a SchemaGraph, opt: &'a OptionSpec) -> Option<&'a SchemaType> {
match &opt.shape {
OptionShape::Scalar(t) | OptionShape::OptionalScalar(t) => Some(t),
OptionShape::RepeatableList(shape) => Some(&shape.item_type),
OptionShape::RepeatableMap(shape) => match graph.resolve_ref(&shape.map_type).ok()? {
SchemaType::Map { value, .. } => Some(value),
_ => None,
},
}
}
fn option_input_type(opt: &OptionSpec) -> &SchemaType {
match &opt.shape {
OptionShape::Scalar(t) | OptionShape::OptionalScalar(t) => t,
OptionShape::RepeatableList(shape) => &shape.item_type,
OptionShape::RepeatableMap(shape) => &shape.map_type,
}
}
fn register_option<'a>(graph: &'a SchemaGraph, scope: &mut NameScope<'a>, opt: &'a OptionSpec) {
scope.names.insert(opt.long.clone());
scope.names.extend(opt.aliases.iter().cloned());
let comparand = match option_value_type(graph, opt) {
Some(ty) => ValueComparand::Type(ty, option_value_is_mode(opt)),
None => ValueComparand::BlockedByTypeError,
};
scope.typed.insert(opt.long.clone(), comparand);
for alias in &opt.aliases {
scope.typed.insert(alias.clone(), comparand);
}
}
fn option_value_is_mode(opt: &OptionSpec) -> ValueIsMode {
match &opt.shape {
OptionShape::Scalar(_) | OptionShape::OptionalScalar(_) => ValueIsMode::WholeOrOnePeel,
OptionShape::RepeatableList(_) | OptionShape::RepeatableMap(_) => ValueIsMode::Exact,
}
}
fn type_reaches_variant(
graph: &SchemaGraph,
ty: &SchemaType,
visited: &mut HashSet<String>,
) -> bool {
if let SchemaType::Ref { id, .. } = ty
&& !visited.insert(id.to_string())
{
return false;
}
let Ok(resolved) = graph.resolve_ref(ty) else {
return false;
};
match resolved {
SchemaType::Variant { .. } => true,
SchemaType::Record { fields, .. } => fields
.iter()
.any(|f| type_reaches_variant(graph, &f.body, visited)),
SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
type_reaches_variant(graph, element, visited)
}
SchemaType::Option { inner, .. } => type_reaches_variant(graph, inner, visited),
SchemaType::Map { key, value, .. } => {
type_reaches_variant(graph, key, visited) || type_reaches_variant(graph, value, visited)
}
SchemaType::Tuple { elements, .. } => elements
.iter()
.any(|e| type_reaches_variant(graph, e, visited)),
SchemaType::Result { spec, .. } => {
spec.ok
.as_deref()
.is_some_and(|t| type_reaches_variant(graph, t, visited))
|| spec
.err
.as_deref()
.is_some_and(|t| type_reaches_variant(graph, t, visited))
}
SchemaType::Union { spec, .. } => spec
.branches
.iter()
.any(|b| type_reaches_variant(graph, &b.body, visited)),
SchemaType::Future { inner: Some(t), .. } | SchemaType::Stream { inner: Some(t), .. } => {
type_reaches_variant(graph, t, visited)
}
_ => false,
}
}
fn value_mismatch_is_only_dangling(errors: &[ValueError]) -> bool {
!errors.is_empty()
&& errors
.iter()
.all(|e| matches!(e, ValueError::DanglingRef { .. }))
}
fn map_schema_errors(
command: String,
position: String,
errors: Vec<SchemaError>,
) -> Vec<ToolValidationError> {
let mut seen_chain_targets: HashSet<TypeId> = HashSet::new();
let mut mapped = Vec::with_capacity(errors.len());
for error in errors {
match error {
SchemaError::DanglingRef(id) => {
if seen_chain_targets.insert(id.clone()) {
mapped.push(ToolValidationError::UnresolvedTypeRef {
command: command.clone(),
position: position.clone(),
id: id.to_string(),
});
}
}
SchemaError::RecursiveAlias(id) => {
if seen_chain_targets.insert(id.clone()) {
mapped.push(ToolValidationError::IllFormedSchema {
command: command.clone(),
position: position.clone(),
detail: SchemaError::RecursiveAlias(id).to_string(),
});
}
}
other => mapped.push(ToolValidationError::IllFormedSchema {
command: command.clone(),
position: position.clone(),
detail: other.to_string(),
}),
}
}
mapped
}
fn collect_structural_ref_ids(ty: &SchemaType, out: &mut HashSet<TypeId>) {
match ty {
SchemaType::Ref { id, .. } => {
out.insert(id.clone());
}
SchemaType::Record { fields, .. } => {
for field in fields {
collect_structural_ref_ids(&field.body, out);
}
}
SchemaType::Variant { cases, .. } => {
for case in cases {
if let Some(payload) = &case.payload {
collect_structural_ref_ids(payload, out);
}
}
}
SchemaType::Tuple { elements, .. } => {
for element in elements {
collect_structural_ref_ids(element, out);
}
}
SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
collect_structural_ref_ids(element, out);
}
SchemaType::Map { key, value, .. } => {
collect_structural_ref_ids(key, out);
collect_structural_ref_ids(value, out);
}
SchemaType::Option { inner, .. } => collect_structural_ref_ids(inner, out),
SchemaType::Result { spec, .. } => {
if let Some(ok) = &spec.ok {
collect_structural_ref_ids(ok, out);
}
if let Some(err) = &spec.err {
collect_structural_ref_ids(err, out);
}
}
SchemaType::Union { spec, .. } => {
for branch in &spec.branches {
collect_structural_ref_ids(&branch.body, out);
}
}
SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
if let Some(inner) = inner {
collect_structural_ref_ids(inner, out);
}
}
_ => {}
}
}
fn pure_alias_closure(graph: &SchemaGraph, seeds: &HashSet<TypeId>) -> HashSet<TypeId> {
let mut covered = HashSet::new();
for seed in seeds {
let mut current = seed.clone();
while let Some(def) = graph.lookup(¤t) {
let SchemaType::Ref { id, .. } = &def.body else {
break;
};
if !covered.insert(current.clone()) {
break;
}
current = id.clone();
}
}
covered
}
fn collect_refs(constraint: &Constraint) -> Vec<&Ref> {
match constraint {
Constraint::RequiresAll(refs)
| Constraint::AllOrNone(refs)
| Constraint::RequiresAny(refs) => refs.iter().collect(),
Constraint::MutexGroups(groups) => groups.iter().flat_map(|g| g.refs.iter()).collect(),
Constraint::Implies(implies) => implies.lhs.iter().chain(implies.rhs.iter()).collect(),
Constraint::Forbids(forbids) => forbids.lhs.iter().chain(forbids.rhs.iter()).collect(),
}
}
#[cfg(test)]
mod tests;