Skip to main content

golem_schema/schema/tool/
argv.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//! Shared command-line parsing and help for schema-described tools.
16//!
17//! Arguments are decoded into the canonical input record and checked against tool constraints.
18//! Metadata never reads the process environment. A caller supplies explicit argument values,
19//! and can supply variables for the options and flags that declare one.
20
21use crate::schema::tool::canonical::{
22    CanonicalSurfaceRef, canonical_option_type, canonical_positional_type,
23};
24use crate::schema::tool::constraints::validate_tool_constraints;
25use crate::schema::tool::{
26    DuplicateKeyPolicy, FlagShape, FlagSpec, OptionShape, OptionSpec, Positional, Repetition,
27    TailPositional, Tool,
28};
29use crate::schema::{SchemaGraph, SchemaType, SchemaValue, TypedSchemaValue};
30use std::collections::HashMap;
31
32/// A request for command help or a validated tool invocation.
33pub enum ParsedToolArguments {
34    /// Help text for the selected command.
35    Help(String),
36    /// Canonical command identity and input, ready for a tool transport.
37    Invoke {
38        /// Canonical subcommand names, excluding the root tool name.
39        command_path: Vec<String>,
40        /// Input encoded according to the command's canonical input model.
41        input: Box<TypedSchemaValue>,
42    },
43}
44
45#[derive(Clone, Copy)]
46enum Surface<'a> {
47    Option(&'a OptionSpec),
48    Flag(&'a FlagSpec),
49    Positional(usize),
50    Tail,
51}
52
53enum Token<'a> {
54    Help,
55    TailSeparator {
56        verbatim: bool,
57    },
58    EndOptions,
59    LongOption {
60        option: &'a OptionSpec,
61        attached: Option<String>,
62    },
63    LongFlag {
64        flag: &'a FlagSpec,
65        negated: bool,
66    },
67    ShortOptions,
68    Subcommand(usize),
69    Positional,
70    Tail,
71    /// The first tail item of a command whose `--` separator is optional.
72    TailStart,
73}
74
75struct ParseCursor {
76    options_enabled: bool,
77    tail_started: bool,
78    body_started: bool,
79}
80
81impl ParseCursor {
82    fn classify<'a>(
83        &self,
84        tool: &'a Tool,
85        node_index: usize,
86        token: &str,
87        fixed_count: usize,
88    ) -> Result<Token<'a>, String> {
89        let node = &tool.commands.nodes[node_index];
90        // A declared separator can skip optional fixed slots, but not required ones.
91        // It remains required even after option parsing has ended.
92        if !self.tail_started
93            && let Some(spec) = node
94                .body
95                .as_ref()
96                .filter(|body| {
97                    body.positionals
98                        .fixed
99                        .iter()
100                        .skip(fixed_count)
101                        .all(|p| !p.required || p.default.is_some())
102                })
103                .and_then(|body| body.positionals.tail.as_ref())
104                .filter(|tail| tail.separator.as_deref() == Some(token))
105        {
106            return Ok(Token::TailSeparator {
107                verbatim: spec.verbatim,
108            });
109        }
110        let implicit_tail = !self.tail_started && implicit_tail(tool, node_index);
111        if self.options_enabled {
112            let surfaces = effective_named(tool, node_index);
113            if let Some(long) = token.strip_prefix("--") {
114                let (name, attached) = long
115                    .split_once('=')
116                    .map_or((long, None), |(name, value)| (name, Some(value)));
117                if let Some(option) = surfaces.iter().find_map(|(_, surface)| match surface {
118                    Surface::Option(option)
119                        if option.long == name
120                            || option.aliases.iter().any(|alias| alias == name) =>
121                    {
122                        Some(*option)
123                    }
124                    _ => None,
125                }) {
126                    return Ok(Token::LongOption {
127                        option,
128                        attached: attached.map(str::to_owned),
129                    });
130                }
131                if let Some((_, flag, negated)) = find_long_flag(&surfaces, name) {
132                    if attached.is_some() {
133                        return Err(format!("flag --{name} does not take a value"));
134                    }
135                    return Ok(Token::LongFlag { flag, negated });
136                }
137            }
138            // Declared short forms own their spelling, including digits and punctuation.
139            // Numeric-looking tokens become positionals only when no declaration matches.
140            // Where the tail can start without its separator, a cluster belongs to the command
141            // only if each of its characters does; any other cluster starts the tail.
142            if let Some(cluster) = token.strip_prefix('-')
143                && let Some(short) = cluster.chars().next()
144                && if implicit_tail {
145                    declared_short_cluster(tool, node_index, &surfaces, cluster)
146                } else {
147                    find_short(&surfaces, short).is_some()
148                }
149            {
150                return Ok(Token::ShortOptions);
151            }
152            if !self.body_started
153                && let Some(child) = child_named(tool, node_index, token)
154            {
155                return Ok(Token::Subcommand(child));
156            }
157            if help_available(tool, node_index) && matches!(token, "--help" | "-h") {
158                return Ok(Token::Help);
159            }
160            if token == "--" {
161                return Ok(Token::EndOptions);
162            }
163            if implicit_tail {
164                return Ok(Token::TailStart);
165            }
166            if token.starts_with("--") && token.len() > 2 {
167                let name = token[2..].split('=').next().unwrap();
168                return Err(format!("unknown option --{name}"));
169            }
170            if token.starts_with('-') && token != "-" && !looks_negative_number(token) {
171                return Ok(Token::ShortOptions);
172            }
173        }
174        let body = node
175            .body
176            .as_ref()
177            .ok_or_else(|| format!("expected a subcommand, found {token:?}"))?;
178        if !self.tail_started && fixed_count < body.positionals.fixed.len() {
179            return Ok(Token::Positional);
180        }
181        if let Some(spec) = &body.positionals.tail {
182            if !self.tail_started
183                && let Some(separator) = &spec.separator
184            {
185                return Err(format!("expected tail separator {separator:?}"));
186            }
187            return Ok(Token::Tail);
188        }
189        Err(format!("unexpected argument {token:?}"))
190    }
191}
192
193/// Parses arguments using a validated tool definition's command-line surface.
194///
195/// `args` excludes the tool name. Options, subcommands, aliases, positionals, defaults,
196/// help, and constraints follow the same rules for native CLI and guest callers.
197///
198/// A command whose only positional surface is a verbatim tail after `--`, and that has no
199/// subcommands, does not need the separator. Its declared options and flags and the generated
200/// help are read until the first other token, which starts the tail; an unknown option starts
201/// it too. From there every token goes to the tail unchanged. An explicit `--` still starts the
202/// tail, which is how a token that is also one of the command's options reaches the tail.
203///
204/// Environment-variable metadata never reads process environment values, and this function
205/// gives it no variables: see [`parse_with_variables`].
206///
207/// # Errors
208/// Returns a diagnostic for an invalid command line or a value that fails canonical validation.
209pub fn parse(tool: &Tool, args: &[String]) -> Result<ParsedToolArguments, String> {
210    parse_with_variables(tool, args, &HashMap::new())
211}
212
213/// Parses arguments like [`parse`], with the caller's variables.
214///
215/// An option or flag that is not on the command line and declares an environment variable takes
216/// the value of that variable from `variables`, as if the caller had written it once. The
217/// command line wins over a variable, and a variable wins over a default. Only `variables` is
218/// read, never the process environment, so the caller decides which values a tool can see.
219///
220/// # Errors
221/// Returns a diagnostic for an invalid command line or a value that fails canonical validation.
222pub fn parse_with_variables(
223    tool: &Tool,
224    args: &[String],
225    variables: &HashMap<String, String>,
226) -> Result<ParsedToolArguments, String> {
227    if tool.commands.nodes.is_empty() {
228        return Err("tool metadata has no root command".into());
229    }
230    let mut node_index = 0;
231    let mut path = Vec::new();
232    let mut supplied: HashMap<String, Vec<String>> = HashMap::new();
233    let mut flags: HashMap<String, (u32, Option<bool>)> = HashMap::new();
234    let mut positionals = Vec::new();
235    let mut tail = Vec::new();
236    let mut cursor = ParseCursor {
237        options_enabled: true,
238        tail_started: false,
239        body_started: false,
240    };
241    let mut i = 0;
242
243    while i < args.len() {
244        let token = &args[i];
245        let node = &tool.commands.nodes[node_index];
246        match cursor.classify(tool, node_index, token, positionals.len())? {
247            Token::Help => {
248                return Ok(ParsedToolArguments::Help(render_help(
249                    tool, node_index, &path,
250                )));
251            }
252            Token::TailSeparator { verbatim } => {
253                cursor.options_enabled &= !verbatim;
254                cursor.tail_started = true;
255                cursor.body_started = true;
256            }
257            Token::EndOptions => cursor.options_enabled = false,
258            Token::LongOption { option, attached } => {
259                let value = option_value(option, attached, args, &mut i)?;
260                supplied.entry(option.long.clone()).or_default().push(value);
261                cursor.body_started |= node
262                    .body
263                    .as_ref()
264                    .is_some_and(|body| body.options.iter().any(|o| std::ptr::eq(o, option)));
265            }
266            Token::LongFlag { flag, negated } => {
267                apply_flag(&mut flags, flag, negated)?;
268                cursor.body_started |= node
269                    .body
270                    .as_ref()
271                    .is_some_and(|body| body.flags.iter().any(|f| std::ptr::eq(f, flag)));
272            }
273            Token::ShortOptions => {
274                let surfaces = effective_named(tool, node_index);
275                let chars: Vec<char> = token[1..].chars().collect();
276                let mut c = 0;
277                while c < chars.len() {
278                    let short = chars[c];
279                    if help_available(tool, node_index) && short == 'h' {
280                        return Ok(ParsedToolArguments::Help(render_help(
281                            tool, node_index, &path,
282                        )));
283                    }
284                    let Some(surface) = find_short(&surfaces, short) else {
285                        return Err(format!("unknown short option -{short}"));
286                    };
287                    match surface {
288                        Surface::Flag(flag) => {
289                            apply_flag(&mut flags, flag, false)?;
290                            cursor.body_started |= node.body.as_ref().is_some_and(|body| {
291                                body.flags.iter().any(|f| std::ptr::eq(f, flag))
292                            });
293                        }
294                        Surface::Option(option) => {
295                            cursor.body_started |= node.body.as_ref().is_some_and(|body| {
296                                body.options.iter().any(|o| std::ptr::eq(o, option))
297                            });
298                            let rest: String = chars[c + 1..].iter().collect();
299                            let attached = (!rest.is_empty()).then_some(rest);
300                            supplied
301                                .entry(option.long.clone())
302                                .or_default()
303                                .push(option_value(option, attached, args, &mut i)?);
304                            break;
305                        }
306                        _ => unreachable!(),
307                    }
308                    c += 1;
309                }
310            }
311            Token::Subcommand(child) => {
312                node_index = child;
313                path.push(tool.commands.nodes[child].name.clone());
314            }
315            Token::Positional => {
316                cursor.body_started = true;
317                positionals.push(token.clone());
318            }
319            Token::Tail => {
320                cursor.body_started = true;
321                tail.push(token.clone());
322            }
323            Token::TailStart => {
324                cursor.options_enabled = false;
325                cursor.tail_started = true;
326                cursor.body_started = true;
327                tail.push(token.clone());
328            }
329        }
330        i += 1;
331    }
332
333    let node = &tool.commands.nodes[node_index];
334    let body = node
335        .body
336        .as_ref()
337        .ok_or_else(|| "a subcommand is required".to_string())?;
338    let surfaces = tool.canonical_input_surfaces(node_index);
339    // An option or flag the command line left out takes its declared variable, as if it had
340    // been written once. `from_variable` remembers the variable for the error text.
341    let mut from_variable: HashMap<&str, &str> = HashMap::new();
342    for surface_ref in surfaces.iter().copied() {
343        match resolve_surface(tool, node_index, surface_ref)? {
344            Surface::Option(option) if !supplied.contains_key(&option.long) => {
345                if let Some((name, value)) = declared_variable(&option.env_var, variables) {
346                    supplied.insert(option.long.clone(), vec![value.to_owned()]);
347                    from_variable.insert(&option.long, name);
348                }
349            }
350            Surface::Flag(flag) if !flags.contains_key(&flag.long) => {
351                if let Some((name, value)) = declared_variable(&flag.env_var, variables) {
352                    let state = flag_from_variable(flag, value)
353                        .map_err(|e| format!("--{} from variable {name}: {e}", flag.long))?;
354                    flags.insert(flag.long.clone(), state);
355                }
356            }
357            _ => {}
358        }
359    }
360    let mut values = Vec::with_capacity(surfaces.len());
361    for surface_ref in surfaces.iter().copied() {
362        let surface = resolve_surface(tool, node_index, surface_ref)?;
363        let value = collect_value(
364            tool,
365            node_index,
366            surface,
367            &supplied,
368            &flags,
369            &positionals,
370            &tail,
371        )
372        .map_err(|e| match surface {
373            Surface::Option(option) => match from_variable.get(option.long.as_str()) {
374                Some(name) => format!("--{} from variable {name}: {e}", option.long),
375                None => e,
376            },
377            _ => e,
378        })?;
379        values.push(value);
380    }
381    let model = tool
382        .canonical_input_model(node_index)
383        .map_err(|e| e.to_string())?;
384    let record = SchemaValue::Record { fields: values };
385    let decoded = model
386        .decode_record(record.clone())
387        .map_err(|e| e.to_string())?;
388    validate_tool_constraints(tool, node_index, &body.constraints, &surfaces, &decoded)?;
389    crate::schema::validation::validate_value(
390        &model.record_schema,
391        &model.record_schema.root,
392        &record,
393    )
394    .map_err(|errors| format!("invalid tool arguments: {errors:?}"))?;
395    Ok(ParsedToolArguments::Invoke {
396        command_path: path,
397        input: Box::new(TypedSchemaValue::new(model.record_schema, record)),
398    })
399}
400
401fn effective_named(tool: &Tool, command: usize) -> Vec<((usize, usize), Surface<'_>)> {
402    let mut path = Vec::new();
403    find_node_path(tool, 0, command, &mut path);
404    let mut result = Vec::new();
405    for node_index in path {
406        let node = &tool.commands.nodes[node_index];
407        result.extend(
408            node.globals
409                .options
410                .iter()
411                .enumerate()
412                .map(|(index, option)| ((node_index, index), Surface::Option(option))),
413        );
414        result.extend(
415            node.globals
416                .flags
417                .iter()
418                .enumerate()
419                .map(|(index, flag)| ((node_index, index), Surface::Flag(flag))),
420        );
421    }
422    if let Some(body) = &tool.commands.nodes[command].body {
423        result.extend(
424            body.options
425                .iter()
426                .enumerate()
427                .map(|(index, option)| ((command, index), Surface::Option(option))),
428        );
429        result.extend(
430            body.flags
431                .iter()
432                .enumerate()
433                .map(|(index, flag)| ((command, index), Surface::Flag(flag))),
434        );
435    }
436    result
437}
438
439fn find_node_path(tool: &Tool, current: usize, target: usize, path: &mut Vec<usize>) -> bool {
440    path.push(current);
441    if current == target {
442        return true;
443    }
444    for child in tool.commands.nodes[current]
445        .subcommands
446        .iter()
447        .filter_map(|index| index.as_usize())
448    {
449        if find_node_path(tool, child, target, path) {
450            return true;
451        }
452    }
453    path.pop();
454    false
455}
456
457fn resolve_surface(
458    tool: &Tool,
459    command: usize,
460    r: CanonicalSurfaceRef,
461) -> Result<Surface<'_>, String> {
462    let body = tool.commands.nodes[command]
463        .body
464        .as_ref()
465        .ok_or("command has no body")?;
466    Ok(match r {
467        CanonicalSurfaceRef::GlobalOption { node, index } => {
468            Surface::Option(&tool.commands.nodes[node].globals.options[index])
469        }
470        CanonicalSurfaceRef::GlobalFlag { node, index } => {
471            Surface::Flag(&tool.commands.nodes[node].globals.flags[index])
472        }
473        CanonicalSurfaceRef::BodyPositional { index } => Surface::Positional(index),
474        CanonicalSurfaceRef::BodyTail => Surface::Tail,
475        CanonicalSurfaceRef::BodyOption { index } => Surface::Option(&body.options[index]),
476        CanonicalSurfaceRef::BodyFlag { index } => Surface::Flag(&body.flags[index]),
477    })
478}
479
480fn collect_value(
481    tool: &Tool,
482    command: usize,
483    surface: Surface<'_>,
484    supplied: &HashMap<String, Vec<String>>,
485    flags: &HashMap<String, (u32, Option<bool>)>,
486    positionals: &[String],
487    tail: &[String],
488) -> Result<SchemaValue, String> {
489    let graph = &tool.schema;
490    match surface {
491        Surface::Positional(index) => {
492            let p = &tool.commands.nodes[command]
493                .body
494                .as_ref()
495                .unwrap()
496                .positionals
497                .fixed[index];
498            let type_ = canonical_positional_type(p);
499            match positionals.get(index) {
500                Some(v) => decode(graph, &type_, v),
501                None if p.default.is_some() => Ok(p.default.clone().unwrap()),
502                None if !p.required => omitted(resolve(graph, &type_)?),
503                None => Err(format!("missing required positional {}", p.name)),
504            }
505        }
506        Surface::Tail => {
507            let p = tool.commands.nodes[command]
508                .body
509                .as_ref()
510                .unwrap()
511                .positionals
512                .tail
513                .as_ref()
514                .unwrap();
515            if tail.len() < p.min as usize || p.max.is_some_and(|m| tail.len() > m as usize) {
516                return Err(format!("tail {} has invalid cardinality", p.name));
517            }
518            Ok(SchemaValue::List {
519                elements: tail
520                    .iter()
521                    .map(|v| decode(graph, &p.item_type, v))
522                    .collect::<Result<_, _>>()?,
523            })
524        }
525        Surface::Flag(flag) => {
526            let (count, boolean) = flags.get(&flag.long).copied().unwrap_or_default();
527            match flag.shape {
528                FlagShape::BoolFlag(s) => Ok(SchemaValue::Bool(boolean.unwrap_or(s.default))),
529                FlagShape::CountFlag(_) => Ok(SchemaValue::U32(count)),
530            }
531        }
532        Surface::Option(option) => {
533            let raw = supplied.get(&option.long).cloned();
534            if raw.is_none() {
535                if let Some(default) = &option.default {
536                    return Ok(default.clone());
537                }
538                if option.required {
539                    return Err(format!("missing required option --{}", option.long));
540                }
541            }
542            collect_option(graph, option, raw.unwrap_or_default())
543        }
544    }
545}
546
547fn collect_option(
548    graph: &SchemaGraph,
549    option: &OptionSpec,
550    raw: Vec<String>,
551) -> Result<SchemaValue, String> {
552    let repeatable = match &option.shape {
553        OptionShape::Scalar(_) | OptionShape::OptionalScalar(_) => false,
554        OptionShape::RepeatableList(shape) => !matches!(shape.repetition, Repetition::Delimited(_)),
555        OptionShape::RepeatableMap(shape) => !matches!(shape.repetition, Repetition::Delimited(_)),
556    };
557    if raw.len() > 1 && !repeatable {
558        return Err(format!("--{} cannot be repeated", option.long));
559    }
560    match &option.shape {
561        OptionShape::Scalar(_) => {
562            let ty = canonical_option_type(option);
563            raw.last()
564                .map(|v| decode(graph, &ty, v))
565                .unwrap_or_else(|| omitted(resolve(graph, &ty)?))
566        }
567        OptionShape::OptionalScalar(_) => {
568            let ty = canonical_option_type(option);
569            match raw.last() {
570                Some(value) if value == "\0" => option
571                    .default
572                    .clone()
573                    .ok_or_else(|| format!("--{} has no bare-value default", option.long)),
574                Some(value) => decode(graph, &ty, value),
575                None => omitted(resolve(graph, &ty)?),
576            }
577        }
578        OptionShape::RepeatableList(shape) => {
579            let parts = expand(&raw, shape.repetition);
580            Ok(SchemaValue::List {
581                elements: parts
582                    .iter()
583                    .map(|v| decode(graph, &shape.item_type, v))
584                    .collect::<Result<_, _>>()?,
585            })
586        }
587        OptionShape::RepeatableMap(shape) => {
588            let SchemaType::Map { key, value, .. } = resolve(graph, &shape.map_type)? else {
589                return Err(format!("--{} does not declare a map type", option.long));
590            };
591            let mut entries: Vec<(SchemaValue, SchemaValue)> = Vec::new();
592            for item in expand(&raw, shape.repetition) {
593                let (k, v) = item
594                    .split_once('=')
595                    .ok_or_else(|| format!("--{} expects KEY=VALUE", option.long))?;
596                let pair = (decode(graph, key, k)?, decode(graph, value, v)?);
597                if let Some(index) = entries.iter().position(|(old, _)| old == &pair.0) {
598                    match shape.duplicate_key_policy {
599                        DuplicateKeyPolicy::Reject => {
600                            return Err(format!("duplicate key for --{}", option.long));
601                        }
602                        DuplicateKeyPolicy::LastWins => entries[index] = pair,
603                    }
604                } else {
605                    entries.push(pair);
606                }
607            }
608            Ok(SchemaValue::Map { entries })
609        }
610    }
611}
612
613fn option_value(
614    option: &OptionSpec,
615    attached: Option<String>,
616    args: &[String],
617    i: &mut usize,
618) -> Result<String, String> {
619    if let Some(value) = attached {
620        return Ok(value);
621    }
622    if matches!(option.shape, OptionShape::OptionalScalar(_)) {
623        return Ok("\0".to_string());
624    }
625    *i += 1;
626    args.get(*i)
627        .cloned()
628        .ok_or_else(|| format!("--{} requires a value", option.long))
629}
630
631fn declared_variable<'a>(
632    declared: &'a Option<String>,
633    variables: &'a HashMap<String, String>,
634) -> Option<(&'a str, &'a str)> {
635    let name = declared.as_deref()?;
636    Some((name, variables.get(name)?))
637}
638
639fn flag_from_variable(flag: &FlagSpec, value: &str) -> Result<(u32, Option<bool>), String> {
640    match flag.shape {
641        FlagShape::BoolFlag(_) => match bool_word(value) {
642            Some(state) => Ok((0, Some(state))),
643            None => Err(format!(
644                "invalid value {value:?}: expected true, yes, on, y, t or 1, or false, no, off, n, f or 0"
645            )),
646        },
647        FlagShape::CountFlag(max) => {
648            let count: u32 = value
649                .parse()
650                .map_err(|_| format!("invalid value {value:?}: expected a count"))?;
651            if max.is_some_and(|max| count > max) {
652                return Err(format!("invalid value {value:?}: exceeds maximum count"));
653            }
654            Ok((count, None))
655        }
656    }
657}
658
659/// The state that a variable gives to a bool flag. Only the usual words have a meaning; other
660/// text is refused and not guessed.
661fn bool_word(value: &str) -> Option<bool> {
662    const TRUE: [&str; 6] = ["1", "true", "yes", "on", "y", "t"];
663    const FALSE: [&str; 6] = ["0", "false", "no", "off", "n", "f"];
664    if TRUE.iter().any(|word| value.eq_ignore_ascii_case(word)) {
665        Some(true)
666    } else if FALSE.iter().any(|word| value.eq_ignore_ascii_case(word)) {
667        Some(false)
668    } else {
669        None
670    }
671}
672
673fn apply_flag(
674    store: &mut HashMap<String, (u32, Option<bool>)>,
675    flag: &FlagSpec,
676    negated: bool,
677) -> Result<(), String> {
678    let entry = store.entry(flag.long.clone()).or_default();
679    match flag.shape {
680        FlagShape::BoolFlag(_) => entry.1 = Some(!negated),
681        FlagShape::CountFlag(max) => {
682            entry.0 += 1;
683            if max.is_some_and(|m| entry.0 > m) {
684                return Err(format!("--{} exceeds maximum count", flag.long));
685            }
686        }
687    }
688    Ok(())
689}
690
691fn find_short<'a>(surfaces: &[((usize, usize), Surface<'a>)], short: char) -> Option<Surface<'a>> {
692    surfaces.iter().find_map(|(_, surface)| match surface {
693        Surface::Option(option) if option.short == Some(short) => Some(*surface),
694        Surface::Flag(flag) if flag.short == Some(short) => Some(*surface),
695        _ => None,
696    })
697}
698
699fn find_long_flag<'a>(
700    surfaces: &[((usize, usize), Surface<'a>)],
701    name: &str,
702) -> Option<((usize, usize), &'a FlagSpec, bool)> {
703    surfaces
704        .iter()
705        .find_map(|(key, s)| match s {
706            Surface::Flag(f) if f.long == name || f.aliases.iter().any(|a| a == name) => {
707                Some((*key, *f, false))
708            }
709            _ => None,
710        })
711        .or_else(|| {
712            surfaces.iter().find_map(|(key, s)| match s {
713                Surface::Flag(f)
714                    if matches!(f.shape, FlagShape::BoolFlag(s) if s.negatable)
715                        && name == format!("no-{}", f.long) =>
716                {
717                    Some((*key, *f, true))
718                }
719                _ => None,
720            })
721        })
722}
723
724fn decode(graph: &SchemaGraph, ty: &SchemaType, raw: &str) -> Result<SchemaValue, String> {
725    let resolved = resolve(graph, ty)?;
726    if let SchemaType::Option { inner, .. } = resolved {
727        return Ok(SchemaValue::Option {
728            inner: Some(Box::new(decode(graph, inner, raw)?)),
729        });
730    }
731    let json = match resolved {
732        SchemaType::String { .. }
733        | SchemaType::Char { .. }
734        | SchemaType::Path { .. }
735        | SchemaType::Url { .. }
736        | SchemaType::Uuid { .. }
737        | SchemaType::Datetime { .. }
738        | SchemaType::Duration { .. } => serde_json::Value::String(raw.into()),
739        SchemaType::Enum { cases, .. } => serde_json::Value::String(enum_case(cases, raw).into()),
740        SchemaType::Bool { .. } if raw.eq_ignore_ascii_case("true") => {
741            serde_json::Value::Bool(true)
742        }
743        SchemaType::Bool { .. } if raw.eq_ignore_ascii_case("false") => {
744            serde_json::Value::Bool(false)
745        }
746        // The canonical JSON form of a 64-bit integer is a string, so a plain number goes in as
747        // its digits. The quoted form still parses below.
748        SchemaType::U64 { .. } | SchemaType::S64 { .. } if !raw.starts_with('"') => {
749            serde_json::Value::String(raw.into())
750        }
751        _ => serde_json::from_str(raw).map_err(|e| format!("invalid value {raw:?}: {e}"))?,
752    };
753    crate::schema::render::from_untrusted_json_value(graph, ty, &json)
754        .map_err(|e| format!("invalid value {raw:?}: {e}"))
755}
756
757/// The case that `raw` names: the exact one, or the only one that is equal without regard to
758/// letter case. Other text goes on unchanged, and the reader refuses it.
759fn enum_case<'a>(cases: &'a [String], raw: &'a str) -> &'a str {
760    if cases.iter().any(|case| case == raw) {
761        return raw;
762    }
763    let mut equal = cases.iter().filter(|case| case.eq_ignore_ascii_case(raw));
764    match (equal.next(), equal.next()) {
765        (Some(case), None) => case,
766        _ => raw,
767    }
768}
769
770fn resolve<'a>(graph: &'a SchemaGraph, ty: &'a SchemaType) -> Result<&'a SchemaType, String> {
771    graph.resolve_ref(ty).map_err(|e| e.to_string())
772}
773
774fn omitted(ty: &SchemaType) -> Result<SchemaValue, String> {
775    match ty {
776        SchemaType::Option { .. } => Ok(SchemaValue::Option { inner: None }),
777        SchemaType::List { .. } => Ok(SchemaValue::List { elements: vec![] }),
778        SchemaType::Map { .. } => Ok(SchemaValue::Map { entries: vec![] }),
779        _ => Err("optional argument must have an option or collection schema, or a default".into()),
780    }
781}
782
783fn expand(values: &[String], repetition: Repetition) -> Vec<String> {
784    match repetition {
785        Repetition::Repeated => values.to_vec(),
786        Repetition::Delimited(c) | Repetition::Either(c) => values
787            .iter()
788            .flat_map(|v| v.split(c).map(str::to_string))
789            .collect(),
790    }
791}
792
793fn child_named(tool: &Tool, node: usize, name: &str) -> Option<usize> {
794    tool.commands.nodes[node]
795        .subcommands
796        .iter()
797        .filter_map(|i| i.as_usize())
798        .find(|i| {
799            tool.commands
800                .nodes
801                .get(*i)
802                .is_some_and(|n| n.name == name || n.aliases.iter().any(|a| a == name))
803        })
804}
805
806fn looks_negative_number(token: &str) -> bool {
807    token[1..].starts_with(|c: char| c.is_ascii_digit() || c == '.')
808}
809
810/// Whether the command's tail can start without its separator.
811///
812/// That is so when a verbatim tail after `--` is the command's only positional surface and the
813/// command has no subcommands: then no token before the tail can be anything but a declared
814/// option, flag or help request, so the first other token starts the tail.
815fn implicit_tail(tool: &Tool, node: usize) -> bool {
816    let command = &tool.commands.nodes[node];
817    command.subcommands.is_empty()
818        && command.body.as_ref().is_some_and(|body| {
819            body.positionals.fixed.is_empty()
820                && body
821                    .positionals
822                    .tail
823                    .as_ref()
824                    .is_some_and(|tail| tail.verbatim && tail.separator.as_deref() == Some("--"))
825        })
826}
827
828/// Whether each character of a short-option cluster is the command's, read as the parser reads
829/// it: declared flags, up to a declared option that takes the rest as its value or a help
830/// request.
831fn declared_short_cluster(
832    tool: &Tool,
833    node: usize,
834    surfaces: &[((usize, usize), Surface<'_>)],
835    cluster: &str,
836) -> bool {
837    let help = help_available(tool, node);
838    for short in cluster.chars() {
839        if help && short == 'h' {
840            return true;
841        }
842        match find_short(surfaces, short) {
843            Some(Surface::Flag(_)) => {}
844            Some(_) => return true,
845            None => return false,
846        }
847    }
848    !cluster.is_empty()
849}
850
851fn help_available(tool: &Tool, node: usize) -> bool {
852    if tool.commands.nodes[node]
853        .body
854        .as_ref()
855        .and_then(|body| body.positionals.tail.as_ref())
856        .and_then(|tail| tail.separator.as_deref())
857        .is_some_and(|separator| matches!(separator, "--help" | "-h"))
858    {
859        return false;
860    }
861    !effective_named(tool, node).iter().any(|(_, s)| match s {
862        Surface::Option(o) => {
863            o.long == "help" || o.aliases.iter().any(|a| a == "help") || o.short == Some('h')
864        }
865        Surface::Flag(f) => {
866            f.long == "help" || f.aliases.iter().any(|a| a == "help") || f.short == Some('h')
867        }
868        _ => false,
869    })
870}
871
872fn render_help(tool: &Tool, node: usize, path: &[String]) -> String {
873    let command = &tool.commands.nodes[node];
874    let mut out = format!("Usage: {}", tool.name().unwrap_or("tool"));
875    for part in path {
876        out.push(' ');
877        out.push_str(part);
878    }
879    if !effective_named(tool, node).is_empty() {
880        out.push_str(" [OPTIONS]");
881    }
882    if !command.subcommands.is_empty() {
883        out.push_str(" [COMMAND]");
884    }
885    if let Some(body) = &command.body {
886        for p in &body.positionals.fixed {
887            out.push(' ');
888            out.push_str(&positional_label(p));
889        }
890        if let Some(tail) = &body.positionals.tail {
891            if let Some(separator) = &tail.separator {
892                if implicit_tail(tool, node) {
893                    out.push_str(&format!(" [{separator}]"));
894                } else {
895                    out.push_str(&format!(" {separator}"));
896                }
897            }
898            out.push(' ');
899            out.push_str(&tail_label(tail));
900        }
901    }
902    out.push('\n');
903    if !command.doc.summary.is_empty() {
904        out.push('\n');
905        out.push_str(&command.doc.summary);
906        out.push('\n');
907    }
908    if !command.doc.description.is_empty() {
909        out.push_str(&command.doc.description);
910        out.push('\n');
911    }
912    if !command.subcommands.is_empty() {
913        out.push_str("\nCommands:\n");
914        for child in &command.subcommands {
915            if let Some(c) = child.as_usize().and_then(|i| tool.commands.nodes.get(i)) {
916                out.push_str(&format!("  {:16} {}\n", c.name, c.doc.summary));
917            }
918        }
919    }
920    if let Some(body) = &command.body {
921        let mut arguments: Vec<_> = body
922            .positionals
923            .fixed
924            .iter()
925            .map(|p| (positional_label(p), &p.doc, &p.type_, ""))
926            .collect();
927        if let Some(tail) = &body.positionals.tail {
928            let start = if implicit_tail(tool, node) {
929                " [starts at the first argument that is not an option of this command, or after --]"
930            } else {
931                ""
932            };
933            arguments.push((tail_label(tail), &tail.doc, &tail.item_type, start));
934        }
935        if !arguments.is_empty() {
936            out.push_str("\nArguments:\n");
937        }
938        for (label, doc, type_, start) in arguments {
939            let text = format!(
940                "{}{}{start}",
941                doc.summary,
942                possible_values(&tool.schema, type_)
943            );
944            let line = format!("  {label:16} {}", text.trim_start());
945            out.push_str(line.trim_end());
946            out.push('\n');
947        }
948    }
949    out.push_str("\nOptions:\n");
950    for (_, s) in effective_named(tool, node) {
951        match s {
952            Surface::Option(o) => {
953                let short = o.short.map(|s| format!("-{s}, ")).unwrap_or_default();
954                let value = o.value_name.as_deref().unwrap_or("VALUE");
955                let value = if matches!(o.shape, OptionShape::OptionalScalar(_)) {
956                    format!("[={value}]")
957                } else {
958                    format!(" <{value}>")
959                };
960                out.push_str(&format!("  {short}--{}{value}  {}", o.long, o.doc.summary));
961                if o.required {
962                    out.push_str(" [required]");
963                }
964                if o.default.is_some() {
965                    out.push_str(" [has default]");
966                }
967                if !o.aliases.is_empty() {
968                    out.push_str(&format!(" [aliases: {}]", o.aliases.join(", ")));
969                }
970                match &o.shape {
971                    OptionShape::Scalar(type_) | OptionShape::OptionalScalar(type_) => {
972                        out.push_str(&possible_values(&tool.schema, type_));
973                    }
974                    OptionShape::RepeatableList(shape) => {
975                        out.push_str(&possible_values(&tool.schema, &shape.item_type));
976                    }
977                    OptionShape::RepeatableMap(_) => {}
978                }
979                out.push('\n');
980            }
981            Surface::Flag(f) => {
982                let short = f.short.map(|s| format!("-{s}, ")).unwrap_or_default();
983                out.push_str(&format!("  {short}--{}  {}", f.long, f.doc.summary));
984                if let FlagShape::BoolFlag(shape) = f.shape
985                    && shape.negatable
986                {
987                    out.push_str(&format!(" [--no-{}]", f.long));
988                }
989                if !f.aliases.is_empty() {
990                    out.push_str(&format!(" [aliases: {}]", f.aliases.join(", ")));
991                }
992                out.push('\n');
993            }
994            _ => {}
995        }
996    }
997    if help_available(tool, node) {
998        out.push_str("  -h, --help      Print help (reserved only when undeclared)\n");
999    }
1000    out
1001}
1002
1003/// A fixed positional as the usage line and the argument list show it.
1004fn positional_label(positional: &Positional) -> String {
1005    let name = positional.value_name.as_deref().unwrap_or(&positional.name);
1006    if positional.required && positional.default.is_none() {
1007        format!("<{name}>")
1008    } else {
1009        format!("[{name}]")
1010    }
1011}
1012
1013/// The tail positional as the usage line and the argument list show it.
1014fn tail_label(tail: &TailPositional) -> String {
1015    format!("[{}]...", tail.value_name.as_deref().unwrap_or(&tail.name))
1016}
1017
1018/// The help marker that lists the cases of an enum value, or nothing for another type.
1019fn possible_values(graph: &SchemaGraph, type_: &SchemaType) -> String {
1020    match enum_cases(graph, type_) {
1021        Some(cases) => format!(" [possible values: {}]", cases.join(", ")),
1022        None => String::new(),
1023    }
1024}
1025
1026/// The cases of the enum that a value of `type_` is, through named references and options.
1027fn enum_cases<'a>(graph: &'a SchemaGraph, type_: &'a SchemaType) -> Option<&'a [String]> {
1028    let mut current = type_;
1029    // Bounded, because a recursive type can contain itself through an option.
1030    for _ in 0..8 {
1031        current = match graph.resolve_ref(current).ok()? {
1032            SchemaType::Enum { cases, .. } => return Some(cases),
1033            SchemaType::Option { inner, .. } => inner,
1034            _ => return None,
1035        };
1036    }
1037    None
1038}
1039
1040#[cfg(test)]
1041mod tests;