Skip to main content

agent_first_data/cli_spec/
spec.rs

1use super::build::validate_spec;
2use super::*;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6/// Serializable version-one closed-world CLI registry.
7#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
8pub struct CliSpec {
9    pub schema: String,
10    pub name: String,
11    pub version: String,
12    /// Human-facing product name, distinct from the binary identity in `name`.
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub display_name: Option<String>,
15    /// Opaque build identifier (a git SHA, for example). The core only carries
16    /// it; what it means is the host's business.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub build: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub about: Option<String>,
21    /// Arguments every command accepts, declared once.
22    ///
23    /// Spliced into each command at build time and added to every combination's
24    /// optional set, so nothing downstream — resolution, help, `--docs` — needs
25    /// to know they were shared. Serialized as their own list so a consumer in
26    /// another language reads the same declaration rather than N copies.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub shared_arguments: Vec<ArgSpec>,
29    pub lifecycle_output: OutputSpec,
30    /// Exit codes this CLI returns beyond the 0/1/2 AFDATA defines, rendered
31    /// into the reference's exit-code table. Without this the published
32    /// reference documents only AFDATA's three, so a tool that also returns,
33    /// say, a partial-success code ships a document that contradicts its own
34    /// binary — on exactly the code a caller needs to branch on.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub exit_codes: Vec<ExitCodeSpec>,
37    pub commands: Vec<CommandSpec>,
38}
39
40/// One exit code a CLI defines for itself, beyond AFDATA's 0/1/2.
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct ExitCodeSpec {
43    pub code: u8,
44    /// What the code means to a caller, as one table cell.
45    pub meaning: String,
46}
47
48impl CliSpec {
49    /// Start a `cli-spec-v1` registry.
50    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
51        Self {
52            schema: "cli-spec-v1".to_string(),
53            name: name.into(),
54            version: version.into(),
55            display_name: None,
56            build: None,
57            about: None,
58            shared_arguments: Vec::new(),
59            lifecycle_output: OutputSpec::protocol_finite(
60                ["json", "yaml", "plain"],
61                ["split", "stdout", "stderr"],
62                "json",
63                "split",
64            ),
65            exit_codes: Vec::new(),
66            commands: Vec::new(),
67        }
68    }
69
70    pub fn about(mut self, about: impl Into<String>) -> Self {
71        self.about = nonempty(about.into());
72        self
73    }
74
75    pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
76        self.display_name = nonempty(display_name.into());
77        self
78    }
79
80    /// Record an opaque build identifier. Named `build_id` because `build()`
81    /// already compiles the registry.
82    pub fn build_id(mut self, build: impl Into<String>) -> Self {
83        self.build = nonempty(build.into());
84        self
85    }
86
87    pub fn lifecycle_output(mut self, output: OutputSpec) -> Self {
88        self.lifecycle_output = output;
89        self
90    }
91
92    /// Declare an exit code this CLI returns beyond AFDATA's 0/1/2, so the
93    /// rendered reference documents what the binary actually does.
94    pub fn exit_code(mut self, code: u8, meaning: impl Into<String>) -> Self {
95        self.exit_codes.push(ExitCodeSpec {
96            code,
97            meaning: meaning.into(),
98        });
99        self
100    }
101
102    pub fn command(mut self, command: CommandSpec) -> Self {
103        self.commands.push(command);
104        self
105    }
106
107    /// Declare an argument every command accepts, once.
108    ///
109    /// AFDATA already parses `--output`, `--stdout-file` and friends at every
110    /// command; this is the same capability for an argument the application
111    /// owns, so a `--config` needed by six commands is written once instead of
112    /// six times and cannot drift between them.
113    ///
114    /// It does **not** change where the argument may appear. The command path
115    /// is still matched against the leading tokens of argv, so `tool --config x
116    /// sub` remains an error and `tool sub --config x` is the accepted form.
117    /// This is only about declaration, not position — a caller migrating from a
118    /// parser with interleaved global flags has to move them after the path.
119    ///
120    /// A command with no combinations is skipped: it exists to carry help for
121    /// its children, accepts nothing itself, and would otherwise fail the
122    /// build for declaring an argument no combination covers.
123    #[must_use]
124    pub fn shared_arg(mut self, argument: ArgSpec) -> Self {
125        self.shared_arguments.push(argument);
126        self
127    }
128
129    /// Validate and compile the registry.
130    pub fn build(mut self) -> Result<BuiltCliSpec, CliSpecError> {
131        self.splice_shared_arguments()?;
132        validate_spec(&self)?;
133        Ok(BuiltCliSpec { spec: self })
134    }
135
136    /// Copy the shared arguments into every command, and into every
137    /// combination's optional set.
138    ///
139    /// Done before validation so a shared argument is held to exactly the same
140    /// rules as a declared one — reserved names, canonical spelling, and the
141    /// id/flag agreement all report against it normally.
142    fn splice_shared_arguments(&mut self) -> Result<(), CliSpecError> {
143        if self.shared_arguments.is_empty() {
144            return Ok(());
145        }
146        for shared in &self.shared_arguments {
147            for command in &self.commands {
148                if command
149                    .arguments
150                    .iter()
151                    .any(|argument| argument.argument_id == shared.argument_id)
152                {
153                    return Err(CliSpecError::new(
154                        "shared_argument_redeclared",
155                        format!(
156                            "argument `{}` is shared by every command, so `{}` must not declare \
157                             it again",
158                            shared.argument_id,
159                            if command.command_path.is_empty() {
160                                "the root command".to_string()
161                            } else {
162                                command.command_path.join(" ")
163                            }
164                        ),
165                    ));
166                }
167            }
168        }
169        let shared = self.shared_arguments.clone();
170        for command in &mut self.commands {
171            // A combination-less command is a help-only path node (`tool
172            // analysis --help` listing its children). It accepts no arguments
173            // of its own, so splicing one in would leave an argument no
174            // combination covers and fail validation.
175            if command.combinations.is_empty() {
176                continue;
177            }
178            for argument in &shared {
179                command.arguments.push(argument.clone());
180                for combination in &mut command.combinations {
181                    // Shared arguments are never required and never fixed: a
182                    // combination that needs one must declare its own.
183                    if !combination.optional.contains(&argument.argument_id) {
184                        combination.optional.push(argument.argument_id.clone());
185                    }
186                }
187            }
188        }
189        Ok(())
190    }
191}
192
193/// One exact command path in a CLI registry.
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195pub struct CommandSpec {
196    pub command_path: Vec<String>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub about: Option<String>,
199    pub arguments: Vec<ArgSpec>,
200    pub combinations: Vec<Combination>,
201}
202
203impl CommandSpec {
204    pub fn root() -> Self {
205        Self::new(std::iter::empty::<String>())
206    }
207
208    pub fn new<I, S>(command_path: I) -> Self
209    where
210        I: IntoIterator<Item = S>,
211        S: Into<String>,
212    {
213        Self {
214            command_path: command_path.into_iter().map(Into::into).collect(),
215            about: None,
216            arguments: Vec::new(),
217            combinations: Vec::new(),
218        }
219    }
220
221    pub fn about(mut self, about: impl Into<String>) -> Self {
222        self.about = nonempty(about.into());
223        self
224    }
225
226    pub fn arg(mut self, argument: ArgSpec) -> Self {
227        self.arguments.push(argument);
228        self
229    }
230
231    pub fn combination(mut self, combination: Combination) -> Self {
232        self.combinations.push(combination);
233        self
234    }
235}
236
237/// An argument's exact command-local spelling.
238#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(tag = "kind", rename_all = "snake_case")]
240pub enum ArgSyntax {
241    Long { name: String },
242    Positional { index: usize },
243}
244
245/// Closed portable value type used by CLI specs and resolved invocations.
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ArgValueType {
249    Flag,
250    String,
251    I64,
252    FiniteF64,
253    Enum,
254    Json,
255    /// A canonical RFC 4122 UUID, kept as its string form.
256    ///
257    /// Declarative on purpose: the registry serializes to `cli-spec-v1`, so a
258    /// value type has to be something another language can implement from the
259    /// spec alone. A host-supplied parser could not survive that trip; this
260    /// can. The value stays a `String` so the core takes no UUID dependency —
261    /// what it buys is that a malformed one is a *usage* error, rejected before
262    /// the command runs, instead of a domain failure the handler has to invent.
263    Uuid,
264}
265
266/// A typed value produced by a built CLI registry.
267///
268/// `Json` deliberately holds the argument's raw source text rather than a
269/// parsed `serde_json::Value`. AFDATA turns on `serde_json/arbitrary_precision`
270/// and that feature unifies across a whole binary, so a parsed value's number
271/// semantics would depend on which other crates happen to be linked in. The
272/// text is validated as one JSON value at parse time; deciding what its numbers
273/// mean is the caller's choice.
274#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
275#[serde(untagged)]
276pub enum CliValue {
277    Bool(bool),
278    String(String),
279    I64(i64),
280    FiniteF64(f64),
281    Json(String),
282    List(Vec<CliValue>),
283}
284
285impl CliValue {
286    pub fn as_bool(&self) -> Option<bool> {
287        match self {
288            Self::Bool(value) => Some(*value),
289            _ => None,
290        }
291    }
292
293    pub fn as_str(&self) -> Option<&str> {
294        match self {
295            Self::String(value) => Some(value),
296            _ => None,
297        }
298    }
299
300    pub fn as_i64(&self) -> Option<i64> {
301        match self {
302            Self::I64(value) => Some(*value),
303            _ => None,
304        }
305    }
306
307    pub fn as_f64(&self) -> Option<f64> {
308        match self {
309            Self::FiniteF64(value) => Some(*value),
310            _ => None,
311        }
312    }
313
314    /// The raw, still-unparsed JSON source text of a `json` argument.
315    pub fn as_json_str(&self) -> Option<&str> {
316        match self {
317            Self::Json(value) => Some(value),
318            _ => None,
319        }
320    }
321
322    pub fn as_list(&self) -> Option<&[CliValue]> {
323        match self {
324            Self::List(values) => Some(values),
325            _ => None,
326        }
327    }
328}
329
330/// One command-local application argument.
331#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332pub struct ArgSpec {
333    pub argument_id: String,
334    pub syntax: ArgSyntax,
335    pub value_type: ArgValueType,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub value_name: Option<String>,
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    pub enum_values: Vec<String>,
340    /// Inclusive bounds for an `I64` argument.
341    ///
342    /// Lets a registry say `1..=1000` without a host-supplied parser, so a
343    /// count that must fit an `i32`, a `usize`, or a `NonZero` is rejected at
344    /// exit 2 with the other usage errors rather than checked again inside the
345    /// handler — where the only honest report left is a domain failure.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub range: Option<[i64; 2]>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub default: Option<CliValue>,
350    #[serde(default, skip_serializing_if = "is_false")]
351    pub repeatable: bool,
352    /// This argument's value must never be echoed back.
353    ///
354    /// The core only consumes the bit: it suppresses help defaults, keeps the
355    /// value out of rendered templates, and rejects a serializable default.
356    /// Which arguments deserve it is a host convention — `crate::cli_afdata`
357    /// derives it from AFDATA's `_secret` suffix.
358    #[serde(default, skip_serializing_if = "is_false")]
359    pub sensitive: bool,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub about: Option<String>,
362}
363
364impl ArgSpec {
365    pub fn flag(long: impl Into<String>) -> Self {
366        Self::long(long, ArgValueType::Flag, None::<String>)
367    }
368
369    pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
370        Self::long(long, ArgValueType::String, Some(value_name.into()))
371    }
372
373    pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
374        Self::long(long, ArgValueType::I64, Some(value_name.into()))
375    }
376
377    pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
378        Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
379    }
380
381    pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
382        Self::long(long, ArgValueType::Json, Some(value_name.into()))
383    }
384
385    pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
386    where
387        I: IntoIterator<Item = S>,
388        S: Into<String>,
389    {
390        let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
391        spec.enum_values = values.into_iter().map(Into::into).collect();
392        spec
393    }
394
395    /// Read this argument as a canonical RFC 4122 UUID.
396    ///
397    /// The resolved value is still a `CliValue::String`; what changes is that a
398    /// malformed one is rejected as a usage error before the command runs.
399    #[must_use]
400    pub fn uuid(mut self) -> Self {
401        self.value_type = ArgValueType::Uuid;
402        self
403    }
404
405    /// Constrain an `I64` argument to an inclusive range.
406    ///
407    /// Use it for a count that must fit a narrower integer than `i64` — the
408    /// check then reports as a usage error, beside the other argument
409    /// failures, rather than as a domain failure from inside the handler.
410    #[must_use]
411    pub fn range(mut self, minimum: i64, maximum: i64) -> Self {
412        self.value_type = ArgValueType::I64;
413        self.range = Some([minimum, maximum]);
414        self
415    }
416
417    pub fn positional(
418        argument_id: impl Into<String>,
419        index: usize,
420        value_name: impl Into<String>,
421    ) -> Self {
422        Self {
423            argument_id: argument_id.into(),
424            syntax: ArgSyntax::Positional { index },
425            value_type: ArgValueType::String,
426            value_name: nonempty(value_name.into()),
427            enum_values: Vec::new(),
428            range: None,
429            default: None,
430            repeatable: false,
431            sensitive: false,
432            about: None,
433        }
434    }
435
436    pub fn positional_json(
437        argument_id: impl Into<String>,
438        index: usize,
439        value_name: impl Into<String>,
440    ) -> Self {
441        Self {
442            value_type: ArgValueType::Json,
443            ..Self::positional(argument_id, index, value_name)
444        }
445    }
446
447    pub fn positional_enum<I, S>(
448        argument_id: impl Into<String>,
449        index: usize,
450        value_name: impl Into<String>,
451        values: I,
452    ) -> Self
453    where
454        I: IntoIterator<Item = S>,
455        S: Into<String>,
456    {
457        let mut spec = Self {
458            value_type: ArgValueType::Enum,
459            ..Self::positional(argument_id, index, value_name)
460        };
461        spec.enum_values = values.into_iter().map(Into::into).collect();
462        spec
463    }
464
465    fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
466        let long = long.into();
467        let argument_id = long
468            .strip_prefix("--")
469            .unwrap_or(long.as_str())
470            .replace('-', "_");
471        Self {
472            argument_id,
473            syntax: ArgSyntax::Long { name: long },
474            value_type,
475            value_name: value_name.and_then(nonempty),
476            enum_values: Vec::new(),
477            range: None,
478            default: None,
479            repeatable: false,
480            sensitive: false,
481            about: None,
482        }
483    }
484
485    pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
486        self.value_name = nonempty(value_name.into());
487        self
488    }
489
490    pub fn default(mut self, value: impl Into<String>) -> Self {
491        self.default = Some(CliValue::String(value.into()));
492        self
493    }
494
495    pub fn default_i64(mut self, value: i64) -> Self {
496        self.default = Some(CliValue::I64(value));
497        self
498    }
499
500    pub fn default_f64(mut self, value: f64) -> Self {
501        self.default = Some(CliValue::FiniteF64(value));
502        self
503    }
504
505    pub fn repeatable(mut self) -> Self {
506        self.repeatable = true;
507        self
508    }
509
510    /// Mark this argument's value as one that must never be echoed back.
511    pub fn sensitive(mut self) -> Self {
512        self.sensitive = true;
513        self
514    }
515
516    pub fn about(mut self, about: impl Into<String>) -> Self {
517        self.about = nonempty(about.into());
518        self
519    }
520}
521
522/// A finite fixed enum constraint.
523#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(untagged)]
525pub enum FixedValue {
526    Value(String),
527    OneOf { one_of: Vec<String> },
528}
529
530impl FixedValue {
531    pub(super) fn values(&self) -> &[String] {
532        match self {
533            Self::Value(value) => std::slice::from_ref(value),
534            Self::OneOf { one_of } => one_of,
535        }
536    }
537}
538
539/// One named legal application invocation shape.
540#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
541pub struct Combination {
542    pub combination_id: String,
543    pub action_id: String,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub about: Option<String>,
546    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
547    pub fixed: BTreeMap<String, FixedValue>,
548    #[serde(default, skip_serializing_if = "Vec::is_empty")]
549    pub required: Vec<String>,
550    #[serde(default, skip_serializing_if = "Vec::is_empty")]
551    pub optional: Vec<String>,
552    pub output: OutputSpec,
553}
554
555impl Combination {
556    pub fn new(combination_id: impl Into<String>) -> Self {
557        Self {
558            combination_id: combination_id.into(),
559            action_id: String::new(),
560            about: None,
561            fixed: BTreeMap::new(),
562            required: Vec::new(),
563            optional: Vec::new(),
564            output: OutputSpec::protocol_finite(
565                ["json", "yaml", "plain"],
566                ["split", "stdout", "stderr"],
567                "json",
568                "split",
569            ),
570        }
571    }
572
573    pub fn action(mut self, action_id: impl Into<String>) -> Self {
574        self.action_id = action_id.into();
575        self
576    }
577
578    pub fn about(mut self, about: impl Into<String>) -> Self {
579        self.about = nonempty(about.into());
580        self
581    }
582
583    pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
584        self.fixed
585            .insert(argument_id.into(), FixedValue::Value(value.into()));
586        self
587    }
588
589    pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
590    where
591        I: IntoIterator<Item = S>,
592        S: Into<String>,
593    {
594        self.fixed.insert(
595            argument_id.into(),
596            FixedValue::OneOf {
597                one_of: values.into_iter().map(Into::into).collect(),
598            },
599        );
600        self
601    }
602
603    pub fn required<I, S>(mut self, argument_ids: I) -> Self
604    where
605        I: IntoIterator<Item = S>,
606        S: Into<String>,
607    {
608        self.required
609            .extend(argument_ids.into_iter().map(Into::into));
610        self
611    }
612
613    pub fn optional<I, S>(mut self, argument_ids: I) -> Self
614    where
615        I: IntoIterator<Item = S>,
616        S: Into<String>,
617    {
618        self.optional
619            .extend(argument_ids.into_iter().map(Into::into));
620        self
621    }
622
623    pub fn output(mut self, output: OutputSpec) -> Self {
624        self.output = output;
625        self
626    }
627}
628
629/// Protocol output lifecycle.
630#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(rename_all = "snake_case")]
632pub enum OutputLifecycle {
633    Finite,
634    Stream,
635}
636
637/// Closed output contract for one combination.
638#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
639#[serde(tag = "kind", rename_all = "snake_case")]
640pub enum OutputSpec {
641    Raw {
642        #[serde(default)]
643        file_sinks: Vec<String>,
644    },
645    Protocol {
646        lifecycle: OutputLifecycle,
647        formats: Vec<String>,
648        destinations: Vec<String>,
649        default_format: String,
650        default_destination: String,
651        #[serde(default)]
652        file_sinks: Vec<String>,
653    },
654}
655
656impl OutputSpec {
657    pub fn raw() -> Self {
658        Self::Raw {
659            file_sinks: Vec::new(),
660        }
661    }
662
663    pub fn protocol_finite<FI, FS, DI, DS>(
664        formats: FI,
665        destinations: DI,
666        default_format: impl Into<String>,
667        default_destination: impl Into<String>,
668    ) -> Self
669    where
670        FI: IntoIterator<Item = FS>,
671        FS: Into<String>,
672        DI: IntoIterator<Item = DS>,
673        DS: Into<String>,
674    {
675        Self::Protocol {
676            lifecycle: OutputLifecycle::Finite,
677            formats: formats.into_iter().map(Into::into).collect(),
678            destinations: destinations.into_iter().map(Into::into).collect(),
679            default_format: default_format.into(),
680            default_destination: default_destination.into(),
681            file_sinks: Vec::new(),
682        }
683    }
684
685    pub fn protocol_stream<FI, FS, DI, DS>(
686        formats: FI,
687        destinations: DI,
688        default_format: impl Into<String>,
689        default_destination: impl Into<String>,
690    ) -> Self
691    where
692        FI: IntoIterator<Item = FS>,
693        FS: Into<String>,
694        DI: IntoIterator<Item = DS>,
695        DS: Into<String>,
696    {
697        Self::Protocol {
698            lifecycle: OutputLifecycle::Stream,
699            formats: formats.into_iter().map(Into::into).collect(),
700            destinations: destinations.into_iter().map(Into::into).collect(),
701            default_format: default_format.into(),
702            default_destination: default_destination.into(),
703            file_sinks: Vec::new(),
704        }
705    }
706
707    pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
708    where
709        I: IntoIterator<Item = S>,
710        S: Into<String>,
711    {
712        let values = sinks.into_iter().map(Into::into).collect();
713        match &mut self {
714            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
715                *file_sinks = values;
716            }
717        }
718        self
719    }
720
721    pub(super) fn file_sinks_ref(&self) -> &[String] {
722        match self {
723            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
724        }
725    }
726}
727
728/// Stable build-time registry error.
729#[derive(Clone, Debug, PartialEq, Eq)]
730pub struct CliSpecError {
731    pub rule: &'static str,
732    pub message: String,
733}
734
735impl CliSpecError {
736    pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
737        Self {
738            rule,
739            message: message.into(),
740        }
741    }
742}
743
744impl std::fmt::Display for CliSpecError {
745    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746        write!(f, "{}: {}", self.rule, self.message)
747    }
748}
749
750impl std::error::Error for CliSpecError {}