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