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    pub lifecycle_output: OutputSpec,
22    /// Exit codes this CLI returns beyond the 0/1/2 AFDATA defines, rendered
23    /// into the reference's exit-code table. Without this the published
24    /// reference documents only AFDATA's three, so a tool that also returns,
25    /// say, a partial-success code ships a document that contradicts its own
26    /// binary — on exactly the code a caller needs to branch on.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub exit_codes: Vec<ExitCodeSpec>,
29    pub commands: Vec<CommandSpec>,
30}
31
32/// One exit code a CLI defines for itself, beyond AFDATA's 0/1/2.
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct ExitCodeSpec {
35    pub code: u8,
36    /// What the code means to a caller, as one table cell.
37    pub meaning: String,
38}
39
40impl CliSpec {
41    /// Start a `cli-spec-v1` registry.
42    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
43        Self {
44            schema: "cli-spec-v1".to_string(),
45            name: name.into(),
46            version: version.into(),
47            display_name: None,
48            build: None,
49            about: None,
50            lifecycle_output: OutputSpec::protocol_finite(
51                ["json", "yaml", "plain"],
52                ["split", "stdout", "stderr"],
53                "json",
54                "split",
55            ),
56            exit_codes: Vec::new(),
57            commands: Vec::new(),
58        }
59    }
60
61    pub fn about(mut self, about: impl Into<String>) -> Self {
62        self.about = nonempty(about.into());
63        self
64    }
65
66    pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
67        self.display_name = nonempty(display_name.into());
68        self
69    }
70
71    /// Record an opaque build identifier. Named `build_id` because `build()`
72    /// already compiles the registry.
73    pub fn build_id(mut self, build: impl Into<String>) -> Self {
74        self.build = nonempty(build.into());
75        self
76    }
77
78    pub fn lifecycle_output(mut self, output: OutputSpec) -> Self {
79        self.lifecycle_output = output;
80        self
81    }
82
83    /// Declare an exit code this CLI returns beyond AFDATA's 0/1/2, so the
84    /// rendered reference documents what the binary actually does.
85    pub fn exit_code(mut self, code: u8, meaning: impl Into<String>) -> Self {
86        self.exit_codes.push(ExitCodeSpec {
87            code,
88            meaning: meaning.into(),
89        });
90        self
91    }
92
93    pub fn command(mut self, command: CommandSpec) -> Self {
94        self.commands.push(command);
95        self
96    }
97
98    /// Validate and compile the registry.
99    pub fn build(self) -> Result<BuiltCliSpec, CliSpecError> {
100        validate_spec(&self)?;
101        Ok(BuiltCliSpec { spec: self })
102    }
103}
104
105/// One exact command path in a CLI registry.
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct CommandSpec {
108    pub command_path: Vec<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub about: Option<String>,
111    pub arguments: Vec<ArgSpec>,
112    pub combinations: Vec<Combination>,
113}
114
115impl CommandSpec {
116    pub fn root() -> Self {
117        Self::new(std::iter::empty::<String>())
118    }
119
120    pub fn new<I, S>(command_path: I) -> Self
121    where
122        I: IntoIterator<Item = S>,
123        S: Into<String>,
124    {
125        Self {
126            command_path: command_path.into_iter().map(Into::into).collect(),
127            about: None,
128            arguments: Vec::new(),
129            combinations: Vec::new(),
130        }
131    }
132
133    pub fn about(mut self, about: impl Into<String>) -> Self {
134        self.about = nonempty(about.into());
135        self
136    }
137
138    pub fn arg(mut self, argument: ArgSpec) -> Self {
139        self.arguments.push(argument);
140        self
141    }
142
143    pub fn combination(mut self, combination: Combination) -> Self {
144        self.combinations.push(combination);
145        self
146    }
147}
148
149/// An argument's exact command-local spelling.
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum ArgSyntax {
153    Long { name: String },
154    Positional { index: usize },
155}
156
157/// Closed portable value type used by CLI specs and resolved invocations.
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ArgValueType {
161    Flag,
162    String,
163    I64,
164    FiniteF64,
165    Enum,
166    Json,
167}
168
169/// A typed value produced by a built CLI registry.
170///
171/// `Json` deliberately holds the argument's raw source text rather than a
172/// parsed `serde_json::Value`. AFDATA turns on `serde_json/arbitrary_precision`
173/// and that feature unifies across a whole binary, so a parsed value's number
174/// semantics would depend on which other crates happen to be linked in. The
175/// text is validated as one JSON value at parse time; deciding what its numbers
176/// mean is the caller's choice.
177#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
178#[serde(untagged)]
179pub enum CliValue {
180    Bool(bool),
181    String(String),
182    I64(i64),
183    FiniteF64(f64),
184    Json(String),
185    List(Vec<CliValue>),
186}
187
188impl CliValue {
189    pub fn as_bool(&self) -> Option<bool> {
190        match self {
191            Self::Bool(value) => Some(*value),
192            _ => None,
193        }
194    }
195
196    pub fn as_str(&self) -> Option<&str> {
197        match self {
198            Self::String(value) => Some(value),
199            _ => None,
200        }
201    }
202
203    pub fn as_i64(&self) -> Option<i64> {
204        match self {
205            Self::I64(value) => Some(*value),
206            _ => None,
207        }
208    }
209
210    pub fn as_f64(&self) -> Option<f64> {
211        match self {
212            Self::FiniteF64(value) => Some(*value),
213            _ => None,
214        }
215    }
216
217    /// The raw, still-unparsed JSON source text of a `json` argument.
218    pub fn as_json_str(&self) -> Option<&str> {
219        match self {
220            Self::Json(value) => Some(value),
221            _ => None,
222        }
223    }
224
225    pub fn as_list(&self) -> Option<&[CliValue]> {
226        match self {
227            Self::List(values) => Some(values),
228            _ => None,
229        }
230    }
231}
232
233/// One command-local application argument.
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct ArgSpec {
236    pub argument_id: String,
237    pub syntax: ArgSyntax,
238    pub value_type: ArgValueType,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub value_name: Option<String>,
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub enum_values: Vec<String>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub default: Option<CliValue>,
245    #[serde(default, skip_serializing_if = "is_false")]
246    pub repeatable: bool,
247    /// This argument's value must never be echoed back.
248    ///
249    /// The core only consumes the bit: it suppresses help defaults, keeps the
250    /// value out of rendered templates, and rejects a serializable default.
251    /// Which arguments deserve it is a host convention — `crate::cli_afdata`
252    /// derives it from AFDATA's `_secret` suffix.
253    #[serde(default, skip_serializing_if = "is_false")]
254    pub sensitive: bool,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub about: Option<String>,
257}
258
259impl ArgSpec {
260    pub fn flag(long: impl Into<String>) -> Self {
261        Self::long(long, ArgValueType::Flag, None::<String>)
262    }
263
264    pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
265        Self::long(long, ArgValueType::String, Some(value_name.into()))
266    }
267
268    pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
269        Self::long(long, ArgValueType::I64, Some(value_name.into()))
270    }
271
272    pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
273        Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
274    }
275
276    pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
277        Self::long(long, ArgValueType::Json, Some(value_name.into()))
278    }
279
280    pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
281    where
282        I: IntoIterator<Item = S>,
283        S: Into<String>,
284    {
285        let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
286        spec.enum_values = values.into_iter().map(Into::into).collect();
287        spec
288    }
289
290    pub fn positional(
291        argument_id: impl Into<String>,
292        index: usize,
293        value_name: impl Into<String>,
294    ) -> Self {
295        Self {
296            argument_id: argument_id.into(),
297            syntax: ArgSyntax::Positional { index },
298            value_type: ArgValueType::String,
299            value_name: nonempty(value_name.into()),
300            enum_values: Vec::new(),
301            default: None,
302            repeatable: false,
303            sensitive: false,
304            about: None,
305        }
306    }
307
308    pub fn positional_json(
309        argument_id: impl Into<String>,
310        index: usize,
311        value_name: impl Into<String>,
312    ) -> Self {
313        Self {
314            value_type: ArgValueType::Json,
315            ..Self::positional(argument_id, index, value_name)
316        }
317    }
318
319    pub fn positional_enum<I, S>(
320        argument_id: impl Into<String>,
321        index: usize,
322        value_name: impl Into<String>,
323        values: I,
324    ) -> Self
325    where
326        I: IntoIterator<Item = S>,
327        S: Into<String>,
328    {
329        let mut spec = Self {
330            value_type: ArgValueType::Enum,
331            ..Self::positional(argument_id, index, value_name)
332        };
333        spec.enum_values = values.into_iter().map(Into::into).collect();
334        spec
335    }
336
337    fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
338        let long = long.into();
339        let argument_id = long
340            .strip_prefix("--")
341            .unwrap_or(long.as_str())
342            .replace('-', "_");
343        Self {
344            argument_id,
345            syntax: ArgSyntax::Long { name: long },
346            value_type,
347            value_name: value_name.and_then(nonempty),
348            enum_values: Vec::new(),
349            default: None,
350            repeatable: false,
351            sensitive: false,
352            about: None,
353        }
354    }
355
356    pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
357        self.value_name = nonempty(value_name.into());
358        self
359    }
360
361    pub fn default(mut self, value: impl Into<String>) -> Self {
362        self.default = Some(CliValue::String(value.into()));
363        self
364    }
365
366    pub fn default_i64(mut self, value: i64) -> Self {
367        self.default = Some(CliValue::I64(value));
368        self
369    }
370
371    pub fn default_f64(mut self, value: f64) -> Self {
372        self.default = Some(CliValue::FiniteF64(value));
373        self
374    }
375
376    pub fn repeatable(mut self) -> Self {
377        self.repeatable = true;
378        self
379    }
380
381    /// Mark this argument's value as one that must never be echoed back.
382    pub fn sensitive(mut self) -> Self {
383        self.sensitive = true;
384        self
385    }
386
387    pub fn about(mut self, about: impl Into<String>) -> Self {
388        self.about = nonempty(about.into());
389        self
390    }
391}
392
393/// A finite fixed enum constraint.
394#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(untagged)]
396pub enum FixedValue {
397    Value(String),
398    OneOf { one_of: Vec<String> },
399}
400
401impl FixedValue {
402    pub(super) fn values(&self) -> &[String] {
403        match self {
404            Self::Value(value) => std::slice::from_ref(value),
405            Self::OneOf { one_of } => one_of,
406        }
407    }
408}
409
410/// One named legal application invocation shape.
411#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
412pub struct Combination {
413    pub combination_id: String,
414    pub action_id: String,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub about: Option<String>,
417    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
418    pub fixed: BTreeMap<String, FixedValue>,
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub required: Vec<String>,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub optional: Vec<String>,
423    pub output: OutputSpec,
424}
425
426impl Combination {
427    pub fn new(combination_id: impl Into<String>) -> Self {
428        Self {
429            combination_id: combination_id.into(),
430            action_id: String::new(),
431            about: None,
432            fixed: BTreeMap::new(),
433            required: Vec::new(),
434            optional: Vec::new(),
435            output: OutputSpec::protocol_finite(
436                ["json", "yaml", "plain"],
437                ["split", "stdout", "stderr"],
438                "json",
439                "split",
440            ),
441        }
442    }
443
444    pub fn action(mut self, action_id: impl Into<String>) -> Self {
445        self.action_id = action_id.into();
446        self
447    }
448
449    pub fn about(mut self, about: impl Into<String>) -> Self {
450        self.about = nonempty(about.into());
451        self
452    }
453
454    pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
455        self.fixed
456            .insert(argument_id.into(), FixedValue::Value(value.into()));
457        self
458    }
459
460    pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
461    where
462        I: IntoIterator<Item = S>,
463        S: Into<String>,
464    {
465        self.fixed.insert(
466            argument_id.into(),
467            FixedValue::OneOf {
468                one_of: values.into_iter().map(Into::into).collect(),
469            },
470        );
471        self
472    }
473
474    pub fn required<I, S>(mut self, argument_ids: I) -> Self
475    where
476        I: IntoIterator<Item = S>,
477        S: Into<String>,
478    {
479        self.required
480            .extend(argument_ids.into_iter().map(Into::into));
481        self
482    }
483
484    pub fn optional<I, S>(mut self, argument_ids: I) -> Self
485    where
486        I: IntoIterator<Item = S>,
487        S: Into<String>,
488    {
489        self.optional
490            .extend(argument_ids.into_iter().map(Into::into));
491        self
492    }
493
494    pub fn output(mut self, output: OutputSpec) -> Self {
495        self.output = output;
496        self
497    }
498}
499
500/// Protocol output lifecycle.
501#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum OutputLifecycle {
504    Finite,
505    Stream,
506}
507
508/// Closed output contract for one combination.
509#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(tag = "kind", rename_all = "snake_case")]
511pub enum OutputSpec {
512    Raw {
513        #[serde(default)]
514        file_sinks: Vec<String>,
515    },
516    Protocol {
517        lifecycle: OutputLifecycle,
518        formats: Vec<String>,
519        destinations: Vec<String>,
520        default_format: String,
521        default_destination: String,
522        #[serde(default)]
523        file_sinks: Vec<String>,
524    },
525}
526
527impl OutputSpec {
528    pub fn raw() -> Self {
529        Self::Raw {
530            file_sinks: Vec::new(),
531        }
532    }
533
534    pub fn protocol_finite<FI, FS, DI, DS>(
535        formats: FI,
536        destinations: DI,
537        default_format: impl Into<String>,
538        default_destination: impl Into<String>,
539    ) -> Self
540    where
541        FI: IntoIterator<Item = FS>,
542        FS: Into<String>,
543        DI: IntoIterator<Item = DS>,
544        DS: Into<String>,
545    {
546        Self::Protocol {
547            lifecycle: OutputLifecycle::Finite,
548            formats: formats.into_iter().map(Into::into).collect(),
549            destinations: destinations.into_iter().map(Into::into).collect(),
550            default_format: default_format.into(),
551            default_destination: default_destination.into(),
552            file_sinks: Vec::new(),
553        }
554    }
555
556    pub fn protocol_stream<FI, FS, DI, DS>(
557        formats: FI,
558        destinations: DI,
559        default_format: impl Into<String>,
560        default_destination: impl Into<String>,
561    ) -> Self
562    where
563        FI: IntoIterator<Item = FS>,
564        FS: Into<String>,
565        DI: IntoIterator<Item = DS>,
566        DS: Into<String>,
567    {
568        Self::Protocol {
569            lifecycle: OutputLifecycle::Stream,
570            formats: formats.into_iter().map(Into::into).collect(),
571            destinations: destinations.into_iter().map(Into::into).collect(),
572            default_format: default_format.into(),
573            default_destination: default_destination.into(),
574            file_sinks: Vec::new(),
575        }
576    }
577
578    pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
579    where
580        I: IntoIterator<Item = S>,
581        S: Into<String>,
582    {
583        let values = sinks.into_iter().map(Into::into).collect();
584        match &mut self {
585            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
586                *file_sinks = values;
587            }
588        }
589        self
590    }
591
592    pub(super) fn file_sinks_ref(&self) -> &[String] {
593        match self {
594            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
595        }
596    }
597}
598
599/// Stable build-time registry error.
600#[derive(Clone, Debug, PartialEq, Eq)]
601pub struct CliSpecError {
602    pub rule: &'static str,
603    pub message: String,
604}
605
606impl CliSpecError {
607    pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
608        Self {
609            rule,
610            message: message.into(),
611        }
612    }
613}
614
615impl std::fmt::Display for CliSpecError {
616    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617        write!(f, "{}: {}", self.rule, self.message)
618    }
619}
620
621impl std::error::Error for CliSpecError {}