Skip to main content

agent_first_data/cli_spec/
resolve.rs

1use super::error::{duplicate_error, invalid_value, missing_value};
2use super::help::combination_usage;
3use super::*;
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsString;
6use std::path::{Path, PathBuf};
7
8/// Validated registry used for resolution and help generation.
9#[derive(Clone, Debug)]
10pub struct BuiltCliSpec {
11    pub(super) spec: CliSpec,
12}
13
14impl BuiltCliSpec {
15    pub fn spec(&self) -> &CliSpec {
16        &self.spec
17    }
18
19    pub fn resolve_from<I, S>(&self, args: I) -> Result<CliOutcome, CliError>
20    where
21        I: IntoIterator<Item = S>,
22        S: Into<OsString>,
23    {
24        let raw: Vec<OsString> = args.into_iter().map(Into::into).collect();
25        self.resolve_os(&raw)
26    }
27
28    /// Generate type-correct argv for every combination and every fixed
29    /// `one_of` member. These fixtures exercise the same normalized shapes
30    /// used by help rendering without pretending help placeholders are argv.
31    pub fn synthetic_invocations(&self) -> Vec<SyntheticInvocation> {
32        let mut fixtures = Vec::new();
33        for command in &self.spec.commands {
34            for combination in &command.combinations {
35                let mut variants = vec![vec![self.spec.name.clone()]];
36                for part in &command.command_path {
37                    for argv in &mut variants {
38                        argv.push(part.clone());
39                    }
40                }
41                for argument in &command.arguments {
42                    let values: Vec<String> =
43                        if let Some(fixed) = combination.fixed.get(&argument.argument_id) {
44                            fixed.values().to_vec()
45                        } else if combination
46                            .required
47                            .iter()
48                            .any(|id| id == &argument.argument_id)
49                        {
50                            vec![synthetic_value(argument)]
51                        } else {
52                            continue;
53                        };
54                    let mut expanded = Vec::new();
55                    for argv in variants {
56                        for value in &values {
57                            let mut candidate = argv.clone();
58                            append_synthetic_argument(&mut candidate, argument, value);
59                            expanded.push(candidate);
60                        }
61                    }
62                    variants = expanded;
63                }
64                fixtures.extend(variants.into_iter().map(|argv| SyntheticInvocation {
65                    command_path: command.command_path.clone(),
66                    combination_id: combination.combination_id.clone(),
67                    argv,
68                }));
69            }
70        }
71        fixtures
72    }
73
74    /// The help model for one command.
75    ///
76    /// The same model `--help` returns, reachable in-process. Build-time
77    /// tooling — an offline reference renderer, for example — needs the whole
78    /// registry, and this is how it consumes it without a full-spec dump on
79    /// the agent's discovery path.
80    pub fn help(&self, command_path: &[String]) -> Option<CliHelpV2> {
81        let command = self
82            .spec
83            .commands
84            .iter()
85            .find(|candidate| candidate.command_path == command_path)?;
86        Some(self.help_model(command))
87    }
88
89    /// Check exact action coverage and return an executable binding.
90    pub fn bind_actions<R, I, S>(&self, handlers: I) -> Result<BoundCliSpec<R>, CliSpecError>
91    where
92        I: IntoIterator<Item = (S, fn(&ResolvedInvocation) -> R)>,
93        S: Into<String>,
94    {
95        let expected: BTreeSet<&str> = self
96            .spec
97            .commands
98            .iter()
99            .flat_map(|command| command.combinations.iter())
100            .map(|combination| combination.action_id.as_str())
101            .collect();
102        let mut actual = BTreeMap::new();
103        for (action_id, handler) in handlers {
104            let action_id = action_id.into();
105            if actual.insert(action_id.clone(), handler).is_some() {
106                return Err(CliSpecError::new(
107                    "duplicate_action_handler",
108                    format!("action `{action_id}` has more than one handler"),
109                ));
110            }
111        }
112        let actual_ids: BTreeSet<&str> = actual.keys().map(String::as_str).collect();
113        if expected != actual_ids {
114            let missing: Vec<&str> = expected.difference(&actual_ids).copied().collect();
115            let extra: Vec<&str> = actual_ids.difference(&expected).copied().collect();
116            return Err(CliSpecError::new(
117                "action_handler_coverage",
118                format!("handler coverage mismatch; missing={missing:?}, extra={extra:?}"),
119            ));
120        }
121        Ok(BoundCliSpec {
122            cli: self.clone(),
123            handlers: actual,
124        })
125    }
126
127    fn resolve_os(&self, raw: &[OsString]) -> Result<CliOutcome, CliError> {
128        let mut utf8 = Vec::with_capacity(raw.len());
129        for token in raw {
130            let Some(token) = token.to_str() else {
131                return Err(CliError::new(
132                    CliErrorRule::InvalidUtf8,
133                    self.spec.name.clone(),
134                    "argv contains a token that is not valid UTF-8",
135                ));
136            };
137            utf8.push(token.to_string());
138        }
139        let argv = if utf8.is_empty() { &[][..] } else { &utf8[1..] };
140        let (command, consumed) = self.select_command(argv)?;
141        let command_path = self.display_command_path(command);
142        let parsed = tokenize(command, &argv[consumed..], &command_path)?;
143
144        if parsed.control_count() > 0 {
145            if parsed.control_count() != 1 || !parsed.application_values.is_empty() {
146                return Err(CliError::unregistered(command_path));
147            }
148            // `--docs` renders the whole registry, which is raw bytes, not
149            // protocol events. It therefore gets its own contract instead of
150            // `lifecycle_output`, and must be settled before the shared
151            // protocol plan below would wrongly accept `--output`.
152            if parsed.docs {
153                // Root-only, like `--version` below: past the command path the
154                // spelling belongs to the application, and where it declared
155                // one, `tokenize` bound the token to that argument and never
156                // set this flag at all.
157                if !command.command_path.is_empty() {
158                    return Err(CliError::unregistered(command_path));
159                }
160                let contract = OutputSpec::raw()
161                    .file_sinks(self.spec.lifecycle_output.file_sinks_ref().to_vec());
162                let output = resolve_output(&contract, &parsed.output, &command_path)?;
163                return Ok(CliOutcome::Docs(ResolvedDocs { output }));
164            }
165            let output =
166                resolve_output(&self.spec.lifecycle_output, &parsed.output, &command_path)?;
167            if parsed.help {
168                return Ok(CliOutcome::Help(ResolvedHelp {
169                    model: self.help_model(command),
170                    output,
171                }));
172            }
173            if parsed.version {
174                if !command.command_path.is_empty() {
175                    return Err(CliError::unregistered(command_path));
176                }
177                return Ok(CliOutcome::Version(ResolvedVersion {
178                    name: self.spec.name.clone(),
179                    version: self.spec.version.clone(),
180                    display_name: self.spec.display_name.clone(),
181                    build: self.spec.build.clone(),
182                    output,
183                }));
184            }
185        }
186
187        let matching: Vec<&Combination> = command
188            .combinations
189            .iter()
190            .filter(|combination| combination_matches(command, combination, &parsed))
191            .collect();
192        let Some(combination) = matching.first().copied() else {
193            return Err(CliError::unregistered(command_path));
194        };
195        if matching.len() != 1 {
196            return Err(CliError::new(
197                CliErrorRule::UnregisteredCombination,
198                command_path,
199                "arguments match more than one registered CLI combination",
200            ));
201        }
202        let output = resolve_output(&combination.output, &parsed.output, &command_path)?;
203        let values = project_values(command, combination, &parsed);
204        Ok(CliOutcome::Run(ResolvedInvocation {
205            command_path: command.command_path.clone(),
206            action_id: combination.action_id.clone(),
207            combination_id: combination.combination_id.clone(),
208            values,
209            explicit_argument_ids: parsed.explicit_application_ids,
210            output,
211        }))
212    }
213
214    fn select_command<'a>(&'a self, argv: &[String]) -> Result<(&'a CommandSpec, usize), CliError> {
215        let mut commands: Vec<&CommandSpec> = self.spec.commands.iter().collect();
216        commands.sort_by_key(|command| std::cmp::Reverse(command.command_path.len()));
217        if let Some(command) = commands.iter().copied().find(|command| {
218            command.command_path.len() <= argv.len()
219                && command
220                    .command_path
221                    .iter()
222                    .zip(argv)
223                    .all(|(expected, actual)| expected == actual)
224        }) {
225            let remaining = &argv[command.command_path.len()..];
226            let has_children = self.spec.commands.iter().any(|candidate| {
227                candidate.command_path.len() > command.command_path.len()
228                    && candidate.command_path.starts_with(&command.command_path)
229            });
230            if remaining
231                .first()
232                .is_some_and(|token| !token.starts_with('-'))
233                && has_children
234            {
235                return Err(CliError::new(
236                    CliErrorRule::UnknownCommand,
237                    self.display_command_path(command),
238                    format!("unknown command `{}`", remaining[0]),
239                ));
240            }
241            return Ok((command, command.command_path.len()));
242        }
243        Err(CliError::new(
244            CliErrorRule::UnknownCommand,
245            self.spec.name.clone(),
246            "unknown command",
247        ))
248    }
249
250    fn display_command_path(&self, command: &CommandSpec) -> String {
251        std::iter::once(self.spec.name.as_str())
252            .chain(command.command_path.iter().map(String::as_str))
253            .collect::<Vec<_>>()
254            .join(" ")
255    }
256
257    fn child_help_commands(&self, command: &CommandSpec) -> Vec<String> {
258        let mut children: Vec<Vec<String>> = self
259            .spec
260            .commands
261            .iter()
262            .filter(|candidate| {
263                candidate.command_path.len() == command.command_path.len() + 1
264                    && candidate.command_path.starts_with(&command.command_path)
265            })
266            .map(|candidate| candidate.command_path.clone())
267            .collect();
268        children.sort();
269        children
270            .into_iter()
271            .map(|path| format!("{} {} --help", self.spec.name, path.join(" ")))
272            .collect()
273    }
274
275    fn help_model(&self, command: &CommandSpec) -> CliHelpV2 {
276        let command_path = self.display_command_path(command);
277        let mut shapes = Vec::new();
278        let mut notes = BTreeMap::new();
279        let mut defaults = BTreeMap::new();
280        for combination in &command.combinations {
281            let (usage, shape_notes, shape_defaults) =
282                combination_usage(command, combination, &command_path, true);
283            shapes.push(CliShape {
284                id: combination.combination_id.clone(),
285                // With one shape there is nothing to tell apart, so the
286                // command's own description already covers it.
287                about: if command.combinations.len() == 1 {
288                    None
289                } else {
290                    combination.about.clone()
291                },
292                usage,
293            });
294            // Arguments belong to the command, so a note or default reached
295            // through any shape is the same fact; collecting them once keeps
296            // the response from repeating itself per shape.
297            notes.extend(shape_notes);
298            defaults.extend(shape_defaults);
299        }
300        CliHelpV2 {
301            schema: "cli-help-v2".to_string(),
302            command_path,
303            // The root command has no description of its own — the registry's
304            // does double duty, and without this fallback an agent's first
305            // discovery call learns every subcommand but never what the tool is.
306            about: command.about.clone().or_else(|| {
307                command
308                    .command_path
309                    .is_empty()
310                    .then(|| self.spec.about.clone())
311                    .flatten()
312            }),
313            shapes,
314            subcommands: self.child_help_commands(command),
315            notes,
316            defaults,
317        }
318    }
319}
320
321/// Type-correct argv generated from a registered shape.
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct SyntheticInvocation {
324    pub command_path: Vec<String>,
325    pub combination_id: String,
326    pub argv: Vec<String>,
327}
328
329/// A built registry with exactly one handler per action.
330pub struct BoundCliSpec<R> {
331    cli: BuiltCliSpec,
332    handlers: BTreeMap<String, fn(&ResolvedInvocation) -> R>,
333}
334
335impl<R> BoundCliSpec<R> {
336    pub fn resolve_from<I, S>(&self, args: I) -> Result<CliOutcome, CliError>
337    where
338        I: IntoIterator<Item = S>,
339        S: Into<OsString>,
340    {
341        self.cli.resolve_from(args)
342    }
343
344    /// Run the handler bound to this invocation's action.
345    ///
346    /// `bind_actions` proved at startup that every action id has exactly one
347    /// handler, so this lookup cannot miss. The allow records that proof
348    /// instead of inventing a fallback value of a caller-chosen type `R`.
349    #[allow(clippy::expect_used)]
350    pub fn execute(&self, invocation: &ResolvedInvocation) -> R {
351        let handler = self
352            .handlers
353            .get(invocation.action_id())
354            .expect("bind_actions guarantees one handler per action id");
355        handler(invocation)
356    }
357}
358
359/// What an argv resolved to.
360#[derive(Clone, Debug, PartialEq)]
361pub enum CliOutcome {
362    Run(ResolvedInvocation),
363    Help(ResolvedHelp),
364    Version(ResolvedVersion),
365    Docs(ResolvedDocs),
366}
367
368/// One legal invocation, projected onto the shape that matched it.
369#[derive(Clone, Debug, PartialEq)]
370pub struct ResolvedInvocation {
371    pub(super) command_path: Vec<String>,
372    pub(super) action_id: String,
373    pub(super) combination_id: String,
374    pub(super) values: BTreeMap<String, CliValue>,
375    pub(super) explicit_argument_ids: BTreeSet<String>,
376    pub(super) output: OutputPlan,
377}
378
379/// Stands in for an argument the resolver guarantees is present.
380///
381/// Asking for an id the selected shape cannot produce is a programming error,
382/// not a user error, so it must not become a `cli_error` — and it must not
383/// panic either. Reads of this value simply fail their type check.
384const MISSING: CliValue = CliValue::Bool(false);
385
386impl ResolvedInvocation {
387    pub fn command_path(&self) -> &[String] {
388        &self.command_path
389    }
390
391    pub fn action_id(&self) -> &str {
392        &self.action_id
393    }
394
395    pub fn combination_id(&self) -> &str {
396        &self.combination_id
397    }
398
399    pub fn output_plan(&self) -> &OutputPlan {
400        &self.output
401    }
402
403    /// Whether the caller wrote this argument, as opposed to inheriting it
404    /// from the shape's fixed value or the argument's default.
405    pub fn was_explicit(&self, argument_id: &str) -> bool {
406        self.explicit_argument_ids.contains(argument_id)
407    }
408
409    pub fn optional(&self, argument_id: &str) -> Option<&CliValue> {
410        self.values.get(argument_id)
411    }
412
413    pub fn required(&self, argument_id: &str) -> &CliValue {
414        self.values.get(argument_id).unwrap_or(&MISSING)
415    }
416
417    pub fn repeated(&self, argument_id: &str) -> &[CliValue] {
418        self.values
419            .get(argument_id)
420            .and_then(CliValue::as_list)
421            .unwrap_or(&[])
422    }
423}
424
425/// Where a resolved call's output goes, and in what form.
426#[derive(Clone, Debug, PartialEq, Eq)]
427pub enum OutputPlan {
428    Raw {
429        stdout_file: Option<PathBuf>,
430        stderr_file: Option<PathBuf>,
431    },
432    Protocol {
433        lifecycle: OutputLifecycle,
434        format: String,
435        destination: String,
436        stdout_file: Option<PathBuf>,
437        stderr_file: Option<PathBuf>,
438    },
439}
440
441impl OutputPlan {
442    pub fn format(&self) -> Option<&str> {
443        match self {
444            Self::Raw { .. } => None,
445            Self::Protocol { format, .. } => Some(format),
446        }
447    }
448
449    pub fn destination(&self) -> Option<&str> {
450        match self {
451            Self::Raw { .. } => None,
452            Self::Protocol { destination, .. } => Some(destination),
453        }
454    }
455
456    pub fn stdout_file(&self) -> Option<&Path> {
457        match self {
458            Self::Raw { stdout_file, .. } | Self::Protocol { stdout_file, .. } => {
459                stdout_file.as_deref()
460            }
461        }
462    }
463
464    pub fn stderr_file(&self) -> Option<&Path> {
465        match self {
466            Self::Raw { stderr_file, .. } | Self::Protocol { stderr_file, .. } => {
467                stderr_file.as_deref()
468            }
469        }
470    }
471}
472
473#[derive(Default)]
474struct ParsedArgs {
475    application_values: BTreeMap<String, Vec<CliValue>>,
476    explicit_application_ids: BTreeSet<String>,
477    output: ParsedOutput,
478    help: bool,
479    version: bool,
480    docs: bool,
481}
482
483impl ParsedArgs {
484    fn control_count(&self) -> usize {
485        usize::from(self.help) + usize::from(self.version) + usize::from(self.docs)
486    }
487}
488
489#[derive(Default)]
490struct ParsedOutput {
491    format: Option<String>,
492    destination: Option<String>,
493    stdout_file: Option<PathBuf>,
494    stderr_file: Option<PathBuf>,
495}
496
497fn tokenize(
498    command: &CommandSpec,
499    tokens: &[String],
500    command_path: &str,
501) -> Result<ParsedArgs, CliError> {
502    let longs: BTreeMap<&str, &ArgSpec> = command
503        .arguments
504        .iter()
505        .filter_map(|argument| match &argument.syntax {
506            ArgSyntax::Long { name } => Some((name.as_str(), argument)),
507            ArgSyntax::Positional { .. } => None,
508        })
509        .collect();
510    let mut positionals: Vec<&ArgSpec> = command
511        .arguments
512        .iter()
513        .filter(|argument| matches!(argument.syntax, ArgSyntax::Positional { .. }))
514        .collect();
515    positionals.sort_by_key(|argument| match argument.syntax {
516        ArgSyntax::Positional { index } => index,
517        ArgSyntax::Long { .. } => usize::MAX,
518    });
519
520    let mut parsed = ParsedArgs::default();
521    let mut index = 0;
522    let mut positional_index = 0;
523    let mut options_done = false;
524    while index < tokens.len() {
525        let token = &tokens[index];
526        if !options_done && token == "--" {
527            options_done = true;
528            index += 1;
529            continue;
530        }
531        if !options_done && token.starts_with("--") {
532            let (name, inline_value) = token
533                .split_once('=')
534                .map_or((token.as_str(), None), |(name, value)| (name, Some(value)));
535            if let Some(argument) = longs.get(name).copied() {
536                let display = name.to_string();
537                let raw_value = if argument.value_type == ArgValueType::Flag {
538                    if inline_value.is_some() {
539                        return Err(invalid_value(
540                            command_path,
541                            display,
542                            "flags do not accept values",
543                        ));
544                    }
545                    None
546                } else {
547                    Some(take_value(
548                        tokens,
549                        &mut index,
550                        inline_value,
551                        name,
552                        command_path,
553                    )?)
554                };
555                let value = match raw_value {
556                    Some(value) => parse_value(argument, value).map_err(|message| {
557                        invalid_value(command_path, display.clone(), &message)
558                    })?,
559                    None => CliValue::Bool(true),
560                };
561                insert_application(&mut parsed, argument, value, display, command_path)?;
562                index += 1;
563                continue;
564            }
565            match name {
566                "--help" => {
567                    reject_inline_value(inline_value, name, command_path)?;
568                    set_once(&mut parsed.help, name, command_path)?;
569                }
570                "--version" => {
571                    reject_inline_value(inline_value, name, command_path)?;
572                    set_once(&mut parsed.version, name, command_path)?;
573                }
574                "--docs" => {
575                    reject_inline_value(inline_value, name, command_path)?;
576                    set_once(&mut parsed.docs, name, command_path)?;
577                }
578                "--output" => {
579                    parsed.output.format = Some(set_output_value(
580                        parsed.output.format.as_ref(),
581                        take_value(tokens, &mut index, inline_value, name, command_path)?,
582                        name,
583                        command_path,
584                    )?);
585                }
586                "--output-to" => {
587                    parsed.output.destination = Some(set_output_value(
588                        parsed.output.destination.as_ref(),
589                        take_value(tokens, &mut index, inline_value, name, command_path)?,
590                        name,
591                        command_path,
592                    )?);
593                }
594                "--stdout-file" => {
595                    parsed.output.stdout_file = Some(PathBuf::from(set_output_value(
596                        parsed.output.stdout_file.as_ref(),
597                        take_value(tokens, &mut index, inline_value, name, command_path)?,
598                        name,
599                        command_path,
600                    )?));
601                }
602                "--stderr-file" => {
603                    parsed.output.stderr_file = Some(PathBuf::from(set_output_value(
604                        parsed.output.stderr_file.as_ref(),
605                        take_value(tokens, &mut index, inline_value, name, command_path)?,
606                        name,
607                        command_path,
608                    )?));
609                }
610                _ => {
611                    return Err(CliError::new(
612                        CliErrorRule::UnknownArgument,
613                        command_path.to_string(),
614                        format!("unknown argument `{name}`"),
615                    ));
616                }
617            }
618            index += 1;
619            continue;
620        }
621        if !options_done && token.starts_with('-') && token != "-" {
622            return Err(CliError::new(
623                CliErrorRule::UnknownArgument,
624                command_path.to_string(),
625                format!("unknown argument `{token}`"),
626            ));
627        }
628        let Some(argument) = positionals.get(positional_index).copied() else {
629            return Err(CliError::new(
630                CliErrorRule::UnexpectedPositional,
631                command_path.to_string(),
632                "unexpected positional argument",
633            ));
634        };
635        let value = parse_value(argument, token).map_err(|message| {
636            invalid_value(command_path, argument.argument_id.clone(), &message)
637        })?;
638        insert_application(
639            &mut parsed,
640            argument,
641            value,
642            argument.argument_id.clone(),
643            command_path,
644        )?;
645        if !argument.repeatable {
646            positional_index += 1;
647        }
648        index += 1;
649    }
650    Ok(parsed)
651}
652
653fn take_value<'a>(
654    tokens: &'a [String],
655    index: &mut usize,
656    inline_value: Option<&'a str>,
657    name: &str,
658    command_path: &str,
659) -> Result<&'a str, CliError> {
660    if let Some(value) = inline_value {
661        if value.is_empty() {
662            return Err(missing_value(command_path, name));
663        }
664        return Ok(value);
665    }
666    let Some(value) = tokens.get(*index + 1) else {
667        return Err(missing_value(command_path, name));
668    };
669    if value.starts_with('-') && value != "-" {
670        return Err(missing_value(command_path, name));
671    }
672    *index += 1;
673    Ok(value)
674}
675
676fn reject_inline_value(
677    inline_value: Option<&str>,
678    name: &str,
679    command_path: &str,
680) -> Result<(), CliError> {
681    if inline_value.is_some() {
682        return Err(invalid_value(
683            command_path,
684            name.to_string(),
685            "control flags do not accept values",
686        ));
687    }
688    Ok(())
689}
690
691fn set_once(value: &mut bool, name: &str, command_path: &str) -> Result<(), CliError> {
692    if *value {
693        return Err(duplicate_error(command_path, name));
694    }
695    *value = true;
696    Ok(())
697}
698
699fn set_output_value<T>(
700    existing: Option<&T>,
701    value: &str,
702    name: &str,
703    command_path: &str,
704) -> Result<String, CliError> {
705    if existing.is_some() {
706        return Err(duplicate_error(command_path, name));
707    }
708    Ok(value.to_string())
709}
710
711fn insert_application(
712    parsed: &mut ParsedArgs,
713    argument: &ArgSpec,
714    value: CliValue,
715    display: String,
716    command_path: &str,
717) -> Result<(), CliError> {
718    let values = parsed
719        .application_values
720        .entry(argument.argument_id.clone())
721        .or_default();
722    if !argument.repeatable && !values.is_empty() {
723        return Err(duplicate_error(command_path, &display));
724    }
725    values.push(value);
726    parsed
727        .explicit_application_ids
728        .insert(argument.argument_id.clone());
729    Ok(())
730}
731
732fn parse_value(argument: &ArgSpec, raw: &str) -> Result<CliValue, String> {
733    match argument.value_type {
734        ArgValueType::Flag => Ok(CliValue::Bool(true)),
735        ArgValueType::String | ArgValueType::Enum => {
736            if argument.value_type == ArgValueType::Enum
737                && !argument.enum_values.iter().any(|value| value == raw)
738            {
739                return Err(format!(
740                    "expected one of {}",
741                    argument.enum_values.join(", ")
742                ));
743            }
744            Ok(CliValue::String(raw.to_string()))
745        }
746        ArgValueType::I64 => raw
747            .parse::<i64>()
748            .map(CliValue::I64)
749            .map_err(|_| "expected an i64 integer".to_string()),
750        ArgValueType::FiniteF64 => raw
751            .parse::<f64>()
752            .ok()
753            .filter(|value| value.is_finite())
754            .map(CliValue::FiniteF64)
755            .ok_or_else(|| "expected a finite f64 number".to_string()),
756        // Validate that `raw` is exactly one JSON value, then keep the source
757        // text. `IgnoredAny` checks the grammar without building a
758        // `serde_json::Value`, so the core never commits to a number
759        // representation on the caller's behalf.
760        ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(raw)
761            .map(|_| CliValue::Json(raw.to_string()))
762            .map_err(|_| "expected one valid JSON value".to_string()),
763    }
764}
765
766pub(super) fn validate_value_type(argument: &ArgSpec, value: &CliValue) -> Result<(), String> {
767    match (&argument.value_type, value) {
768        (ArgValueType::Flag, CliValue::Bool(_))
769        | (ArgValueType::String, CliValue::String(_))
770        | (ArgValueType::I64, CliValue::I64(_))
771        | (ArgValueType::Json, CliValue::Json(_)) => Ok(()),
772        (ArgValueType::FiniteF64, CliValue::FiniteF64(value)) if value.is_finite() => Ok(()),
773        (ArgValueType::Enum, CliValue::String(value)) if argument.enum_values.contains(value) => {
774            Ok(())
775        }
776        _ => Err("value type does not match the argument".to_string()),
777    }
778}
779
780fn combination_matches(
781    command: &CommandSpec,
782    combination: &Combination,
783    parsed: &ParsedArgs,
784) -> bool {
785    let allowed: BTreeSet<&str> = combination
786        .fixed
787        .keys()
788        .map(String::as_str)
789        .chain(combination.required.iter().map(String::as_str))
790        .chain(combination.optional.iter().map(String::as_str))
791        .collect();
792    if parsed
793        .explicit_application_ids
794        .iter()
795        .any(|id| !allowed.contains(id.as_str()))
796        || combination
797            .required
798            .iter()
799            .any(|id| !parsed.explicit_application_ids.contains(id))
800    {
801        return false;
802    }
803    combination.fixed.iter().all(|(id, fixed)| {
804        let argument = command
805            .arguments
806            .iter()
807            .find(|argument| argument.argument_id == *id);
808        let effective = parsed
809            .application_values
810            .get(id)
811            .and_then(|values| values.first())
812            .or_else(|| argument.and_then(|argument| argument.default.as_ref()));
813        effective
814            .and_then(CliValue::as_str)
815            .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
816    })
817}
818
819fn project_values(
820    command: &CommandSpec,
821    combination: &Combination,
822    parsed: &ParsedArgs,
823) -> BTreeMap<String, CliValue> {
824    let allowed: BTreeSet<&str> = combination
825        .fixed
826        .keys()
827        .map(String::as_str)
828        .chain(combination.required.iter().map(String::as_str))
829        .chain(combination.optional.iter().map(String::as_str))
830        .collect();
831    command
832        .arguments
833        .iter()
834        .filter(|argument| allowed.contains(argument.argument_id.as_str()))
835        .filter_map(|argument| {
836            let value = parsed
837                .application_values
838                .get(&argument.argument_id)
839                .map(|values| {
840                    if argument.repeatable {
841                        CliValue::List(values.clone())
842                    } else {
843                        values[0].clone()
844                    }
845                })
846                .or_else(|| argument.default.clone())
847                .or_else(|| {
848                    (argument.value_type == ArgValueType::Flag).then_some(CliValue::Bool(false))
849                });
850            value.map(|value| (argument.argument_id.clone(), value))
851        })
852        .collect()
853}
854
855fn resolve_output(
856    spec: &OutputSpec,
857    parsed: &ParsedOutput,
858    command_path: &str,
859) -> Result<OutputPlan, CliError> {
860    match spec {
861        OutputSpec::Raw { file_sinks } => {
862            if parsed.format.is_some() || parsed.destination.is_some() {
863                return Err(CliError::unregistered(command_path.to_string()));
864            }
865            ensure_sinks(file_sinks, parsed, command_path)?;
866            Ok(OutputPlan::Raw {
867                stdout_file: parsed.stdout_file.clone(),
868                stderr_file: parsed.stderr_file.clone(),
869            })
870        }
871        OutputSpec::Protocol {
872            lifecycle,
873            formats,
874            destinations,
875            default_format,
876            default_destination,
877            file_sinks,
878        } => {
879            ensure_sinks(file_sinks, parsed, command_path)?;
880            let format = parsed.format.as_ref().unwrap_or(default_format);
881            if !formats.contains(format) {
882                return Err(invalid_value(
883                    command_path,
884                    "--output".to_string(),
885                    &format!("expected one of {}", formats.join(", ")),
886                ));
887            }
888            let destination = parsed.destination.as_ref().unwrap_or(default_destination);
889            if !destinations.contains(destination) {
890                return Err(invalid_value(
891                    command_path,
892                    "--output-to".to_string(),
893                    &format!("expected one of {}", destinations.join(", ")),
894                ));
895            }
896            Ok(OutputPlan::Protocol {
897                lifecycle: *lifecycle,
898                format: format.clone(),
899                destination: destination.clone(),
900                stdout_file: parsed.stdout_file.clone(),
901                stderr_file: parsed.stderr_file.clone(),
902            })
903        }
904    }
905}
906
907fn ensure_sinks(
908    allowed: &[String],
909    parsed: &ParsedOutput,
910    command_path: &str,
911) -> Result<(), CliError> {
912    if (parsed.stdout_file.is_some() && !allowed.iter().any(|sink| sink == "stdout"))
913        || (parsed.stderr_file.is_some() && !allowed.iter().any(|sink| sink == "stderr"))
914    {
915        return Err(CliError::unregistered(command_path.to_string()));
916    }
917    Ok(())
918}
919
920fn synthetic_value(argument: &ArgSpec) -> String {
921    match argument.value_type {
922        ArgValueType::Flag => String::new(),
923        ArgValueType::String => {
924            if argument.sensitive {
925                "synthetic-sensitive".to_string()
926            } else {
927                "value".to_string()
928            }
929        }
930        ArgValueType::I64 => "1".to_string(),
931        ArgValueType::FiniteF64 => "1.5".to_string(),
932        ArgValueType::Enum => argument.enum_values.first().cloned().unwrap_or_default(),
933        ArgValueType::Json => "{}".to_string(),
934    }
935}
936
937fn append_synthetic_argument(argv: &mut Vec<String>, argument: &ArgSpec, value: &str) {
938    match &argument.syntax {
939        ArgSyntax::Long { name } => {
940            argv.push(name.clone());
941            if argument.value_type != ArgValueType::Flag {
942                argv.push(value.to_string());
943            }
944        }
945        ArgSyntax::Positional { .. } => argv.push(value.to_string()),
946    }
947}