1use super::{
42 CommandNode, FlagShape, FlagSpec, OptionShape, OptionSpec, Positional, TailPositional, Tool,
43};
44use crate::schema::graph::{SchemaGraph, reachable_defs};
45use crate::schema::metadata::MetadataEnvelope;
46use crate::schema::schema_type::{NamedFieldType, SchemaType};
47use crate::schema::schema_value::SchemaValue;
48use crate::schema::tool::validation::ToolValidationError;
49use crate::schema::validation::well_formedness::{SchemaError, validate_graph};
50use std::collections::BTreeSet;
51
52#[derive(Clone, Debug, PartialEq)]
55pub struct CanonicalInputField {
56 pub name: String,
57 pub aliases: Vec<String>,
58 pub short: Option<char>,
59 pub type_: SchemaType,
60}
61
62#[derive(Clone, Debug, PartialEq)]
66pub struct CanonicalInputModel {
67 pub fields: Vec<CanonicalInputField>,
68 pub record_schema: SchemaGraph,
69}
70
71#[derive(Clone, Debug, PartialEq)]
73pub struct CanonicalInputValue {
74 pub name: String,
75 pub aliases: Vec<String>,
76 pub short: Option<char>,
77 pub type_: SchemaType,
78 pub value: SchemaValue,
79}
80
81#[derive(Clone, Debug, PartialEq)]
84pub enum CanonicalInputDecodeError {
85 ExpectedRecord,
86 FieldCountMismatch { expected: usize, actual: usize },
87 Model(ToolValidationError),
88}
89
90impl std::fmt::Display for CanonicalInputDecodeError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 CanonicalInputDecodeError::ExpectedRecord => {
94 write!(f, "canonical tool input must be a record value")
95 }
96 CanonicalInputDecodeError::FieldCountMismatch { expected, actual } => write!(
97 f,
98 "canonical tool input record has {actual} fields, expected {expected}"
99 ),
100 CanonicalInputDecodeError::Model(error) => {
101 write!(f, "invalid canonical input model: {error}")
102 }
103 }
104 }
105}
106
107impl std::error::Error for CanonicalInputDecodeError {}
108
109#[derive(Clone, Debug, PartialEq)]
112#[allow(clippy::large_enum_variant)]
113pub enum EffectiveCommandField {
114 Option(OptionSpec),
115 Flag(FlagSpec),
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum CanonicalSurfaceRef {
123 GlobalOption { node: usize, index: usize },
126 GlobalFlag { node: usize, index: usize },
129 BodyPositional { index: usize },
131 BodyTail,
133 BodyOption { index: usize },
135 BodyFlag { index: usize },
137}
138
139impl CanonicalInputModel {
140 pub fn decode_record(
143 &self,
144 value: SchemaValue,
145 ) -> Result<Vec<CanonicalInputValue>, CanonicalInputDecodeError> {
146 let SchemaValue::Record { fields: values } = value else {
147 return Err(CanonicalInputDecodeError::ExpectedRecord);
148 };
149 if values.len() != self.fields.len() {
150 return Err(CanonicalInputDecodeError::FieldCountMismatch {
151 expected: self.fields.len(),
152 actual: values.len(),
153 });
154 }
155 Ok(self
156 .fields
157 .iter()
158 .cloned()
159 .zip(values)
160 .map(|(field, value)| CanonicalInputValue {
161 name: field.name,
162 aliases: field.aliases,
163 short: field.short,
164 type_: field.type_,
165 value,
166 })
167 .collect())
168 }
169}
170
171impl Tool {
172 pub fn command_index_by_path(&self, command_path: &[String]) -> Option<usize> {
178 let mut current = 0usize;
179 if self.commands.nodes.is_empty() {
180 return None;
181 }
182 for segment in command_path {
183 let next = self.commands.nodes[current]
184 .subcommands
185 .iter()
186 .find_map(|idx| {
187 let idx = idx.as_usize()?;
188 let node = self.commands.nodes.get(idx)?;
189 (node.name == *segment || node.aliases.iter().any(|alias| alias == segment))
190 .then_some(idx)
191 })?;
192 current = next;
193 }
194 self.commands.nodes[current].body.as_ref().map(|_| current)
195 }
196
197 pub fn effective_globals(&self, command_index: usize) -> Vec<EffectiveCommandField> {
201 let path = self.path_to(command_index).unwrap_or_default();
202 let mut result = Vec::new();
203 for idx in path {
204 let Some(node) = self.commands.nodes.get(idx) else {
205 continue;
206 };
207 let globals = &node.globals;
208 result.extend(
209 globals
210 .options
211 .iter()
212 .cloned()
213 .map(EffectiveCommandField::Option),
214 );
215 result.extend(
216 globals
217 .flags
218 .iter()
219 .cloned()
220 .map(EffectiveCommandField::Flag),
221 );
222 }
223 result
224 }
225
226 pub fn canonical_input_surfaces(&self, command_index: usize) -> Vec<CanonicalSurfaceRef> {
236 let body = self
237 .commands
238 .nodes
239 .get(command_index)
240 .and_then(|c| c.body.as_ref());
241 let mut body_names: BTreeSet<&str> = BTreeSet::new();
248 if let Some(body) = body {
249 for p in &body.positionals.fixed {
250 body_names.insert(p.name.as_str());
251 }
252 if let Some(t) = &body.positionals.tail {
253 body_names.insert(t.name.as_str());
254 }
255 for o in &body.options {
256 body_names.insert(o.long.as_str());
257 body_names.extend(o.aliases.iter().map(String::as_str));
258 }
259 for f in &body.flags {
260 body_names.insert(f.long.as_str());
261 body_names.extend(f.aliases.iter().map(String::as_str));
262 }
263 }
264
265 let shadowed = |long: &str, aliases: &[String]| {
266 body_names.contains(long)
267 || aliases
268 .iter()
269 .any(|alias| body_names.contains(alias.as_str()))
270 };
271
272 let mut surfaces = Vec::new();
273 for node_index in self.path_to(command_index).unwrap_or_default() {
274 let Some(node) = self.commands.nodes.get(node_index) else {
275 continue;
276 };
277 for (index, option) in node.globals.options.iter().enumerate() {
278 if shadowed(&option.long, &option.aliases) {
279 continue;
280 }
281 surfaces.push(CanonicalSurfaceRef::GlobalOption {
282 node: node_index,
283 index,
284 });
285 }
286 for (index, flag) in node.globals.flags.iter().enumerate() {
287 if shadowed(&flag.long, &flag.aliases) {
288 continue;
289 }
290 surfaces.push(CanonicalSurfaceRef::GlobalFlag {
291 node: node_index,
292 index,
293 });
294 }
295 }
296 if let Some(body) = body {
297 surfaces.extend(
298 (0..body.positionals.fixed.len())
299 .map(|index| CanonicalSurfaceRef::BodyPositional { index }),
300 );
301 if body.positionals.tail.is_some() {
302 surfaces.push(CanonicalSurfaceRef::BodyTail);
303 }
304 surfaces.extend(
305 (0..body.options.len()).map(|index| CanonicalSurfaceRef::BodyOption { index }),
306 );
307 surfaces
308 .extend((0..body.flags.len()).map(|index| CanonicalSurfaceRef::BodyFlag { index }));
309 }
310 surfaces
311 }
312
313 pub fn canonical_field_for_surface(
316 &self,
317 command_index: usize,
318 surface: CanonicalSurfaceRef,
319 ) -> Option<CanonicalInputField> {
320 let body = || {
321 self.commands
322 .nodes
323 .get(command_index)
324 .and_then(|c| c.body.as_ref())
325 };
326 match surface {
327 CanonicalSurfaceRef::GlobalOption { node, index } => {
328 let option = self.commands.nodes.get(node)?.globals.options.get(index)?;
329 Some(CanonicalInputField {
330 name: option.long.clone(),
331 aliases: option.aliases.clone(),
332 short: option.short,
333 type_: canonical_option_type(option),
334 })
335 }
336 CanonicalSurfaceRef::GlobalFlag { node, index } => {
337 let flag = self.commands.nodes.get(node)?.globals.flags.get(index)?;
338 Some(CanonicalInputField {
339 name: flag.long.clone(),
340 aliases: flag.aliases.clone(),
341 short: flag.short,
342 type_: flag_type(flag),
343 })
344 }
345 CanonicalSurfaceRef::BodyPositional { index } => {
346 let positional = body()?.positionals.fixed.get(index)?;
347 Some(CanonicalInputField {
348 name: positional.name.clone(),
349 aliases: Vec::new(),
350 short: None,
351 type_: canonical_positional_type(positional),
352 })
353 }
354 CanonicalSurfaceRef::BodyTail => {
355 let tail = body()?.positionals.tail.as_ref()?;
356 Some(CanonicalInputField {
357 name: tail.name.clone(),
358 aliases: Vec::new(),
359 short: None,
360 type_: tail_collected_type(tail),
361 })
362 }
363 CanonicalSurfaceRef::BodyOption { index } => {
364 let option = body()?.options.get(index)?;
365 Some(CanonicalInputField {
366 name: option.long.clone(),
367 aliases: option.aliases.clone(),
368 short: option.short,
369 type_: canonical_option_type(option),
370 })
371 }
372 CanonicalSurfaceRef::BodyFlag { index } => {
373 let flag = body()?.flags.get(index)?;
374 Some(CanonicalInputField {
375 name: flag.long.clone(),
376 aliases: flag.aliases.clone(),
377 short: flag.short,
378 type_: flag_type(flag),
379 })
380 }
381 }
382 }
383
384 pub fn canonical_input_fields(&self, command_index: usize) -> Vec<CanonicalInputField> {
387 self.canonical_input_surfaces(command_index)
388 .into_iter()
389 .map(|surface| {
390 self.canonical_field_for_surface(command_index, surface)
391 .expect("canonical_input_surfaces returned an unresolved surface")
392 })
393 .collect()
394 }
395
396 pub fn canonical_input_model(
399 &self,
400 command_index: usize,
401 ) -> Result<CanonicalInputModel, ToolValidationError> {
402 self.check_canonical_input_command_index(command_index)?;
403 let fields = self.canonical_input_fields(command_index);
404 let record_schema = self.canonical_input_record_schema_for_fields(&fields)?;
405 Ok(CanonicalInputModel {
406 fields,
407 record_schema,
408 })
409 }
410
411 pub fn canonical_input_record_schema(
415 &self,
416 command_index: usize,
417 ) -> Result<SchemaGraph, ToolValidationError> {
418 self.check_canonical_input_command_index(command_index)?;
419 let fields = self.canonical_input_fields(command_index);
420 self.canonical_input_record_schema_for_fields(&fields)
421 }
422
423 pub fn decode_canonical_input_record(
426 &self,
427 command_index: usize,
428 value: SchemaValue,
429 ) -> Result<Vec<CanonicalInputValue>, CanonicalInputDecodeError> {
430 let model = self
431 .canonical_input_model(command_index)
432 .map_err(CanonicalInputDecodeError::Model)?;
433 model.decode_record(value)
434 }
435
436 fn canonical_input_record_schema_for_fields(
437 &self,
438 fields: &[CanonicalInputField],
439 ) -> Result<SchemaGraph, ToolValidationError> {
440 let command = self
441 .commands
442 .nodes
443 .first()
444 .map(|node| node.name.clone())
445 .unwrap_or_default();
446 let root = SchemaType::record(
447 fields
448 .iter()
449 .map(|field| NamedFieldType {
450 name: field.name.clone(),
451 body: field.type_.clone(),
452 metadata: MetadataEnvelope::default(),
453 })
454 .collect(),
455 );
456 let defs = reachable_defs(&self.schema, &root);
457 for def in &defs {
462 if self
463 .schema
464 .defs
465 .iter()
466 .filter(|candidate| candidate.id == def.id)
467 .count()
468 > 1
469 {
470 return Err(ToolValidationError::DuplicateTypeId {
471 id: def.id.to_string(),
472 });
473 }
474 }
475 let graph = SchemaGraph { defs, root };
476 check_graph_closed(&graph, command, "canonical input record")?;
477 Ok(graph)
478 }
479
480 fn check_canonical_input_command_index(
481 &self,
482 command_index: usize,
483 ) -> Result<(), ToolValidationError> {
484 check_command_tree_structure(self)?;
485 if command_index >= self.commands.nodes.len() {
486 return Err(ToolValidationError::CommandIndexOutOfBounds {
487 index: command_index as i32,
488 len: self.commands.nodes.len(),
489 });
490 }
491 if self.path_to(command_index).is_none() {
492 return Err(ToolValidationError::UnreachableCommandNode {
493 index: command_index as i32,
494 });
495 }
496 Ok(())
497 }
498
499 fn path_to(&self, command_index: usize) -> Option<Vec<usize>> {
500 fn visit(
501 nodes: &[CommandNode],
502 cur: usize,
503 target: usize,
504 path: &mut Vec<usize>,
505 on_stack: &mut BTreeSet<usize>,
506 ) -> bool {
507 if !on_stack.insert(cur) {
510 return false;
511 }
512 path.push(cur);
513 if cur == target {
514 return true;
515 }
516 for child in &nodes[cur].subcommands {
517 if let Some(child) = child.as_usize()
518 && child < nodes.len()
519 && visit(nodes, child, target, path, on_stack)
520 {
521 return true;
522 }
523 }
524 path.pop();
525 on_stack.remove(&cur);
526 false
527 }
528 let mut path = Vec::new();
529 let mut on_stack = BTreeSet::new();
530 if !self.commands.nodes.is_empty()
531 && visit(
532 &self.commands.nodes,
533 0,
534 command_index,
535 &mut path,
536 &mut on_stack,
537 )
538 {
539 Some(path)
540 } else {
541 None
542 }
543 }
544}
545
546pub fn record_schema_from_field_graphs<'a>(
556 fields: impl IntoIterator<Item = (&'a str, &'a SchemaGraph)> + Clone,
557) -> Result<SchemaGraph, ToolValidationError> {
558 for (name, schema) in fields.clone() {
559 check_graph_closed(
560 schema,
561 String::new(),
562 &format!("canonical input field {name:?}"),
563 )?;
564 }
565 let merged = crate::schema::conversion::merge_agent_graphs(
566 fields.clone().into_iter().map(|(_, schema)| schema.clone()),
567 )
568 .map_err(|error| ToolValidationError::IllFormedSchema {
569 command: String::new(),
570 position: "canonical input record".to_string(),
571 detail: error.to_string(),
572 })?;
573 let graph = SchemaGraph {
574 defs: merged.defs,
575 root: SchemaType::record(
576 fields
577 .into_iter()
578 .map(|(name, schema)| NamedFieldType {
579 name: name.to_string(),
580 body: schema.root.clone(),
581 metadata: MetadataEnvelope::default(),
582 })
583 .collect(),
584 ),
585 };
586 check_graph_closed(&graph, String::new(), "canonical input record")?;
587 Ok(graph)
588}
589
590pub fn option_collected_type(shape: &OptionShape) -> SchemaType {
594 match shape {
595 OptionShape::Scalar(t) | OptionShape::OptionalScalar(t) => t.clone(),
596 OptionShape::RepeatableList(r) => SchemaType::list(r.item_type.clone()),
597 OptionShape::RepeatableMap(r) => r.map_type.clone(),
598 }
599}
600
601pub(super) fn canonical_option_type(option: &OptionSpec) -> SchemaType {
606 let collected = option_collected_type(&option.shape);
607 if !option.required
608 && option.default.is_none()
609 && !matches!(
610 option.shape,
611 OptionShape::RepeatableList(_) | OptionShape::RepeatableMap(_)
612 )
613 && !matches!(&collected, SchemaType::Option { .. })
614 {
615 SchemaType::option(collected)
616 } else {
617 collected
618 }
619}
620
621pub(super) fn canonical_positional_type(positional: &Positional) -> SchemaType {
625 if !positional.required
626 && positional.default.is_none()
627 && !matches!(&positional.type_, SchemaType::Option { .. })
628 {
629 SchemaType::option(positional.type_.clone())
630 } else {
631 positional.type_.clone()
632 }
633}
634
635pub fn tail_collected_type(tail: &TailPositional) -> SchemaType {
637 SchemaType::list(tail.item_type.clone())
638}
639
640pub fn flag_type(flag: &FlagSpec) -> SchemaType {
643 match flag.shape {
644 FlagShape::BoolFlag(_) => SchemaType::bool(),
645 FlagShape::CountFlag(_) => SchemaType::u32(),
646 }
647}
648
649pub fn check_command_tree_structure(tool: &Tool) -> Result<(), ToolValidationError> {
652 let len = tool.commands.nodes.len();
653 if len == 0 {
654 return Err(ToolValidationError::EmptyCommandTree);
655 }
656 for node in &tool.commands.nodes {
657 for sub in &node.subcommands {
658 if sub.0 < 0 || (sub.0 as usize) >= len {
659 return Err(ToolValidationError::CommandIndexOutOfBounds { index: sub.0, len });
660 }
661 }
662 }
663 let mut visited = vec![false; len];
664 let mut on_stack = vec![false; len];
665 dfs_command_tree(tool, 0, &mut visited, &mut on_stack)?;
666 for (i, seen) in visited.iter().enumerate() {
667 if !seen {
668 return Err(ToolValidationError::UnreachableCommandNode { index: i as i32 });
669 }
670 }
671 Ok(())
672}
673
674fn dfs_command_tree(
675 tool: &Tool,
676 idx: usize,
677 visited: &mut [bool],
678 on_stack: &mut [bool],
679) -> Result<(), ToolValidationError> {
680 if on_stack[idx] {
681 return Err(ToolValidationError::CommandTreeCycle { index: idx as i32 });
682 }
683 if visited[idx] {
684 return Err(ToolValidationError::DuplicateCommandParent { index: idx as i32 });
685 }
686 visited[idx] = true;
687 on_stack[idx] = true;
688 for sub in &tool.commands.nodes[idx].subcommands {
689 dfs_command_tree(tool, sub.0 as usize, visited, on_stack)?;
691 }
692 on_stack[idx] = false;
693 Ok(())
694}
695
696fn check_graph_closed(
699 graph: &SchemaGraph,
700 command: String,
701 position: &str,
702) -> Result<(), ToolValidationError> {
703 let errors = match validate_graph(graph) {
704 Ok(()) => return Ok(()),
705 Err(errors) => errors,
706 };
707 let first = errors
708 .iter()
709 .find(|e| matches!(e, SchemaError::DanglingRef(_)))
710 .or_else(|| errors.first());
711 match first {
712 Some(SchemaError::DanglingRef(id)) => Err(ToolValidationError::UnresolvedTypeRef {
713 command,
714 position: position.to_string(),
715 id: id.to_string(),
716 }),
717 Some(error) => Err(ToolValidationError::IllFormedSchema {
718 command,
719 position: position.to_string(),
720 detail: error.to_string(),
721 }),
722 None => Ok(()),
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use crate::schema::graph::SchemaTypeDef;
730 use crate::schema::metadata::TypeId;
731 use crate::schema::tool::{
732 BoolFlagShape, CommandBody, CommandIndex, CommandTree, Doc, Globals, Positional,
733 Positionals, RepeatableListShape, RepeatableMapShape, Repetition, TailPositional,
734 };
735 use crate::schema::validation::value::validate_value;
736 use test_r::test;
737
738 fn body() -> CommandBody {
739 CommandBody {
740 positionals: Positionals::default(),
741 options: Vec::new(),
742 flags: Vec::new(),
743 constraints: Vec::new(),
744 stdin: None,
745 stdout: None,
746 stderr: None,
747 result: None,
748 errors: Vec::new(),
749 annotations: None,
750 }
751 }
752
753 fn node(name: &str) -> CommandNode {
754 CommandNode {
755 name: name.to_string(),
756 aliases: Vec::new(),
757 doc: Doc::default(),
758 globals: Globals::default(),
759 subcommands: Vec::new(),
760 body: None,
761 }
762 }
763
764 fn positional(name: &str, type_: SchemaType) -> Positional {
765 Positional {
766 name: name.to_string(),
767 doc: Doc::default(),
768 value_name: None,
769 type_,
770 default: None,
771 required: true,
772 accepts_stdio: false,
773 }
774 }
775
776 fn option(long: &str, shape: OptionShape) -> OptionSpec {
777 OptionSpec {
778 long: long.to_string(),
779 short: None,
780 aliases: Vec::new(),
781 doc: Doc::default(),
782 value_name: None,
783 shape,
784 default: None,
785 required: false,
786 env_var: None,
787 }
788 }
789
790 fn bool_flag(long: &str) -> FlagSpec {
791 FlagSpec {
792 long: long.to_string(),
793 short: None,
794 aliases: Vec::new(),
795 doc: Doc::default(),
796 shape: FlagShape::BoolFlag(BoolFlagShape {
797 default: false,
798 negatable: false,
799 }),
800 env_var: None,
801 }
802 }
803
804 fn count_flag(long: &str) -> FlagSpec {
805 FlagSpec {
806 long: long.to_string(),
807 short: None,
808 aliases: Vec::new(),
809 doc: Doc::default(),
810 shape: FlagShape::CountFlag(None),
811 env_var: None,
812 }
813 }
814
815 fn color_def() -> SchemaTypeDef {
816 SchemaTypeDef {
817 id: TypeId::from("color-mode"),
818 name: None,
819 body: SchemaType::r#enum(vec![
820 "never".to_string(),
821 "always".to_string(),
822 "auto".to_string(),
823 ]),
824 }
825 }
826
827 fn color_ref() -> SchemaType {
828 SchemaType::ref_to(TypeId::from("color-mode"))
829 }
830
831 fn grep_tool() -> Tool {
837 let mut root = node("grep");
838 root.globals = Globals {
839 options: vec![option("color", OptionShape::Scalar(color_ref()))],
840 flags: vec![bool_flag("case-sensitive")],
841 };
842 root.body = Some(CommandBody {
843 positionals: Positionals {
844 fixed: vec![positional("pattern", SchemaType::string())],
845 tail: Some(TailPositional {
846 name: "files".to_string(),
847 doc: Doc::default(),
848 value_name: None,
849 item_type: SchemaType::string(),
850 min: 0,
851 max: None,
852 separator: None,
853 verbatim: false,
854 accepts_stdio: false,
855 }),
856 },
857 options: vec![
858 option(
859 "extra-patterns",
860 OptionShape::RepeatableList(RepeatableListShape {
861 repetition: Repetition::Repeated,
862 item_type: SchemaType::string(),
863 }),
864 ),
865 option("max-count", OptionShape::Scalar(SchemaType::u32())),
866 ],
867 flags: vec![count_flag("verbosity")],
868 ..body()
869 });
870 root.subcommands = vec![CommandIndex(1)];
871
872 let mut replace = node("replace");
873 replace.aliases = vec!["substitute".to_string()];
874 replace.body = Some(CommandBody {
875 positionals: Positionals {
876 fixed: vec![
877 positional("pattern", SchemaType::string()),
878 positional("replacement", SchemaType::string()),
879 ],
880 tail: None,
881 },
882 ..body()
883 });
884
885 Tool {
886 version: "1.0.0".to_string(),
887 requires_filesystem: false,
888 commands: CommandTree {
889 nodes: vec![root, replace],
890 },
891 schema: SchemaGraph {
892 defs: vec![color_def()],
893 root: SchemaType::record(Vec::new()),
894 },
895 }
896 }
897
898 #[test]
899 fn canonical_input_field_order_at_root() {
900 let tool = grep_tool();
903 let fields = tool.canonical_input_fields(0);
904 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
905 assert_eq!(
906 names,
907 vec![
908 "color",
909 "case-sensitive",
910 "pattern",
911 "files",
912 "extra-patterns",
913 "max-count",
914 "verbosity",
915 ]
916 );
917 }
918
919 #[test]
920 fn canonical_input_fields_preserve_short_options() {
921 let mut tool = grep_tool();
922 tool.commands.nodes[0].globals.options[0].short = Some('c');
923 tool.commands.nodes[0].globals.flags[0].short = Some('i');
924 tool.commands.nodes[0].body.as_mut().unwrap().options[1].short = Some('n');
925 tool.commands.nodes[0].body.as_mut().unwrap().flags[0].short = Some('v');
926
927 let fields = tool.canonical_input_fields(0);
928 let shorts: Vec<Option<char>> = fields.iter().map(|field| field.short).collect();
929
930 assert_eq!(
931 shorts,
932 vec![Some('c'), Some('i'), None, None, None, Some('n'), Some('v'),]
933 );
934 }
935
936 #[test]
937 fn globals_are_effective_on_subcommand() {
938 let tool = grep_tool();
939 let effective = tool.effective_globals(1);
940 assert!(
941 effective
942 .iter()
943 .any(|g| matches!(g, EffectiveCommandField::Option(o) if o.long == "color"))
944 );
945 assert!(
946 effective
947 .iter()
948 .any(|g| matches!(g, EffectiveCommandField::Flag(f) if f.long == "case-sensitive"))
949 );
950
951 let fields = tool.canonical_input_fields(1);
952 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
953 assert_eq!(
954 names,
955 vec!["color", "case-sensitive", "pattern", "replacement"]
956 );
957 }
958
959 #[test]
960 fn body_local_name_shadows_inherited_global() {
961 let mut tool = grep_tool();
962 let mut clashing = option("colour", OptionShape::Scalar(SchemaType::string()));
965 clashing.aliases = vec!["color".to_string()];
966 tool.commands.nodes[1].body.as_mut().unwrap().options = vec![clashing];
967
968 let fields = tool.canonical_input_fields(1);
969 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
970 assert_eq!(
971 names,
972 vec!["case-sensitive", "pattern", "replacement", "colour",],
973 "the inherited `color` global is shadowed by the body-local alias"
974 );
975 }
976
977 #[test]
978 fn collected_field_types() {
979 let tool = grep_tool();
980 let fields = tool.canonical_input_fields(0);
981 let by_name = |name: &str| {
982 fields
983 .iter()
984 .find(|f| f.name == name)
985 .unwrap_or_else(|| panic!("field {name}"))
986 };
987 assert_eq!(by_name("color").type_, SchemaType::option(color_ref()));
988 assert_eq!(by_name("case-sensitive").type_, SchemaType::bool());
989 assert_eq!(by_name("pattern").type_, SchemaType::string());
990 assert_eq!(
991 by_name("files").type_,
992 SchemaType::list(SchemaType::string())
993 );
994 assert_eq!(
995 by_name("extra-patterns").type_,
996 SchemaType::list(SchemaType::string())
997 );
998 assert_eq!(
999 by_name("max-count").type_,
1000 SchemaType::option(SchemaType::u32())
1001 );
1002 assert_eq!(by_name("verbosity").type_, SchemaType::u32());
1003 }
1004
1005 #[test]
1006 fn optional_surfaces_do_not_nest_an_author_supplied_option_carrier() {
1007 let mut root = node("optional");
1008 let mut positional = positional("label", SchemaType::option(SchemaType::string()));
1009 positional.required = false;
1010 root.body = Some(CommandBody {
1011 positionals: Positionals {
1012 fixed: vec![positional],
1013 tail: None,
1014 },
1015 options: vec![option(
1016 "mode",
1017 OptionShape::Scalar(SchemaType::option(SchemaType::string())),
1018 )],
1019 ..body()
1020 });
1021 let tool = Tool {
1022 version: "1.0.0".to_string(),
1023 requires_filesystem: false,
1024 commands: CommandTree { nodes: vec![root] },
1025 schema: SchemaGraph::empty(),
1026 };
1027
1028 let fields = tool.canonical_input_fields(0);
1029 assert_eq!(fields[0].type_, SchemaType::option(SchemaType::string()));
1030 assert_eq!(fields[1].type_, SchemaType::option(SchemaType::string()));
1031 tool.canonical_input_model(0)
1032 .expect("canonical input remains well formed");
1033 }
1034
1035 #[test]
1036 fn optional_fields_accept_omitted_and_supplied_values_in_the_wire_record() {
1037 let mut root = node("optional");
1038 let mut positional = positional("label", SchemaType::string());
1039 positional.required = false;
1040 root.body = Some(CommandBody {
1041 positionals: Positionals {
1042 fixed: vec![positional],
1043 tail: None,
1044 },
1045 options: vec![option("mode", OptionShape::Scalar(SchemaType::string()))],
1046 ..body()
1047 });
1048 let tool = Tool {
1049 version: "1.0.0".to_string(),
1050 requires_filesystem: false,
1051 commands: CommandTree { nodes: vec![root] },
1052 schema: SchemaGraph {
1053 defs: Vec::new(),
1054 root: SchemaType::record(Vec::new()),
1055 },
1056 };
1057 let graph = tool.canonical_input_record_schema(0).unwrap();
1058 for values in [
1059 vec![
1060 SchemaValue::Option { inner: None },
1061 SchemaValue::Option { inner: None },
1062 ],
1063 vec![
1064 SchemaValue::Option {
1065 inner: Some(Box::new(SchemaValue::String("label".to_string()))),
1066 },
1067 SchemaValue::Option {
1068 inner: Some(Box::new(SchemaValue::String("mode".to_string()))),
1069 },
1070 ],
1071 ] {
1072 assert!(
1073 validate_value(&graph, &graph.root, &SchemaValue::Record { fields: values })
1074 .is_ok()
1075 );
1076 }
1077 }
1078
1079 #[test]
1080 fn repeatable_map_collects_into_map_node() {
1081 let map_type = SchemaType::map(SchemaType::string(), SchemaType::string());
1082 let shape = OptionShape::RepeatableMap(RepeatableMapShape {
1083 repetition: Repetition::Repeated,
1084 map_type: map_type.clone(),
1085 duplicate_key_policy: crate::schema::tool::DuplicateKeyPolicy::Reject,
1086 });
1087 assert_eq!(option_collected_type(&shape), map_type);
1088 }
1089
1090 #[test]
1091 fn command_index_by_path_resolves_names_and_aliases() {
1092 let tool = grep_tool();
1093 assert_eq!(tool.command_index_by_path(&[]), Some(0));
1094 assert_eq!(
1095 tool.command_index_by_path(&["replace".to_string()]),
1096 Some(1)
1097 );
1098 assert_eq!(
1099 tool.command_index_by_path(&["substitute".to_string()]),
1100 Some(1)
1101 );
1102 assert_eq!(tool.command_index_by_path(&["missing".to_string()]), None);
1103 }
1104
1105 #[test]
1106 fn command_index_by_path_requires_a_body() {
1107 let mut tool = grep_tool();
1108 tool.commands.nodes[1].body = None;
1109 assert_eq!(tool.command_index_by_path(&["replace".to_string()]), None);
1110
1111 let mut tool = grep_tool();
1112 tool.commands.nodes[0].body = None;
1113 assert_eq!(tool.command_index_by_path(&[]), None);
1114 }
1115
1116 #[test]
1117 fn inherited_global_alias_is_shadowed_by_body_local_long_name() {
1118 let mut tool = grep_tool();
1119 tool.commands.nodes[0].globals.options[0].aliases = vec!["hue".to_string()];
1122 tool.commands.nodes[1].body.as_mut().unwrap().options =
1123 vec![option("hue", OptionShape::Scalar(SchemaType::string()))];
1124
1125 let fields = tool.canonical_input_fields(1);
1126 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1127 assert_eq!(
1128 names,
1129 vec!["case-sensitive", "pattern", "replacement", "hue"],
1130 "the inherited global is shadowed when its alias matches a body-local long name"
1131 );
1132 }
1133
1134 #[test]
1135 fn record_schema_rejects_duplicate_reachable_type_ids() {
1136 let mut tool = grep_tool();
1137 tool.schema.defs.push(color_def());
1138 let error = tool.canonical_input_model(0).expect_err("duplicate id");
1139 assert!(matches!(error, ToolValidationError::DuplicateTypeId { .. }));
1140 }
1141
1142 #[test]
1143 fn record_schema_projects_reachable_defs() {
1144 let mut tool = grep_tool();
1145 tool.schema.defs.push(SchemaTypeDef {
1147 id: TypeId::from("unused"),
1148 name: None,
1149 body: SchemaType::string(),
1150 });
1151
1152 let model = tool.canonical_input_model(0).expect("valid model");
1153 assert_eq!(model.record_schema.defs.len(), 1);
1154 assert_eq!(model.record_schema.defs[0].id, TypeId::from("color-mode"));
1155
1156 let SchemaType::Record { fields, .. } = &model.record_schema.root else {
1157 panic!("record schema root must be a record");
1158 };
1159 assert_eq!(
1160 fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(),
1161 vec![
1162 "color",
1163 "case-sensitive",
1164 "pattern",
1165 "files",
1166 "extra-patterns",
1167 "max-count",
1168 "verbosity",
1169 ]
1170 );
1171 }
1172
1173 #[test]
1174 fn record_schema_rejects_dangling_refs() {
1175 let mut tool = grep_tool();
1176 tool.schema.defs.clear();
1177 let error = tool.canonical_input_model(0).expect_err("dangling ref");
1178 assert!(matches!(
1179 error,
1180 ToolValidationError::UnresolvedTypeRef { .. }
1181 ));
1182 }
1183
1184 #[test]
1185 fn canonical_input_model_rejects_malformed_trees() {
1186 let mut tool = grep_tool();
1187 tool.commands.nodes[1].subcommands = vec![CommandIndex(0)];
1188 assert!(matches!(
1189 tool.canonical_input_model(0),
1190 Err(ToolValidationError::CommandTreeCycle { .. })
1191 ));
1192
1193 let mut tool = grep_tool();
1194 tool.commands.nodes[0].subcommands.clear();
1195 assert!(matches!(
1196 tool.canonical_input_model(1),
1197 Err(ToolValidationError::UnreachableCommandNode { .. })
1198 ));
1199
1200 let mut tool = grep_tool();
1201 tool.commands.nodes[0].subcommands = vec![CommandIndex(7)];
1202 assert!(matches!(
1203 tool.canonical_input_model(0),
1204 Err(ToolValidationError::CommandIndexOutOfBounds { .. })
1205 ));
1206
1207 let mut tool = grep_tool();
1208 tool.commands.nodes.clear();
1209 assert!(matches!(
1210 tool.canonical_input_model(0),
1211 Err(ToolValidationError::EmptyCommandTree)
1212 ));
1213 }
1214
1215 #[test]
1216 fn decode_record_splits_fields_in_model_order() {
1217 let tool = grep_tool();
1218 let model = tool.canonical_input_model(1).expect("valid model");
1219 let values = model
1220 .decode_record(SchemaValue::Record {
1221 fields: vec![
1222 SchemaValue::Enum { case: 2 },
1223 SchemaValue::Bool(true),
1224 SchemaValue::String("a".to_string()),
1225 SchemaValue::String("b".to_string()),
1226 ],
1227 })
1228 .expect("decodes");
1229 assert_eq!(
1230 values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
1231 vec!["color", "case-sensitive", "pattern", "replacement"]
1232 );
1233 assert_eq!(values[2].value, SchemaValue::String("a".to_string()));
1234 }
1235
1236 #[test]
1237 fn decode_record_rejects_wrong_shape() {
1238 let tool = grep_tool();
1239 let model = tool.canonical_input_model(1).expect("valid model");
1240 assert_eq!(
1241 model.decode_record(SchemaValue::Bool(true)),
1242 Err(CanonicalInputDecodeError::ExpectedRecord)
1243 );
1244 assert_eq!(
1245 model.decode_record(SchemaValue::Record { fields: vec![] }),
1246 Err(CanonicalInputDecodeError::FieldCountMismatch {
1247 expected: 4,
1248 actual: 0
1249 })
1250 );
1251 }
1252}