Skip to main content

golem_schema/schema/tool/
canonical.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Canonical input model for tool commands.
16//!
17//! `golem:tool/guest.invoke` takes a single record `typed-schema-value` whose
18//! fields are the flattened input surface of the selected command body. The
19//! *canonical input model* defines that record's deterministic field order,
20//! shared by every tool client (which encodes the record) and every tool
21//! implementation (which decodes it):
22//!
23//! 1. effective inherited globals along the command path, in path order
24//!    (options before flags within each node),
25//! 2. the body's fixed positionals, in declaration order,
26//! 3. the body's tail positional (collected as `list<item>`), if any,
27//! 4. the body's options, in declaration order,
28//! 5. the body's flags, in declaration order.
29//!
30//! An inherited global is shadowed (skipped) when any of its surface names
31//! (long name or alias) collides with any body-local surface name. A
32//! well-formed tool never has such a collision, but this module may be called
33//! on a not-yet-validated tool, and surfacing the body-local field is the
34//! least misleading fallback.
35//!
36//! Field types are expressed with the collected value type of each surface: a
37//! `repeatable-list` option and a tail positional collect into `list<item>`, a
38//! `repeatable-map` option into its map node, a bool flag into `bool`, and a
39//! count flag into `u32`.
40
41use 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/// One field of a command's canonical input record. `type_` may reference
53/// definitions in the owning [`Tool`]'s shared [`SchemaGraph`].
54#[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/// The full canonical input record shape of one command: the ordered fields
63/// and the self-contained record schema (definitions projected from the tool's
64/// shared graph to those reachable from the record).
65#[derive(Clone, Debug, PartialEq)]
66pub struct CanonicalInputModel {
67    pub fields: Vec<CanonicalInputField>,
68    pub record_schema: SchemaGraph,
69}
70
71/// One decoded field of a canonical input record.
72#[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/// Failure while decoding a canonical input record against a
82/// [`CanonicalInputModel`].
83#[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/// An inherited option or flag in scope for a command, collected along the
110/// path from the root.
111#[derive(Clone, Debug, PartialEq)]
112#[allow(clippy::large_enum_variant)]
113pub enum EffectiveCommandField {
114    Option(OptionSpec),
115    Flag(FlagSpec),
116}
117
118/// One canonical input surface of a command, identified by its position in
119/// the tool definition rather than by name (see
120/// [`Tool::canonical_input_surfaces`]).
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum CanonicalSurfaceRef {
123    /// An inherited global option: `node` indexes the command tree, `index`
124    /// the node's `globals.options`.
125    GlobalOption { node: usize, index: usize },
126    /// An inherited global flag: `node` indexes the command tree, `index` the
127    /// node's `globals.flags`.
128    GlobalFlag { node: usize, index: usize },
129    /// A fixed positional of the command's body.
130    BodyPositional { index: usize },
131    /// The tail positional of the command's body.
132    BodyTail,
133    /// An option of the command's body.
134    BodyOption { index: usize },
135    /// A flag of the command's body.
136    BodyFlag { index: usize },
137}
138
139impl CanonicalInputModel {
140    /// Splits a record value into per-field [`CanonicalInputValue`]s following
141    /// this model's field order.
142    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    /// Resolves a `command-path` (as passed to `golem:tool` `invoke`) to the
173    /// index of the command node whose body it selects. An empty path selects
174    /// the root command. Each segment matches a subcommand's name or alias.
175    /// Returns `None` when a segment does not match or the selected command
176    /// has no body.
177    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    /// The inherited globals in scope for `command_index`, collected along the
198    /// path from the root in path order (options before flags within each
199    /// node).
200    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    /// The ordered canonical input surfaces of the command at `command_index`,
227    /// identified by their position in the tool definition (see the module
228    /// documentation for the ordering and shadowing rules).
229    ///
230    /// This is the single source of the canonical field *ordering*: callers
231    /// with richer per-surface data (such as the Rust SDK's extended
232    /// descriptor model) map each returned reference back onto their own
233    /// structures by position, which stays unambiguous even for invalid tools
234    /// with duplicate surface names.
235    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        // Body surface names include option/flag aliases, so an inherited
242        // global is shadowed when *any* of its surface names (long or alias)
243        // collides with any body-local surface name. A well-formed tool never
244        // has such a collision, but this method may be called on a
245        // not-yet-validated tool, and surfacing the body-local field is the
246        // least misleading fallback.
247        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    /// The canonical input field for one surface reference of the command at
314    /// `command_index`, or `None` if the reference does not resolve.
315    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    /// The ordered canonical input fields of the command at `command_index`
385    /// (see the module documentation for the ordering and shadowing rules).
386    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    /// The canonical input model of the command at `command_index`: the
397    /// ordered fields plus the self-contained record schema.
398    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    /// The self-contained record schema of the command's canonical input: a
412    /// record with one field per canonical input field, with the tool's shared
413    /// definitions projected to those reachable from the record.
414    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    /// Decodes a canonical input record value against the command's canonical
424    /// input model.
425    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        // Projection is first-def-wins, so a duplicate definition id in the
458        // shared graph would silently disappear here even though validation
459        // rejects it. Keep the defensive behavior of this module aligned with
460        // `validate_tool` for the reachable definitions.
461        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            // Guards against malformed (cyclic) command trees so this helper
508            // is safe to call before validation proves the tree acyclic.
509            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
546/// Builds the self-contained canonical input record schema from per-field
547/// self-contained graphs: each field graph must be closed, the definitions are
548/// unioned (rejecting id collisions with conflicting bodies), and the record's
549/// field types are the graph roots in field order.
550///
551/// This is the record construction used when the fields do not all come from
552/// one [`Tool`]'s shared graph — for example when a client composes inherited
553/// values captured from another tool's metadata with the current command's
554/// fields.
555pub 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
590/// The whole collected value type of an option: a `repeatable-list` collects
591/// into `list<item>`, a `repeatable-map` into its map node; scalar and
592/// optional-scalar options use the value type directly.
593pub 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
601/// The input-record field type of an option. A scalar or optional-scalar
602/// option that may be left out and has no default is carried as `option<T>`,
603/// unless its declared type is already an option; every other option keeps
604/// its collected type.
605pub(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
621/// The input-record field type of a fixed positional. One that may be left
622/// out and has no default is carried as `option<T>`, unless its declared type
623/// is already an option; every other positional keeps its declared type.
624pub(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
635/// The collected value type of a tail positional: `list<item>`.
636pub fn tail_collected_type(tail: &TailPositional) -> SchemaType {
637    SchemaType::list(tail.item_type.clone())
638}
639
640/// The derived input-record field type for a flag: `bool` for a bool-flag,
641/// `u32` for a count-flag. Flags carry no author-supplied value type.
642pub fn flag_type(flag: &FlagSpec) -> SchemaType {
643    match flag.shape {
644        FlagShape::BoolFlag(_) => SchemaType::bool(),
645        FlagShape::CountFlag(_) => SchemaType::u32(),
646    }
647}
648
649/// Checks that the command tree is non-empty, in bounds, acyclic, and a tree
650/// (every node reachable from the root through exactly one parent).
651pub 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        // Bounds were validated in check_command_tree_structure.
690        dfs_command_tree(tool, sub.0 as usize, visited, on_stack)?;
691    }
692    on_stack[idx] = false;
693    Ok(())
694}
695
696/// Rejects graphs with dangling refs or ill-formed types, reporting the first
697/// error deterministically and preferring the precise dangling-ref variant.
698fn 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    /// A grep-like tool: root command `grep` with a `color` global option (a
832    /// ref into the shared defs), a `case-sensitive` global flag, a `pattern`
833    /// fixed positional, a `files` tail, an `extra-patterns` repeatable-list
834    /// option, a `max-count` option, a `verbosity` count flag, and a `replace`
835    /// subcommand.
836    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        // Globals (options then flags in path order), then body fixed
901        // positionals, tail, options, flags.
902        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        // Give the subcommand a body-local option whose alias collides with
963        // the root's `color` global.
964        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        // Give the root's `color` global an alias that matches a body-local
1120        // option long name on the subcommand.
1121        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        // Add an unused def: it must not appear in the projected record schema.
1146        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}