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