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