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(
143            command,
144            &argv[consumed..],
145            &command_path,
146            &self.spec.commands,
147        )?;
148
149        if parsed.control_count() > 0 {
150            if parsed.control_count() != 1 || !parsed.application_values.is_empty() {
151                return Err(CliError::unregistered(command_path));
152            }
153            // `--docs` renders the whole registry, which is raw bytes, not
154            // protocol events. It therefore gets its own contract instead of
155            // `lifecycle_output`, and must be settled before the shared
156            // protocol plan below would wrongly accept `--output`.
157            if parsed.docs {
158                // Root-only, like `--version` below: past the command path the
159                // spelling belongs to the application, and where it declared
160                // one, `tokenize` bound the token to that argument and never
161                // set this flag at all.
162                if !command.command_path.is_empty() {
163                    return Err(CliError::unregistered(command_path));
164                }
165                let contract = OutputSpec::raw()
166                    .file_sinks(self.spec.lifecycle_output.file_sinks_ref().to_vec());
167                let output = resolve_output(&contract, &parsed.output, &command_path)?;
168                return Ok(CliOutcome::Docs(ResolvedDocs { output }));
169            }
170            let output =
171                resolve_output(&self.spec.lifecycle_output, &parsed.output, &command_path)?;
172            if parsed.help {
173                return Ok(CliOutcome::Help(ResolvedHelp {
174                    model: self.help_model(command),
175                    output,
176                }));
177            }
178            if parsed.version {
179                if !command.command_path.is_empty() {
180                    return Err(CliError::unregistered(command_path));
181                }
182                return Ok(CliOutcome::Version(ResolvedVersion {
183                    name: self.spec.name.clone(),
184                    version: self.spec.version.clone(),
185                    display_name: self.spec.display_name.clone(),
186                    build: self.spec.build.clone(),
187                    output,
188                }));
189            }
190        }
191
192        let matching: Vec<&Combination> = command
193            .combinations
194            .iter()
195            .filter(|combination| combination_matches(command, combination, &parsed))
196            .collect();
197        let Some(combination) = matching.first().copied() else {
198            return Err(CliError::unregistered(command_path));
199        };
200        if matching.len() != 1 {
201            return Err(CliError::new(
202                CliErrorRule::UnregisteredCombination,
203                command_path,
204                "arguments match more than one registered CLI combination",
205            ));
206        }
207        let output = resolve_output(&combination.output, &parsed.output, &command_path)?;
208        let values = project_values(command, combination, &parsed);
209        Ok(CliOutcome::Run(ResolvedInvocation {
210            command_path: command.command_path.clone(),
211            action_id: combination.action_id.clone(),
212            combination_id: combination.combination_id.clone(),
213            values,
214            explicit_argument_ids: parsed.explicit_application_ids,
215            output,
216            strict_reads: false,
217        }))
218    }
219
220    fn select_command<'a>(&'a self, argv: &[String]) -> Result<(&'a CommandSpec, usize), CliError> {
221        let mut commands: Vec<&CommandSpec> = self.spec.commands.iter().collect();
222        commands.sort_by_key(|command| std::cmp::Reverse(command.command_path.len()));
223        if let Some(command) = commands.iter().copied().find(|command| {
224            command.command_path.len() <= argv.len()
225                && command
226                    .command_path
227                    .iter()
228                    .zip(argv)
229                    .all(|(expected, actual)| expected == actual)
230        }) {
231            let remaining = &argv[command.command_path.len()..];
232            let has_children = self.spec.commands.iter().any(|candidate| {
233                candidate.command_path.len() > command.command_path.len()
234                    && candidate.command_path.starts_with(&command.command_path)
235            });
236            if remaining
237                .first()
238                .is_some_and(|token| !token.starts_with('-'))
239                && has_children
240            {
241                return Err(CliError::new(
242                    CliErrorRule::UnknownCommand,
243                    self.display_command_path(command),
244                    "unknown command",
245                ));
246            }
247            return Ok((command, command.command_path.len()));
248        }
249        Err(CliError::new(
250            CliErrorRule::UnknownCommand,
251            self.spec.name.clone(),
252            "unknown command",
253        ))
254    }
255
256    fn display_command_path(&self, command: &CommandSpec) -> String {
257        std::iter::once(self.spec.name.as_str())
258            .chain(command.command_path.iter().map(String::as_str))
259            .collect::<Vec<_>>()
260            .join(" ")
261    }
262
263    fn child_help_commands(&self, command: &CommandSpec) -> Vec<String> {
264        let mut children: Vec<Vec<String>> = self
265            .spec
266            .commands
267            .iter()
268            .filter(|candidate| {
269                candidate.command_path.len() == command.command_path.len() + 1
270                    && candidate.command_path.starts_with(&command.command_path)
271            })
272            .map(|candidate| candidate.command_path.clone())
273            .collect();
274        children.sort();
275        children
276            .into_iter()
277            .map(|path| format!("{} {} --help", self.spec.name, path.join(" ")))
278            .collect()
279    }
280
281    fn help_model(&self, command: &CommandSpec) -> CliHelpV2 {
282        let command_path = self.display_command_path(command);
283        let mut shapes = Vec::new();
284        let mut notes = BTreeMap::new();
285        let mut defaults = BTreeMap::new();
286        for combination in &command.combinations {
287            let (usage, shape_notes, shape_defaults) =
288                combination_usage(command, combination, &command_path, true);
289            shapes.push(CliShape {
290                id: combination.combination_id.clone(),
291                // With one shape there is nothing to tell apart, so the
292                // command's own description already covers it.
293                about: if command.combinations.len() == 1 {
294                    None
295                } else {
296                    combination.about.clone()
297                },
298                usage,
299            });
300            // Arguments belong to the command, so a note or default reached
301            // through any shape is the same fact; collecting them once keeps
302            // the response from repeating itself per shape.
303            notes.extend(shape_notes);
304            defaults.extend(shape_defaults);
305        }
306        CliHelpV2 {
307            schema: "cli-help-v2".to_string(),
308            command_path,
309            // The root command has no description of its own — the registry's
310            // does double duty, and without this fallback an agent's first
311            // discovery call learns every subcommand but never what the tool is.
312            about: command.about.clone().or_else(|| {
313                command
314                    .command_path
315                    .is_empty()
316                    .then(|| self.spec.about.clone())
317                    .flatten()
318            }),
319            shapes,
320            subcommands: self.child_help_commands(command),
321            notes,
322            defaults,
323        }
324    }
325}
326
327/// Type-correct argv generated from a registered shape.
328#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct SyntheticInvocation {
330    pub command_path: Vec<String>,
331    pub combination_id: String,
332    pub argv: Vec<String>,
333}
334
335/// A built registry with exactly one handler per action.
336pub struct BoundCliSpec<R> {
337    cli: BuiltCliSpec,
338    handlers: BTreeMap<String, fn(&ResolvedInvocation) -> R>,
339}
340
341impl<R> BoundCliSpec<R> {
342    /// Resolve argv against this registry, binding the run branch to its
343    /// handler as it goes.
344    ///
345    /// The handler is attached here, where `bind_actions` has already proved
346    /// one exists for every action, which is what makes
347    /// [`BoundInvocation::run`] infallible. Resolving through the registry that
348    /// owns the handlers also removes the possibility of dispatching an
349    /// invocation from a *different* registry: there is no longer a step that
350    /// takes one.
351    pub fn resolve_from<I, S>(&self, args: I) -> Result<BoundOutcome<R>, CliError>
352    where
353        I: IntoIterator<Item = S>,
354        S: Into<OsString>,
355    {
356        Ok(match self.cli.resolve_from(args)? {
357            CliOutcome::Run(invocation) => BoundOutcome::Run(self.bind(invocation)),
358            CliOutcome::Help(help) => BoundOutcome::Help(help),
359            CliOutcome::Version(version) => BoundOutcome::Version(version),
360            CliOutcome::Docs(docs) => BoundOutcome::Docs(docs),
361        })
362    }
363
364    /// Call every declared combination's handler with strict argument reads,
365    /// returning each combination id with what its handler produced.
366    ///
367    /// **This runs your handlers.** Every one of them, on synthetic argv, in
368    /// whatever order the registry declares them. Use it only where a handler
369    /// is a pure projection of argv into a command value. Where the handler
370    /// *is* the command, this will do whatever the command does — bind a port
371    /// and never return, open a terminal, write files, or uninstall something.
372    /// The name of this method is not a promise that it is safe to call; the
373    /// shape of your handlers is.
374    ///
375    /// Where it does fit, it is the other half of
376    /// [`ResolvedInvocation::required`] being infallible. A handler that reads
377    /// an argument id its combination does not declare gets a failed read in
378    /// production — silent, and exactly the class of typo no compiler catches.
379    /// Here it panics naming the combination and the id, driven by the same
380    /// synthetic invocations [`BuiltCliSpec::synthetic_invocations`] generates
381    /// for help and docs. Production code carries no branch for a case it
382    /// cannot reach, and the case is still caught before it ships.
383    ///
384    /// If your handlers are not callable here, that is worth reading as a
385    /// design signal rather than a limitation of this method: separating "parse
386    /// argv into a typed command" from "carry the command out" makes the check
387    /// available and is the better shape independently.
388    ///
389    /// The results are returned rather than discarded because a handler that
390    /// returns a `Result` has a second thing worth checking — that every
391    /// combination actually *builds* — and a caller that had to write its own
392    /// loop for that would be back to hand-rolling the half this replaces.
393    /// Ignore the return when the handler's output says nothing useful.
394    // This method exists to fail loudly from a test; diverging is the whole
395    // point, and the doc above says so. The allow records that rather than
396    // reshaping a deliberate abort into a value nobody can act on.
397    #[allow(clippy::panic)]
398    pub fn call_every_combination(&self) -> Vec<(String, R)> {
399        let mut results = Vec::new();
400        for fixture in self.cli.synthetic_invocations() {
401            // A fixture that does not resolve to a run is a registry defect, and
402            // skipping it would let this method report success while covering
403            // nothing — the one failure a check like this must not have.
404            let outcome = self.cli.resolve_from(fixture.argv.clone());
405            let Ok(CliOutcome::Run(mut invocation)) = outcome else {
406                panic!(
407                    "combination `{}` generated argv {:?}, which does not resolve to a run",
408                    fixture.combination_id, fixture.argv
409                );
410            };
411            invocation.strict_reads = true;
412            let combination = invocation.combination_id.clone();
413            results.push((combination, self.bind(invocation).run()));
414        }
415        assert!(
416            !results.is_empty(),
417            "no combination was called; a registry with no runnable combination cannot be verified"
418        );
419        results
420    }
421
422    fn bind(&self, invocation: ResolvedInvocation) -> BoundInvocation<R> {
423        // `bind_actions` rejected any registry whose action ids and handler ids
424        // differ, and `action_id` can only come from a combination in that same
425        // registry. The allow records that proof once, here, instead of making
426        // every caller carry a branch for it.
427        #[allow(clippy::expect_used)]
428        let handler = *self
429            .handlers
430            .get(invocation.action_id())
431            .expect("bind_actions guarantees one handler per action id");
432        BoundInvocation {
433            invocation,
434            handler,
435        }
436    }
437}
438
439/// What an argv resolved to, with the run branch already bound to its handler.
440pub enum BoundOutcome<R> {
441    Run(BoundInvocation<R>),
442    Help(ResolvedHelp),
443    Version(ResolvedVersion),
444    Docs(ResolvedDocs),
445}
446
447impl<R> std::fmt::Debug for BoundOutcome<R> {
448    /// Written by hand so it does not require `R: Debug`: `R` is whatever the
449    /// application's handlers return, and demanding `Debug` of it would make
450    /// this enum unusable in a test that wants to assert on the outcome.
451    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452        match self {
453            Self::Run(invocation) => formatter.debug_tuple("Run").field(invocation).finish(),
454            Self::Help(help) => formatter.debug_tuple("Help").field(help).finish(),
455            Self::Version(version) => formatter.debug_tuple("Version").field(version).finish(),
456            Self::Docs(docs) => formatter.debug_tuple("Docs").field(docs).finish(),
457        }
458    }
459}
460
461/// A resolved invocation together with the handler that will run it.
462///
463/// Running cannot fail: the handler was looked up at resolution, by the
464/// registry that owns it.
465pub struct BoundInvocation<R> {
466    invocation: ResolvedInvocation,
467    handler: fn(&ResolvedInvocation) -> R,
468}
469
470impl<R> BoundInvocation<R> {
471    /// The output contract, readable before the handler runs so a caller can
472    /// install redirection first.
473    pub fn output_plan(&self) -> &OutputPlan {
474        self.invocation.output_plan()
475    }
476
477    /// The resolved invocation, for a caller that needs more than the plan.
478    pub fn invocation(&self) -> &ResolvedInvocation {
479        &self.invocation
480    }
481
482    /// Run the bound handler.
483    pub fn run(self) -> R {
484        (self.handler)(&self.invocation)
485    }
486
487    /// Run the bound handler and give the invocation back.
488    ///
489    /// For a caller that still needs the resolved values afterwards — a command
490    /// path, or globals read once the handler has produced its command. Without
491    /// this the only way to keep them past [`run`](Self::run) is to clone the
492    /// value maps before calling it.
493    pub fn run_with_invocation(self) -> (R, ResolvedInvocation) {
494        let result = (self.handler)(&self.invocation);
495        (result, self.invocation)
496    }
497}
498
499impl<R> std::fmt::Debug for BoundInvocation<R> {
500    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501        formatter
502            .debug_struct("BoundInvocation")
503            .field("invocation", &self.invocation)
504            .finish_non_exhaustive()
505    }
506}
507
508/// What an argv resolved to.
509#[derive(Clone, Debug, PartialEq)]
510pub enum CliOutcome {
511    Run(ResolvedInvocation),
512    Help(ResolvedHelp),
513    Version(ResolvedVersion),
514    Docs(ResolvedDocs),
515}
516
517/// One legal invocation, projected onto the shape that matched it.
518#[derive(Clone, Debug, PartialEq)]
519pub struct ResolvedInvocation {
520    pub(super) command_path: Vec<String>,
521    pub(super) action_id: String,
522    pub(super) combination_id: String,
523    pub(super) values: BTreeMap<String, CliValue>,
524    pub(super) explicit_argument_ids: BTreeSet<String>,
525    pub(super) output: OutputPlan,
526    /// Set only by [`BoundCliSpec::call_every_combination`]. Reading an argument id
527    /// the combination does not declare is a defect in the handler, and this is
528    /// the mode that says so instead of handing back a default.
529    pub(super) strict_reads: bool,
530}
531
532/// Stands in for an argument the selected combination does not declare.
533///
534/// Reads of it simply fail their type check, so a handler bug degrades to a
535/// failed read rather than a plausible value.
536const MISSING: CliValue = CliValue::Bool(false);
537
538impl ResolvedInvocation {
539    pub fn command_path(&self) -> &[String] {
540        &self.command_path
541    }
542
543    pub fn action_id(&self) -> &str {
544        &self.action_id
545    }
546
547    pub fn combination_id(&self) -> &str {
548        &self.combination_id
549    }
550
551    pub fn output_plan(&self) -> &OutputPlan {
552        &self.output
553    }
554
555    /// Whether the caller wrote this argument, as opposed to inheriting it
556    /// from the shape's fixed value or the argument's default.
557    pub fn was_explicit(&self, argument_id: &str) -> bool {
558        self.explicit_argument_ids.contains(argument_id)
559    }
560
561    pub fn optional(&self, argument_id: &str) -> Option<&CliValue> {
562        self.values.get(argument_id)
563    }
564
565    /// Read a value the selected combination declares as required or fixed.
566    ///
567    /// Infallible by construction: resolution has already proved the selected
568    /// combination supplies every id it declares. Asking for an id it does not
569    /// declare is a defect in the handler, not a runtime condition, so this
570    /// does not make every caller branch on a case a correct program cannot
571    /// reach — [`BoundCliSpec::call_every_combination`] is where that defect
572    /// surfaces, from a test, naming the combination and the id.
573    pub fn required(&self, argument_id: &str) -> &CliValue {
574        match self.values.get(argument_id) {
575            Some(value) => value,
576            None => {
577                assert!(
578                    !self.strict_reads,
579                    "combination `{}` does not declare argument id `{argument_id}`",
580                    self.combination_id
581                );
582                // Fails every typed accessor rather than reading as a valid
583                // flag, so a miss that escapes verification still cannot be
584                // mistaken for a real value.
585                &MISSING
586            }
587        }
588    }
589
590    pub fn repeated(&self, argument_id: &str) -> &[CliValue] {
591        self.values
592            .get(argument_id)
593            .and_then(CliValue::as_list)
594            .unwrap_or(&[])
595    }
596}
597
598/// Where a resolved call's output goes, and in what form.
599#[derive(Clone, Debug, PartialEq, Eq)]
600pub enum OutputPlan {
601    Raw {
602        stdout_file: Option<PathBuf>,
603        stderr_file: Option<PathBuf>,
604    },
605    Protocol {
606        lifecycle: OutputLifecycle,
607        format: OutputFormat,
608        destination: OutputTo,
609        stdout_file: Option<PathBuf>,
610        stderr_file: Option<PathBuf>,
611    },
612}
613
614impl OutputPlan {
615    /// Structured output format, or `None` for a raw-byte command.
616    pub const fn output_format(&self) -> Option<OutputFormat> {
617        match self {
618            Self::Raw { .. } => None,
619            Self::Protocol { format, .. } => Some(*format),
620        }
621    }
622
623    /// Structured output routing, or `None` for a raw-byte command.
624    pub const fn output_to(&self) -> Option<OutputTo> {
625        match self {
626            Self::Raw { .. } => None,
627            Self::Protocol { destination, .. } => Some(*destination),
628        }
629    }
630
631    /// Canonical format spelling retained for string-oriented callers.
632    pub fn format(&self) -> Option<&str> {
633        self.output_format().map(OutputFormat::as_str)
634    }
635
636    /// Canonical destination spelling retained for string-oriented callers.
637    pub fn destination(&self) -> Option<&str> {
638        self.output_to().map(OutputTo::as_str)
639    }
640
641    pub fn stdout_file(&self) -> Option<&Path> {
642        match self {
643            Self::Raw { stdout_file, .. } | Self::Protocol { stdout_file, .. } => {
644                stdout_file.as_deref()
645            }
646        }
647    }
648
649    pub fn stderr_file(&self) -> Option<&Path> {
650        match self {
651            Self::Raw { stderr_file, .. } | Self::Protocol { stderr_file, .. } => {
652                stderr_file.as_deref()
653            }
654        }
655    }
656}
657
658#[derive(Default)]
659struct ParsedArgs {
660    application_values: BTreeMap<String, Vec<CliValue>>,
661    explicit_application_ids: BTreeSet<String>,
662    output: ParsedOutput,
663    help: bool,
664    version: bool,
665    docs: bool,
666}
667
668impl ParsedArgs {
669    fn control_count(&self) -> usize {
670        usize::from(self.help) + usize::from(self.version) + usize::from(self.docs)
671    }
672}
673
674#[derive(Default)]
675struct ParsedOutput {
676    format: Option<String>,
677    destination: Option<String>,
678    stdout_file: Option<PathBuf>,
679    stderr_file: Option<PathBuf>,
680}
681
682fn tokenize(
683    command: &CommandSpec,
684    tokens: &[String],
685    command_path: &str,
686    all_commands: &[CommandSpec],
687) -> Result<ParsedArgs, CliError> {
688    let longs: BTreeMap<&str, &ArgSpec> = command
689        .arguments
690        .iter()
691        .filter_map(|argument| match &argument.syntax {
692            ArgSyntax::Long { name } => Some((name.as_str(), argument)),
693            ArgSyntax::Positional { .. } => None,
694        })
695        .collect();
696    let mut positionals: Vec<&ArgSpec> = command
697        .arguments
698        .iter()
699        .filter(|argument| matches!(argument.syntax, ArgSyntax::Positional { .. }))
700        .collect();
701    positionals.sort_by_key(|argument| match argument.syntax {
702        ArgSyntax::Positional { index } => index,
703        ArgSyntax::Long { .. } => usize::MAX,
704    });
705
706    let mut parsed = ParsedArgs::default();
707    let mut index = 0;
708    let mut positional_index = 0;
709    let mut options_done = false;
710    while index < tokens.len() {
711        let token = &tokens[index];
712        if !options_done && token == "--" {
713            options_done = true;
714            index += 1;
715            continue;
716        }
717        if !options_done && token.starts_with("--") {
718            let (name, inline_value) = token
719                .split_once('=')
720                .map_or((token.as_str(), None), |(name, value)| (name, Some(value)));
721            if let Some(argument) = longs.get(name).copied() {
722                let display = name.to_string();
723                let raw_value = if argument.value_type == ArgValueType::Flag {
724                    if inline_value.is_some() {
725                        return Err(invalid_value(
726                            command_path,
727                            display,
728                            "flags do not accept values",
729                        ));
730                    }
731                    None
732                } else {
733                    Some(take_value(
734                        tokens,
735                        &mut index,
736                        inline_value,
737                        Some(argument),
738                        name,
739                        command_path,
740                    )?)
741                };
742                let value = match raw_value {
743                    Some(value) => parse_value(argument, value).map_err(|message| {
744                        invalid_value(command_path, display.clone(), &message)
745                    })?,
746                    None => CliValue::Bool(true),
747                };
748                insert_application(&mut parsed, argument, value, display, command_path)?;
749                index += 1;
750                continue;
751            }
752            match name {
753                "--help" => {
754                    reject_inline_value(inline_value, name, command_path)?;
755                    set_once(&mut parsed.help, name, command_path)?;
756                }
757                "--version" => {
758                    reject_inline_value(inline_value, name, command_path)?;
759                    set_once(&mut parsed.version, name, command_path)?;
760                }
761                "--docs" => {
762                    reject_inline_value(inline_value, name, command_path)?;
763                    set_once(&mut parsed.docs, name, command_path)?;
764                }
765                "--output" => {
766                    parsed.output.format = Some(set_output_value(
767                        parsed.output.format.as_ref(),
768                        take_value(tokens, &mut index, inline_value, None, name, command_path)?,
769                        name,
770                        command_path,
771                    )?);
772                }
773                "--output-to" => {
774                    parsed.output.destination = Some(set_output_value(
775                        parsed.output.destination.as_ref(),
776                        take_value(tokens, &mut index, inline_value, None, name, command_path)?,
777                        name,
778                        command_path,
779                    )?);
780                }
781                "--stdout-file" => {
782                    parsed.output.stdout_file = Some(PathBuf::from(set_output_value(
783                        parsed.output.stdout_file.as_ref(),
784                        take_value(tokens, &mut index, inline_value, None, name, command_path)?,
785                        name,
786                        command_path,
787                    )?));
788                }
789                "--stderr-file" => {
790                    parsed.output.stderr_file = Some(PathBuf::from(set_output_value(
791                        parsed.output.stderr_file.as_ref(),
792                        take_value(tokens, &mut index, inline_value, None, name, command_path)?,
793                        name,
794                        command_path,
795                    )?));
796                }
797                _ => {
798                    return Err(CliError::new(
799                        CliErrorRule::UnknownArgument,
800                        command_path.to_string(),
801                        format!("unknown argument `{name}`"),
802                    ));
803                }
804            }
805            index += 1;
806            continue;
807        }
808        if !options_done && token.starts_with('-') && token != "-" {
809            let positional_accepts_value = positionals
810                .get(positional_index)
811                .is_some_and(|argument| accepts_hyphen_prefixed_value(argument, token));
812            if !positional_accepts_value {
813                return Err(CliError::new(
814                    CliErrorRule::UnknownArgument,
815                    command_path.to_string(),
816                    "unknown short argument",
817                ));
818            }
819        }
820        let Some(argument) = positionals.get(positional_index).copied() else {
821            // A registered command name here is almost always a caller who put
822            // the command after its arguments. Saying only "unexpected
823            // positional argument" is true and useless: the fix is an ordering
824            // one, and nothing else in the message would suggest it.
825            let message = if is_registered_command_segment(token, all_commands) {
826                "command name must come before its arguments"
827            } else {
828                "unexpected positional argument"
829            };
830            return Err(CliError::new(
831                CliErrorRule::UnexpectedPositional,
832                command_path.to_string(),
833                message,
834            ));
835        };
836        let value = parse_value(argument, token).map_err(|message| {
837            invalid_value(command_path, argument.argument_id.clone(), &message)
838        })?;
839        insert_application(
840            &mut parsed,
841            argument,
842            value,
843            argument.argument_id.clone(),
844            command_path,
845        )?;
846        if !argument.repeatable {
847            positional_index += 1;
848        }
849        index += 1;
850    }
851    Ok(parsed)
852}
853
854fn take_value<'a>(
855    tokens: &'a [String],
856    index: &mut usize,
857    inline_value: Option<&'a str>,
858    argument: Option<&ArgSpec>,
859    name: &str,
860    command_path: &str,
861) -> Result<&'a str, CliError> {
862    if let Some(value) = inline_value {
863        if value.is_empty() {
864            return Err(missing_value(command_path, name));
865        }
866        return Ok(value);
867    }
868    let Some(value) = tokens.get(*index + 1) else {
869        return Err(missing_value(command_path, name));
870    };
871    if value.starts_with('-')
872        && value != "-"
873        && !argument.is_some_and(|argument| accepts_hyphen_prefixed_value(argument, value))
874    {
875        return Err(missing_value(command_path, name));
876    }
877    *index += 1;
878    Ok(value)
879}
880
881/// Whether a `-`-prefixed token is this argument's value rather than a flag.
882///
883/// Deliberately a *syntax* question, not a validity one. Asking `parse_value`
884/// would fold semantic constraints in, so `--limit -1` against a `1..=1000`
885/// range would be read as "no value supplied" — reporting a missing argument
886/// for a value the caller plainly typed. Range and enum membership are checked
887/// after the token is claimed, where they can say what is actually wrong.
888fn accepts_hyphen_prefixed_value(argument: &ArgSpec, value: &str) -> bool {
889    match argument.value_type {
890        ArgValueType::I64 => value.parse::<i64>().is_ok(),
891        ArgValueType::FiniteF64 => value.parse::<f64>().is_ok_and(f64::is_finite),
892        ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(value).is_ok(),
893        _ => false,
894    }
895}
896
897fn reject_inline_value(
898    inline_value: Option<&str>,
899    name: &str,
900    command_path: &str,
901) -> Result<(), CliError> {
902    if inline_value.is_some() {
903        return Err(invalid_value(
904            command_path,
905            name.to_string(),
906            "control flags do not accept values",
907        ));
908    }
909    Ok(())
910}
911
912fn set_once(value: &mut bool, name: &str, command_path: &str) -> Result<(), CliError> {
913    if *value {
914        return Err(duplicate_error(command_path, name));
915    }
916    *value = true;
917    Ok(())
918}
919
920fn set_output_value<T>(
921    existing: Option<&T>,
922    value: &str,
923    name: &str,
924    command_path: &str,
925) -> Result<String, CliError> {
926    if existing.is_some() {
927        return Err(duplicate_error(command_path, name));
928    }
929    Ok(value.to_string())
930}
931
932fn insert_application(
933    parsed: &mut ParsedArgs,
934    argument: &ArgSpec,
935    value: CliValue,
936    display: String,
937    command_path: &str,
938) -> Result<(), CliError> {
939    let values = parsed
940        .application_values
941        .entry(argument.argument_id.clone())
942        .or_default();
943    if !argument.repeatable && !values.is_empty() {
944        return Err(duplicate_error(command_path, &display));
945    }
946    values.push(value);
947    parsed
948        .explicit_application_ids
949        .insert(argument.argument_id.clone());
950    Ok(())
951}
952
953fn parse_value(argument: &ArgSpec, raw: &str) -> Result<CliValue, String> {
954    match argument.value_type {
955        ArgValueType::Flag => Ok(CliValue::Bool(true)),
956        ArgValueType::String | ArgValueType::Enum => {
957            if argument.value_type == ArgValueType::Enum
958                && !argument.enum_values.iter().any(|value| value == raw)
959            {
960                return Err(format!(
961                    "expected one of {}",
962                    argument.enum_values.join(", ")
963                ));
964            }
965            Ok(CliValue::String(raw.to_string()))
966        }
967        ArgValueType::I64 => {
968            let value = raw
969                .parse::<i64>()
970                .map_err(|_| "expected an i64 integer".to_string())?;
971            if let Some([minimum, maximum]) = argument.range
972                && !(minimum..=maximum).contains(&value)
973            {
974                return Err(format!("expected an integer in {minimum}..={maximum}"));
975            }
976            Ok(CliValue::I64(value))
977        }
978        ArgValueType::Uuid => {
979            if is_canonical_uuid(raw) {
980                Ok(CliValue::String(raw.to_string()))
981            } else {
982                Err("expected a UUID (8-4-4-4-12 hexadecimal digits)".to_string())
983            }
984        }
985        ArgValueType::FiniteF64 => raw
986            .parse::<f64>()
987            .ok()
988            .filter(|value| value.is_finite())
989            .map(CliValue::FiniteF64)
990            .ok_or_else(|| "expected a finite f64 number".to_string()),
991        // Validate that `raw` is exactly one JSON value, then keep the source
992        // text. `IgnoredAny` checks the grammar without building a
993        // `serde_json::Value`, so the core never commits to a number
994        // representation on the caller's behalf.
995        ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(raw)
996            .map(|_| CliValue::Json(raw.to_string()))
997            .map_err(|_| "expected one valid JSON value".to_string()),
998    }
999}
1000
1001pub(super) fn validate_value_type(argument: &ArgSpec, value: &CliValue) -> Result<(), String> {
1002    match (&argument.value_type, value) {
1003        (ArgValueType::Flag, CliValue::Bool(_))
1004        | (ArgValueType::String, CliValue::String(_))
1005        | (ArgValueType::Json, CliValue::Json(_)) => Ok(()),
1006        // A default or fixed value is held to the same range as one typed on
1007        // the command line. Checking only the type would let a registry ship a
1008        // default its own argument rejects — a contradiction nothing downstream
1009        // could report, because the value never passes through the parser.
1010        (ArgValueType::I64, CliValue::I64(number)) => match argument.range {
1011            Some([minimum, maximum]) if !(minimum..=maximum).contains(number) => Err(format!(
1012                "value {number} is outside the argument's {minimum}..={maximum} range"
1013            )),
1014            _ => Ok(()),
1015        },
1016        (ArgValueType::Uuid, CliValue::String(text)) if is_canonical_uuid(text) => Ok(()),
1017        (ArgValueType::Uuid, CliValue::String(_)) => {
1018            Err("value is not a UUID (8-4-4-4-12 hexadecimal digits)".to_string())
1019        }
1020        (ArgValueType::FiniteF64, CliValue::FiniteF64(value)) if value.is_finite() => Ok(()),
1021        (ArgValueType::Enum, CliValue::String(value)) if argument.enum_values.contains(value) => {
1022            Ok(())
1023        }
1024        _ => Err("value type does not match the argument".to_string()),
1025    }
1026}
1027
1028fn combination_matches(
1029    command: &CommandSpec,
1030    combination: &Combination,
1031    parsed: &ParsedArgs,
1032) -> bool {
1033    let allowed: BTreeSet<&str> = combination
1034        .fixed
1035        .keys()
1036        .map(String::as_str)
1037        .chain(combination.required.iter().map(String::as_str))
1038        .chain(combination.optional.iter().map(String::as_str))
1039        .collect();
1040    if parsed
1041        .explicit_application_ids
1042        .iter()
1043        .any(|id| !allowed.contains(id.as_str()))
1044        || combination
1045            .required
1046            .iter()
1047            .any(|id| !parsed.explicit_application_ids.contains(id))
1048    {
1049        return false;
1050    }
1051    combination.fixed.iter().all(|(id, fixed)| {
1052        let argument = command
1053            .arguments
1054            .iter()
1055            .find(|argument| argument.argument_id == *id);
1056        let effective = parsed
1057            .application_values
1058            .get(id)
1059            .and_then(|values| values.first())
1060            .or_else(|| argument.and_then(|argument| argument.default.as_ref()));
1061        effective
1062            .and_then(CliValue::as_str)
1063            .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
1064    })
1065}
1066
1067fn project_values(
1068    command: &CommandSpec,
1069    combination: &Combination,
1070    parsed: &ParsedArgs,
1071) -> BTreeMap<String, CliValue> {
1072    let allowed: BTreeSet<&str> = combination
1073        .fixed
1074        .keys()
1075        .map(String::as_str)
1076        .chain(combination.required.iter().map(String::as_str))
1077        .chain(combination.optional.iter().map(String::as_str))
1078        .collect();
1079    command
1080        .arguments
1081        .iter()
1082        .filter(|argument| allowed.contains(argument.argument_id.as_str()))
1083        .filter_map(|argument| {
1084            let value = parsed
1085                .application_values
1086                .get(&argument.argument_id)
1087                .map(|values| {
1088                    if argument.repeatable {
1089                        CliValue::List(values.clone())
1090                    } else {
1091                        values[0].clone()
1092                    }
1093                })
1094                .or_else(|| argument.default.clone())
1095                .or_else(|| {
1096                    (argument.value_type == ArgValueType::Flag).then_some(CliValue::Bool(false))
1097                });
1098            value.map(|value| (argument.argument_id.clone(), value))
1099        })
1100        .collect()
1101}
1102
1103fn resolve_output(
1104    spec: &OutputSpec,
1105    parsed: &ParsedOutput,
1106    command_path: &str,
1107) -> Result<OutputPlan, CliError> {
1108    match spec {
1109        OutputSpec::Raw { file_sinks } => {
1110            if parsed.format.is_some() || parsed.destination.is_some() {
1111                return Err(CliError::unregistered(command_path.to_string()));
1112            }
1113            ensure_sinks(file_sinks, parsed, command_path)?;
1114            Ok(OutputPlan::Raw {
1115                stdout_file: parsed.stdout_file.clone(),
1116                stderr_file: parsed.stderr_file.clone(),
1117            })
1118        }
1119        OutputSpec::Protocol {
1120            lifecycle,
1121            formats,
1122            destinations,
1123            default_format,
1124            default_destination,
1125            file_sinks,
1126        } => {
1127            ensure_sinks(file_sinks, parsed, command_path)?;
1128            let format = parsed.format.as_ref().unwrap_or(default_format);
1129            if !formats.contains(format) {
1130                return Err(invalid_value(
1131                    command_path,
1132                    "--output".to_string(),
1133                    &format!("expected one of {}", formats.join(", ")),
1134                ));
1135            }
1136            let destination = parsed.destination.as_ref().unwrap_or(default_destination);
1137            if !destinations.contains(destination) {
1138                return Err(invalid_value(
1139                    command_path,
1140                    "--output-to".to_string(),
1141                    &format!("expected one of {}", destinations.join(", ")),
1142                ));
1143            }
1144            let output_format = format.parse::<OutputFormat>().map_err(|_| {
1145                invalid_value(
1146                    command_path,
1147                    "--output".to_string(),
1148                    "expected one of json, yaml, plain",
1149                )
1150            })?;
1151            let output_to = destination.parse::<OutputTo>().map_err(|_| {
1152                invalid_value(
1153                    command_path,
1154                    "--output-to".to_string(),
1155                    "expected one of split, stdout, stderr",
1156                )
1157            })?;
1158            Ok(OutputPlan::Protocol {
1159                lifecycle: *lifecycle,
1160                format: output_format,
1161                destination: output_to,
1162                stdout_file: parsed.stdout_file.clone(),
1163                stderr_file: parsed.stderr_file.clone(),
1164            })
1165        }
1166    }
1167}
1168
1169fn ensure_sinks(
1170    allowed: &[String],
1171    parsed: &ParsedOutput,
1172    command_path: &str,
1173) -> Result<(), CliError> {
1174    if (parsed.stdout_file.is_some() && !allowed.iter().any(|sink| sink == "stdout"))
1175        || (parsed.stderr_file.is_some() && !allowed.iter().any(|sink| sink == "stderr"))
1176    {
1177        return Err(CliError::unregistered(command_path.to_string()));
1178    }
1179    Ok(())
1180}
1181
1182/// Whether `token` names a segment of some registered command path.
1183///
1184/// Used only to sharpen a diagnosis, so a plain containment test is enough: the
1185/// point is to recognise that the caller typed a command where a value was
1186/// expected, not to work out which command they meant.
1187fn is_registered_command_segment(token: &str, all_commands: &[CommandSpec]) -> bool {
1188    all_commands
1189        .iter()
1190        .any(|command| command.command_path.iter().any(|segment| segment == token))
1191}
1192
1193/// Whether `raw` is a canonical 8-4-4-4-12 hexadecimal UUID.
1194///
1195/// Hand-written rather than pulled from a crate: the check has to be something
1196/// another language can reimplement from `cli-spec-v1` alone, and it keeps the
1197/// core free of a dependency for one argument shape.
1198fn is_canonical_uuid(raw: &str) -> bool {
1199    const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
1200    let mut parts = raw.split('-');
1201    for width in GROUPS {
1202        let Some(part) = parts.next() else {
1203            return false;
1204        };
1205        if part.len() != width || !part.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1206            return false;
1207        }
1208    }
1209    parts.next().is_none()
1210}
1211
1212fn synthetic_value(argument: &ArgSpec) -> String {
1213    match argument.value_type {
1214        ArgValueType::Flag => String::new(),
1215        ArgValueType::String => {
1216            if argument.sensitive {
1217                "synthetic-sensitive".to_string()
1218            } else {
1219                "value".to_string()
1220            }
1221        }
1222        ArgValueType::I64 => argument
1223            .range
1224            .map_or_else(|| "1".to_string(), |[minimum, _]| minimum.to_string()),
1225        ArgValueType::Uuid => "00000000-0000-0000-0000-000000000000".to_string(),
1226        ArgValueType::FiniteF64 => "1.5".to_string(),
1227        ArgValueType::Enum => argument.enum_values.first().cloned().unwrap_or_default(),
1228        ArgValueType::Json => "{}".to_string(),
1229    }
1230}
1231
1232fn append_synthetic_argument(argv: &mut Vec<String>, argument: &ArgSpec, value: &str) {
1233    match &argument.syntax {
1234        ArgSyntax::Long { name } => {
1235            argv.push(name.clone());
1236            if argument.value_type != ArgValueType::Flag {
1237                argv.push(value.to_string());
1238            }
1239        }
1240        ArgSyntax::Positional { .. } => argv.push(value.to_string()),
1241    }
1242}