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
601fn canonical_option_type(option: &OptionSpec) -> SchemaType {
602 let collected = option_collected_type(&option.shape);
603 if !option.required
604 && option.default.is_none()
605 && !matches!(
606 option.shape,
607 OptionShape::RepeatableList(_) | OptionShape::RepeatableMap(_)
608 )
609 && !matches!(&collected, SchemaType::Option { .. })
610 {
611 SchemaType::option(collected)
612 } else {
613 collected
614 }
615}
616
617fn canonical_positional_type(positional: &Positional) -> SchemaType {
618 if !positional.required
619 && positional.default.is_none()
620 && !matches!(&positional.type_, SchemaType::Option { .. })
621 {
622 SchemaType::option(positional.type_.clone())
623 } else {
624 positional.type_.clone()
625 }
626}
627
628pub fn tail_collected_type(tail: &TailPositional) -> SchemaType {
630 SchemaType::list(tail.item_type.clone())
631}
632
633pub fn flag_type(flag: &FlagSpec) -> SchemaType {
636 match flag.shape {
637 FlagShape::BoolFlag(_) => SchemaType::bool(),
638 FlagShape::CountFlag(_) => SchemaType::u32(),
639 }
640}
641
642pub fn check_command_tree_structure(tool: &Tool) -> Result<(), ToolValidationError> {
645 let len = tool.commands.nodes.len();
646 if len == 0 {
647 return Err(ToolValidationError::EmptyCommandTree);
648 }
649 for node in &tool.commands.nodes {
650 for sub in &node.subcommands {
651 if sub.0 < 0 || (sub.0 as usize) >= len {
652 return Err(ToolValidationError::CommandIndexOutOfBounds { index: sub.0, len });
653 }
654 }
655 }
656 let mut visited = vec![false; len];
657 let mut on_stack = vec![false; len];
658 dfs_command_tree(tool, 0, &mut visited, &mut on_stack)?;
659 for (i, seen) in visited.iter().enumerate() {
660 if !seen {
661 return Err(ToolValidationError::UnreachableCommandNode { index: i as i32 });
662 }
663 }
664 Ok(())
665}
666
667fn dfs_command_tree(
668 tool: &Tool,
669 idx: usize,
670 visited: &mut [bool],
671 on_stack: &mut [bool],
672) -> Result<(), ToolValidationError> {
673 if on_stack[idx] {
674 return Err(ToolValidationError::CommandTreeCycle { index: idx as i32 });
675 }
676 if visited[idx] {
677 return Err(ToolValidationError::DuplicateCommandParent { index: idx as i32 });
678 }
679 visited[idx] = true;
680 on_stack[idx] = true;
681 for sub in &tool.commands.nodes[idx].subcommands {
682 dfs_command_tree(tool, sub.0 as usize, visited, on_stack)?;
684 }
685 on_stack[idx] = false;
686 Ok(())
687}
688
689fn check_graph_closed(
692 graph: &SchemaGraph,
693 command: String,
694 position: &str,
695) -> Result<(), ToolValidationError> {
696 let errors = match validate_graph(graph) {
697 Ok(()) => return Ok(()),
698 Err(errors) => errors,
699 };
700 let first = errors
701 .iter()
702 .find(|e| matches!(e, SchemaError::DanglingRef(_)))
703 .or_else(|| errors.first());
704 match first {
705 Some(SchemaError::DanglingRef(id)) => Err(ToolValidationError::UnresolvedTypeRef {
706 command,
707 position: position.to_string(),
708 id: id.to_string(),
709 }),
710 Some(error) => Err(ToolValidationError::IllFormedSchema {
711 command,
712 position: position.to_string(),
713 detail: error.to_string(),
714 }),
715 None => Ok(()),
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use crate::schema::graph::SchemaTypeDef;
723 use crate::schema::metadata::TypeId;
724 use crate::schema::tool::{
725 BoolFlagShape, CommandBody, CommandIndex, CommandTree, Doc, Globals, Positional,
726 Positionals, RepeatableListShape, RepeatableMapShape, Repetition, TailPositional,
727 };
728 use crate::schema::validation::value::validate_value;
729 use test_r::test;
730
731 fn body() -> CommandBody {
732 CommandBody {
733 positionals: Positionals::default(),
734 options: Vec::new(),
735 flags: Vec::new(),
736 constraints: Vec::new(),
737 stdin: None,
738 stdout: None,
739 stderr: None,
740 result: None,
741 errors: Vec::new(),
742 annotations: None,
743 }
744 }
745
746 fn node(name: &str) -> CommandNode {
747 CommandNode {
748 name: name.to_string(),
749 aliases: Vec::new(),
750 doc: Doc::default(),
751 globals: Globals::default(),
752 subcommands: Vec::new(),
753 body: None,
754 }
755 }
756
757 fn positional(name: &str, type_: SchemaType) -> Positional {
758 Positional {
759 name: name.to_string(),
760 doc: Doc::default(),
761 value_name: None,
762 type_,
763 default: None,
764 required: true,
765 accepts_stdio: false,
766 }
767 }
768
769 fn option(long: &str, shape: OptionShape) -> OptionSpec {
770 OptionSpec {
771 long: long.to_string(),
772 short: None,
773 aliases: Vec::new(),
774 doc: Doc::default(),
775 value_name: None,
776 shape,
777 default: None,
778 required: false,
779 env_var: None,
780 }
781 }
782
783 fn bool_flag(long: &str) -> FlagSpec {
784 FlagSpec {
785 long: long.to_string(),
786 short: None,
787 aliases: Vec::new(),
788 doc: Doc::default(),
789 shape: FlagShape::BoolFlag(BoolFlagShape {
790 default: false,
791 negatable: false,
792 }),
793 env_var: None,
794 }
795 }
796
797 fn count_flag(long: &str) -> FlagSpec {
798 FlagSpec {
799 long: long.to_string(),
800 short: None,
801 aliases: Vec::new(),
802 doc: Doc::default(),
803 shape: FlagShape::CountFlag(None),
804 env_var: None,
805 }
806 }
807
808 fn color_def() -> SchemaTypeDef {
809 SchemaTypeDef {
810 id: TypeId::from("color-mode"),
811 name: None,
812 body: SchemaType::r#enum(vec![
813 "never".to_string(),
814 "always".to_string(),
815 "auto".to_string(),
816 ]),
817 }
818 }
819
820 fn color_ref() -> SchemaType {
821 SchemaType::ref_to(TypeId::from("color-mode"))
822 }
823
824 fn grep_tool() -> Tool {
830 let mut root = node("grep");
831 root.globals = Globals {
832 options: vec![option("color", OptionShape::Scalar(color_ref()))],
833 flags: vec![bool_flag("case-sensitive")],
834 };
835 root.body = Some(CommandBody {
836 positionals: Positionals {
837 fixed: vec![positional("pattern", SchemaType::string())],
838 tail: Some(TailPositional {
839 name: "files".to_string(),
840 doc: Doc::default(),
841 value_name: None,
842 item_type: SchemaType::string(),
843 min: 0,
844 max: None,
845 separator: None,
846 verbatim: false,
847 accepts_stdio: false,
848 }),
849 },
850 options: vec![
851 option(
852 "extra-patterns",
853 OptionShape::RepeatableList(RepeatableListShape {
854 repetition: Repetition::Repeated,
855 item_type: SchemaType::string(),
856 }),
857 ),
858 option("max-count", OptionShape::Scalar(SchemaType::u32())),
859 ],
860 flags: vec![count_flag("verbosity")],
861 ..body()
862 });
863 root.subcommands = vec![CommandIndex(1)];
864
865 let mut replace = node("replace");
866 replace.aliases = vec!["substitute".to_string()];
867 replace.body = Some(CommandBody {
868 positionals: Positionals {
869 fixed: vec![
870 positional("pattern", SchemaType::string()),
871 positional("replacement", SchemaType::string()),
872 ],
873 tail: None,
874 },
875 ..body()
876 });
877
878 Tool {
879 version: "1.0.0".to_string(),
880 requires_filesystem: false,
881 commands: CommandTree {
882 nodes: vec![root, replace],
883 },
884 schema: SchemaGraph {
885 defs: vec![color_def()],
886 root: SchemaType::record(Vec::new()),
887 },
888 }
889 }
890
891 #[test]
892 fn canonical_input_field_order_at_root() {
893 let tool = grep_tool();
896 let fields = tool.canonical_input_fields(0);
897 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
898 assert_eq!(
899 names,
900 vec![
901 "color",
902 "case-sensitive",
903 "pattern",
904 "files",
905 "extra-patterns",
906 "max-count",
907 "verbosity",
908 ]
909 );
910 }
911
912 #[test]
913 fn canonical_input_fields_preserve_short_options() {
914 let mut tool = grep_tool();
915 tool.commands.nodes[0].globals.options[0].short = Some('c');
916 tool.commands.nodes[0].globals.flags[0].short = Some('i');
917 tool.commands.nodes[0].body.as_mut().unwrap().options[1].short = Some('n');
918 tool.commands.nodes[0].body.as_mut().unwrap().flags[0].short = Some('v');
919
920 let fields = tool.canonical_input_fields(0);
921 let shorts: Vec<Option<char>> = fields.iter().map(|field| field.short).collect();
922
923 assert_eq!(
924 shorts,
925 vec![Some('c'), Some('i'), None, None, None, Some('n'), Some('v'),]
926 );
927 }
928
929 #[test]
930 fn globals_are_effective_on_subcommand() {
931 let tool = grep_tool();
932 let effective = tool.effective_globals(1);
933 assert!(
934 effective
935 .iter()
936 .any(|g| matches!(g, EffectiveCommandField::Option(o) if o.long == "color"))
937 );
938 assert!(
939 effective
940 .iter()
941 .any(|g| matches!(g, EffectiveCommandField::Flag(f) if f.long == "case-sensitive"))
942 );
943
944 let fields = tool.canonical_input_fields(1);
945 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
946 assert_eq!(
947 names,
948 vec!["color", "case-sensitive", "pattern", "replacement"]
949 );
950 }
951
952 #[test]
953 fn body_local_name_shadows_inherited_global() {
954 let mut tool = grep_tool();
955 let mut clashing = option("colour", OptionShape::Scalar(SchemaType::string()));
958 clashing.aliases = vec!["color".to_string()];
959 tool.commands.nodes[1].body.as_mut().unwrap().options = vec![clashing];
960
961 let fields = tool.canonical_input_fields(1);
962 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
963 assert_eq!(
964 names,
965 vec!["case-sensitive", "pattern", "replacement", "colour",],
966 "the inherited `color` global is shadowed by the body-local alias"
967 );
968 }
969
970 #[test]
971 fn collected_field_types() {
972 let tool = grep_tool();
973 let fields = tool.canonical_input_fields(0);
974 let by_name = |name: &str| {
975 fields
976 .iter()
977 .find(|f| f.name == name)
978 .unwrap_or_else(|| panic!("field {name}"))
979 };
980 assert_eq!(by_name("color").type_, SchemaType::option(color_ref()));
981 assert_eq!(by_name("case-sensitive").type_, SchemaType::bool());
982 assert_eq!(by_name("pattern").type_, SchemaType::string());
983 assert_eq!(
984 by_name("files").type_,
985 SchemaType::list(SchemaType::string())
986 );
987 assert_eq!(
988 by_name("extra-patterns").type_,
989 SchemaType::list(SchemaType::string())
990 );
991 assert_eq!(
992 by_name("max-count").type_,
993 SchemaType::option(SchemaType::u32())
994 );
995 assert_eq!(by_name("verbosity").type_, SchemaType::u32());
996 }
997
998 #[test]
999 fn optional_surfaces_do_not_nest_an_author_supplied_option_carrier() {
1000 let mut root = node("optional");
1001 let mut positional = positional("label", SchemaType::option(SchemaType::string()));
1002 positional.required = false;
1003 root.body = Some(CommandBody {
1004 positionals: Positionals {
1005 fixed: vec![positional],
1006 tail: None,
1007 },
1008 options: vec![option(
1009 "mode",
1010 OptionShape::Scalar(SchemaType::option(SchemaType::string())),
1011 )],
1012 ..body()
1013 });
1014 let tool = Tool {
1015 version: "1.0.0".to_string(),
1016 requires_filesystem: false,
1017 commands: CommandTree { nodes: vec![root] },
1018 schema: SchemaGraph::empty(),
1019 };
1020
1021 let fields = tool.canonical_input_fields(0);
1022 assert_eq!(fields[0].type_, SchemaType::option(SchemaType::string()));
1023 assert_eq!(fields[1].type_, SchemaType::option(SchemaType::string()));
1024 tool.canonical_input_model(0)
1025 .expect("canonical input remains well formed");
1026 }
1027
1028 #[test]
1029 fn optional_fields_accept_omitted_and_supplied_values_in_the_wire_record() {
1030 let mut root = node("optional");
1031 let mut positional = positional("label", SchemaType::string());
1032 positional.required = false;
1033 root.body = Some(CommandBody {
1034 positionals: Positionals {
1035 fixed: vec![positional],
1036 tail: None,
1037 },
1038 options: vec![option("mode", OptionShape::Scalar(SchemaType::string()))],
1039 ..body()
1040 });
1041 let tool = Tool {
1042 version: "1.0.0".to_string(),
1043 requires_filesystem: false,
1044 commands: CommandTree { nodes: vec![root] },
1045 schema: SchemaGraph {
1046 defs: Vec::new(),
1047 root: SchemaType::record(Vec::new()),
1048 },
1049 };
1050 let graph = tool.canonical_input_record_schema(0).unwrap();
1051 for values in [
1052 vec![
1053 SchemaValue::Option { inner: None },
1054 SchemaValue::Option { inner: None },
1055 ],
1056 vec![
1057 SchemaValue::Option {
1058 inner: Some(Box::new(SchemaValue::String("label".to_string()))),
1059 },
1060 SchemaValue::Option {
1061 inner: Some(Box::new(SchemaValue::String("mode".to_string()))),
1062 },
1063 ],
1064 ] {
1065 assert!(
1066 validate_value(&graph, &graph.root, &SchemaValue::Record { fields: values })
1067 .is_ok()
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn repeatable_map_collects_into_map_node() {
1074 let map_type = SchemaType::map(SchemaType::string(), SchemaType::string());
1075 let shape = OptionShape::RepeatableMap(RepeatableMapShape {
1076 repetition: Repetition::Repeated,
1077 map_type: map_type.clone(),
1078 duplicate_key_policy: crate::schema::tool::DuplicateKeyPolicy::Reject,
1079 });
1080 assert_eq!(option_collected_type(&shape), map_type);
1081 }
1082
1083 #[test]
1084 fn command_index_by_path_resolves_names_and_aliases() {
1085 let tool = grep_tool();
1086 assert_eq!(tool.command_index_by_path(&[]), Some(0));
1087 assert_eq!(
1088 tool.command_index_by_path(&["replace".to_string()]),
1089 Some(1)
1090 );
1091 assert_eq!(
1092 tool.command_index_by_path(&["substitute".to_string()]),
1093 Some(1)
1094 );
1095 assert_eq!(tool.command_index_by_path(&["missing".to_string()]), None);
1096 }
1097
1098 #[test]
1099 fn command_index_by_path_requires_a_body() {
1100 let mut tool = grep_tool();
1101 tool.commands.nodes[1].body = None;
1102 assert_eq!(tool.command_index_by_path(&["replace".to_string()]), None);
1103
1104 let mut tool = grep_tool();
1105 tool.commands.nodes[0].body = None;
1106 assert_eq!(tool.command_index_by_path(&[]), None);
1107 }
1108
1109 #[test]
1110 fn inherited_global_alias_is_shadowed_by_body_local_long_name() {
1111 let mut tool = grep_tool();
1112 tool.commands.nodes[0].globals.options[0].aliases = vec!["hue".to_string()];
1115 tool.commands.nodes[1].body.as_mut().unwrap().options =
1116 vec![option("hue", OptionShape::Scalar(SchemaType::string()))];
1117
1118 let fields = tool.canonical_input_fields(1);
1119 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1120 assert_eq!(
1121 names,
1122 vec!["case-sensitive", "pattern", "replacement", "hue"],
1123 "the inherited global is shadowed when its alias matches a body-local long name"
1124 );
1125 }
1126
1127 #[test]
1128 fn record_schema_rejects_duplicate_reachable_type_ids() {
1129 let mut tool = grep_tool();
1130 tool.schema.defs.push(color_def());
1131 let error = tool.canonical_input_model(0).expect_err("duplicate id");
1132 assert!(matches!(error, ToolValidationError::DuplicateTypeId { .. }));
1133 }
1134
1135 #[test]
1136 fn record_schema_projects_reachable_defs() {
1137 let mut tool = grep_tool();
1138 tool.schema.defs.push(SchemaTypeDef {
1140 id: TypeId::from("unused"),
1141 name: None,
1142 body: SchemaType::string(),
1143 });
1144
1145 let model = tool.canonical_input_model(0).expect("valid model");
1146 assert_eq!(model.record_schema.defs.len(), 1);
1147 assert_eq!(model.record_schema.defs[0].id, TypeId::from("color-mode"));
1148
1149 let SchemaType::Record { fields, .. } = &model.record_schema.root else {
1150 panic!("record schema root must be a record");
1151 };
1152 assert_eq!(
1153 fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(),
1154 vec![
1155 "color",
1156 "case-sensitive",
1157 "pattern",
1158 "files",
1159 "extra-patterns",
1160 "max-count",
1161 "verbosity",
1162 ]
1163 );
1164 }
1165
1166 #[test]
1167 fn record_schema_rejects_dangling_refs() {
1168 let mut tool = grep_tool();
1169 tool.schema.defs.clear();
1170 let error = tool.canonical_input_model(0).expect_err("dangling ref");
1171 assert!(matches!(
1172 error,
1173 ToolValidationError::UnresolvedTypeRef { .. }
1174 ));
1175 }
1176
1177 #[test]
1178 fn canonical_input_model_rejects_malformed_trees() {
1179 let mut tool = grep_tool();
1180 tool.commands.nodes[1].subcommands = vec![CommandIndex(0)];
1181 assert!(matches!(
1182 tool.canonical_input_model(0),
1183 Err(ToolValidationError::CommandTreeCycle { .. })
1184 ));
1185
1186 let mut tool = grep_tool();
1187 tool.commands.nodes[0].subcommands.clear();
1188 assert!(matches!(
1189 tool.canonical_input_model(1),
1190 Err(ToolValidationError::UnreachableCommandNode { .. })
1191 ));
1192
1193 let mut tool = grep_tool();
1194 tool.commands.nodes[0].subcommands = vec![CommandIndex(7)];
1195 assert!(matches!(
1196 tool.canonical_input_model(0),
1197 Err(ToolValidationError::CommandIndexOutOfBounds { .. })
1198 ));
1199
1200 let mut tool = grep_tool();
1201 tool.commands.nodes.clear();
1202 assert!(matches!(
1203 tool.canonical_input_model(0),
1204 Err(ToolValidationError::EmptyCommandTree)
1205 ));
1206 }
1207
1208 #[test]
1209 fn decode_record_splits_fields_in_model_order() {
1210 let tool = grep_tool();
1211 let model = tool.canonical_input_model(1).expect("valid model");
1212 let values = model
1213 .decode_record(SchemaValue::Record {
1214 fields: vec![
1215 SchemaValue::Enum { case: 2 },
1216 SchemaValue::Bool(true),
1217 SchemaValue::String("a".to_string()),
1218 SchemaValue::String("b".to_string()),
1219 ],
1220 })
1221 .expect("decodes");
1222 assert_eq!(
1223 values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
1224 vec!["color", "case-sensitive", "pattern", "replacement"]
1225 );
1226 assert_eq!(values[2].value, SchemaValue::String("a".to_string()));
1227 }
1228
1229 #[test]
1230 fn decode_record_rejects_wrong_shape() {
1231 let tool = grep_tool();
1232 let model = tool.canonical_input_model(1).expect("valid model");
1233 assert_eq!(
1234 model.decode_record(SchemaValue::Bool(true)),
1235 Err(CanonicalInputDecodeError::ExpectedRecord)
1236 );
1237 assert_eq!(
1238 model.decode_record(SchemaValue::Record { fields: vec![] }),
1239 Err(CanonicalInputDecodeError::FieldCountMismatch {
1240 expected: 4,
1241 actual: 0
1242 })
1243 );
1244 }
1245}