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    /// Prose for the generated reference, after this command's argument table.
200    ///
201    /// An argument's `about` is one table cell and has to stay one — but some
202    /// commands need a paragraph that belongs to no single argument: where a
203    /// default is read from, what two options mean together, what the result
204    /// reports back. Without somewhere to put it, that paragraph ends up
205    /// hand-written into a file whose own header says "generated, do not edit
206    /// by hand", where the next regeneration silently deletes it.
207    ///
208    /// Reference only. `--help` stays the machine-readable shape of a command;
209    /// this is for the document a person reads.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub reference_note: Option<String>,
212    pub arguments: Vec<ArgSpec>,
213    pub combinations: Vec<Combination>,
214}
215
216impl CommandSpec {
217    pub fn root() -> Self {
218        Self::new(std::iter::empty::<String>())
219    }
220
221    pub fn new<I, S>(command_path: I) -> Self
222    where
223        I: IntoIterator<Item = S>,
224        S: Into<String>,
225    {
226        Self {
227            command_path: command_path.into_iter().map(Into::into).collect(),
228            about: None,
229            reference_note: None,
230            arguments: Vec::new(),
231            combinations: Vec::new(),
232        }
233    }
234
235    pub fn about(mut self, about: impl Into<String>) -> Self {
236        self.about = nonempty(about.into());
237        self
238    }
239
240    /// Prose for the generated reference, after this command's argument table.
241    #[must_use]
242    pub fn reference_note(mut self, note: impl Into<String>) -> Self {
243        self.reference_note = nonempty(note.into());
244        self
245    }
246
247    pub fn arg(mut self, argument: ArgSpec) -> Self {
248        self.arguments.push(argument);
249        self
250    }
251
252    pub fn combination(mut self, combination: Combination) -> Self {
253        self.combinations.push(combination);
254        self
255    }
256}
257
258/// An argument's exact command-local spelling.
259#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(tag = "kind", rename_all = "snake_case")]
261pub enum ArgSyntax {
262    Long { name: String },
263    Positional { index: usize },
264}
265
266/// Closed portable value type used by CLI specs and resolved invocations.
267#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum ArgValueType {
270    Flag,
271    String,
272    I64,
273    FiniteF64,
274    Enum,
275    Json,
276    /// A canonical RFC 4122 UUID, kept as its string form.
277    ///
278    /// Declarative on purpose: the registry serializes to `cli-spec-v1`, so a
279    /// value type has to be something another language can implement from the
280    /// spec alone. A host-supplied parser could not survive that trip; this
281    /// can. The value stays a `String` so the core takes no UUID dependency —
282    /// what it buys is that a malformed one is a *usage* error, rejected before
283    /// the command runs, instead of a domain failure the handler has to invent.
284    Uuid,
285}
286
287/// A typed value produced by a built CLI registry.
288///
289/// `Json` deliberately holds the argument's raw source text rather than a
290/// parsed `serde_json::Value`. AFDATA turns on `serde_json/arbitrary_precision`
291/// and that feature unifies across a whole binary, so a parsed value's number
292/// semantics would depend on which other crates happen to be linked in. The
293/// text is validated as one JSON value at parse time; deciding what its numbers
294/// mean is the caller's choice.
295#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
296#[serde(untagged)]
297pub enum CliValue {
298    Bool(bool),
299    String(String),
300    I64(i64),
301    FiniteF64(f64),
302    Json(String),
303    List(Vec<CliValue>),
304}
305
306impl CliValue {
307    pub fn as_bool(&self) -> Option<bool> {
308        match self {
309            Self::Bool(value) => Some(*value),
310            _ => None,
311        }
312    }
313
314    pub fn as_str(&self) -> Option<&str> {
315        match self {
316            Self::String(value) => Some(value),
317            _ => None,
318        }
319    }
320
321    pub fn as_i64(&self) -> Option<i64> {
322        match self {
323            Self::I64(value) => Some(*value),
324            _ => None,
325        }
326    }
327
328    pub fn as_f64(&self) -> Option<f64> {
329        match self {
330            Self::FiniteF64(value) => Some(*value),
331            _ => None,
332        }
333    }
334
335    /// The raw, still-unparsed JSON source text of a `json` argument.
336    pub fn as_json_str(&self) -> Option<&str> {
337        match self {
338            Self::Json(value) => Some(value),
339            _ => None,
340        }
341    }
342
343    pub fn as_list(&self) -> Option<&[CliValue]> {
344        match self {
345            Self::List(values) => Some(values),
346            _ => None,
347        }
348    }
349}
350
351/// One command-local application argument.
352#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
353pub struct ArgSpec {
354    pub argument_id: String,
355    pub syntax: ArgSyntax,
356    pub value_type: ArgValueType,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub value_name: Option<String>,
359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
360    pub enum_values: Vec<String>,
361    /// Inclusive bounds for an `I64` argument.
362    ///
363    /// Lets a registry say `1..=1000` without a host-supplied parser, so a
364    /// count that must fit an `i32`, a `usize`, or a `NonZero` is rejected at
365    /// exit 2 with the other usage errors rather than checked again inside the
366    /// handler — where the only honest report left is a domain failure.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub range: Option<[i64; 2]>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub default: Option<CliValue>,
371    #[serde(default, skip_serializing_if = "is_false")]
372    pub repeatable: bool,
373    /// This argument's value must never be echoed back.
374    ///
375    /// The core only consumes the bit: it suppresses help defaults, keeps the
376    /// value out of rendered templates, and rejects a serializable default.
377    /// Which arguments deserve it is a host convention — `crate::cli_afdata`
378    /// derives it from AFDATA's `_secret` suffix.
379    #[serde(default, skip_serializing_if = "is_false")]
380    pub sensitive: bool,
381    /// The sources this argument accepts beside a literal value.
382    ///
383    /// Declared rather than assumed: a source turns an argument into a reader
384    /// of files and environment variables, which is right for a credential and
385    /// wrong for most everything else. The core validates that a value names
386    /// only a scheme in this set, and renders the syntax into help so no host
387    /// repeats it in an `about` string. Reading happens in the host, when it
388    /// chooses — see [`crate::value_source`].
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub sources: Option<SourceSet>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub about: Option<String>,
393}
394
395impl ArgSpec {
396    pub fn flag(long: impl Into<String>) -> Self {
397        Self::long(long, ArgValueType::Flag, None::<String>)
398    }
399
400    pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
401        Self::long(long, ArgValueType::String, Some(value_name.into()))
402    }
403
404    pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
405        Self::long(long, ArgValueType::I64, Some(value_name.into()))
406    }
407
408    pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
409        Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
410    }
411
412    pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
413        Self::long(long, ArgValueType::Json, Some(value_name.into()))
414    }
415
416    pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
417    where
418        I: IntoIterator<Item = S>,
419        S: Into<String>,
420    {
421        let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
422        spec.enum_values = values.into_iter().map(Into::into).collect();
423        spec
424    }
425
426    /// Read this argument as a canonical RFC 4122 UUID.
427    ///
428    /// The resolved value is still a `CliValue::String`; what changes is that a
429    /// malformed one is rejected as a usage error before the command runs.
430    #[must_use]
431    pub fn uuid(mut self) -> Self {
432        self.value_type = ArgValueType::Uuid;
433        self
434    }
435
436    /// Constrain an `I64` argument to an inclusive range.
437    ///
438    /// Use it for a count that must fit a narrower integer than `i64` — the
439    /// check then reports as a usage error, beside the other argument
440    /// failures, rather than as a domain failure from inside the handler.
441    #[must_use]
442    pub fn range(mut self, minimum: i64, maximum: i64) -> Self {
443        self.value_type = ArgValueType::I64;
444        self.range = Some([minimum, maximum]);
445        self
446    }
447
448    pub fn positional(
449        argument_id: impl Into<String>,
450        index: usize,
451        value_name: impl Into<String>,
452    ) -> Self {
453        Self {
454            argument_id: argument_id.into(),
455            syntax: ArgSyntax::Positional { index },
456            value_type: ArgValueType::String,
457            value_name: nonempty(value_name.into()),
458            enum_values: Vec::new(),
459            range: None,
460            default: None,
461            repeatable: false,
462            sensitive: false,
463            sources: None,
464            about: None,
465        }
466    }
467
468    pub fn positional_json(
469        argument_id: impl Into<String>,
470        index: usize,
471        value_name: impl Into<String>,
472    ) -> Self {
473        Self {
474            value_type: ArgValueType::Json,
475            ..Self::positional(argument_id, index, value_name)
476        }
477    }
478
479    pub fn positional_enum<I, S>(
480        argument_id: impl Into<String>,
481        index: usize,
482        value_name: impl Into<String>,
483        values: I,
484    ) -> Self
485    where
486        I: IntoIterator<Item = S>,
487        S: Into<String>,
488    {
489        let mut spec = Self {
490            value_type: ArgValueType::Enum,
491            ..Self::positional(argument_id, index, value_name)
492        };
493        spec.enum_values = values.into_iter().map(Into::into).collect();
494        spec
495    }
496
497    fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
498        let long = long.into();
499        let argument_id = long
500            .strip_prefix("--")
501            .unwrap_or(long.as_str())
502            .replace('-', "_");
503        Self {
504            argument_id,
505            syntax: ArgSyntax::Long { name: long },
506            value_type,
507            value_name: value_name.and_then(nonempty),
508            enum_values: Vec::new(),
509            range: None,
510            default: None,
511            repeatable: false,
512            sensitive: false,
513            sources: None,
514            about: None,
515        }
516    }
517
518    pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
519        self.value_name = nonempty(value_name.into());
520        self
521    }
522
523    pub fn default(mut self, value: impl Into<String>) -> Self {
524        self.default = Some(CliValue::String(value.into()));
525        self
526    }
527
528    pub fn default_i64(mut self, value: i64) -> Self {
529        self.default = Some(CliValue::I64(value));
530        self
531    }
532
533    pub fn default_f64(mut self, value: f64) -> Self {
534        self.default = Some(CliValue::FiniteF64(value));
535        self
536    }
537
538    pub fn repeatable(mut self) -> Self {
539        self.repeatable = true;
540        self
541    }
542
543    /// Mark this argument's value as one that must never be echoed back.
544    pub fn sensitive(mut self) -> Self {
545        self.sensitive = true;
546        self
547    }
548
549    /// Accept the value indirectly, from any source in `sources`.
550    ///
551    /// The help text follows from the set, so `about` should say what the value
552    /// *is* and leave the syntax to this. Reading is the host's, at the moment
553    /// it chooses: [`crate::cli_spec::SourceSet::parse`] on the resolved
554    /// string, then `read` or `read_secret`.
555    #[must_use]
556    pub fn sources(mut self, sources: SourceSet) -> Self {
557        self.sources = Some(sources);
558        self
559    }
560
561    pub fn about(mut self, about: impl Into<String>) -> Self {
562        self.about = nonempty(about.into());
563        self
564    }
565
566    /// What a reader is told about this argument: what the value means, plus
567    /// how it may be sourced.
568    ///
569    /// Hosts declare those separately — `about` says what the value *is*, the
570    /// source set says where it may come from — and every rendering path joins
571    /// them here. That is the whole reason the set is declared rather than
572    /// written into prose: a single flag's syntax is otherwise repeated across
573    /// every row of a generated reference that mentions it — dozens, for a
574    /// widely-used one — each a place to forget when a source is added.
575    #[must_use]
576    pub fn rendered_about(&self) -> Option<String> {
577        match (&self.about, &self.sources) {
578            (Some(about), Some(sources)) => Some(format!("{about} ({})", sources.syntax_summary())),
579            (Some(about), None) => Some(about.clone()),
580            (None, Some(sources)) => Some(sources.syntax_summary()),
581            (None, None) => None,
582        }
583    }
584}
585
586/// A finite fixed enum constraint.
587#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(untagged)]
589pub enum FixedValue {
590    Value(String),
591    OneOf { one_of: Vec<String> },
592}
593
594impl FixedValue {
595    pub(super) fn values(&self) -> &[String] {
596        match self {
597            Self::Value(value) => std::slice::from_ref(value),
598            Self::OneOf { one_of } => one_of,
599        }
600    }
601}
602
603/// One named legal application invocation shape.
604#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
605pub struct Combination {
606    pub combination_id: String,
607    pub action_id: String,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub about: Option<String>,
610    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
611    pub fixed: BTreeMap<String, FixedValue>,
612    #[serde(default, skip_serializing_if = "Vec::is_empty")]
613    pub required: Vec<String>,
614    #[serde(default, skip_serializing_if = "Vec::is_empty")]
615    pub optional: Vec<String>,
616    pub output: OutputSpec,
617}
618
619impl Combination {
620    pub fn new(combination_id: impl Into<String>) -> Self {
621        Self {
622            combination_id: combination_id.into(),
623            action_id: String::new(),
624            about: None,
625            fixed: BTreeMap::new(),
626            required: Vec::new(),
627            optional: Vec::new(),
628            output: OutputSpec::protocol_finite(
629                ["json", "yaml", "plain"],
630                ["split", "stdout", "stderr"],
631                "json",
632                "split",
633            ),
634        }
635    }
636
637    pub fn action(mut self, action_id: impl Into<String>) -> Self {
638        self.action_id = action_id.into();
639        self
640    }
641
642    pub fn about(mut self, about: impl Into<String>) -> Self {
643        self.about = nonempty(about.into());
644        self
645    }
646
647    pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
648        self.fixed
649            .insert(argument_id.into(), FixedValue::Value(value.into()));
650        self
651    }
652
653    pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
654    where
655        I: IntoIterator<Item = S>,
656        S: Into<String>,
657    {
658        self.fixed.insert(
659            argument_id.into(),
660            FixedValue::OneOf {
661                one_of: values.into_iter().map(Into::into).collect(),
662            },
663        );
664        self
665    }
666
667    pub fn required<I, S>(mut self, argument_ids: I) -> Self
668    where
669        I: IntoIterator<Item = S>,
670        S: Into<String>,
671    {
672        self.required
673            .extend(argument_ids.into_iter().map(Into::into));
674        self
675    }
676
677    pub fn optional<I, S>(mut self, argument_ids: I) -> Self
678    where
679        I: IntoIterator<Item = S>,
680        S: Into<String>,
681    {
682        self.optional
683            .extend(argument_ids.into_iter().map(Into::into));
684        self
685    }
686
687    pub fn output(mut self, output: OutputSpec) -> Self {
688        self.output = output;
689        self
690    }
691}
692
693/// Protocol output lifecycle.
694#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
695#[serde(rename_all = "snake_case")]
696pub enum OutputLifecycle {
697    Finite,
698    Stream,
699}
700
701/// Closed output contract for one combination.
702#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(tag = "kind", rename_all = "snake_case")]
704pub enum OutputSpec {
705    Raw {
706        #[serde(default)]
707        file_sinks: Vec<String>,
708    },
709    Protocol {
710        lifecycle: OutputLifecycle,
711        formats: Vec<String>,
712        destinations: Vec<String>,
713        default_format: String,
714        default_destination: String,
715        #[serde(default)]
716        file_sinks: Vec<String>,
717    },
718}
719
720impl OutputSpec {
721    pub fn raw() -> Self {
722        Self::Raw {
723            file_sinks: Vec::new(),
724        }
725    }
726
727    pub fn protocol_finite<FI, FS, DI, DS>(
728        formats: FI,
729        destinations: DI,
730        default_format: impl Into<String>,
731        default_destination: impl Into<String>,
732    ) -> Self
733    where
734        FI: IntoIterator<Item = FS>,
735        FS: Into<String>,
736        DI: IntoIterator<Item = DS>,
737        DS: Into<String>,
738    {
739        Self::Protocol {
740            lifecycle: OutputLifecycle::Finite,
741            formats: formats.into_iter().map(Into::into).collect(),
742            destinations: destinations.into_iter().map(Into::into).collect(),
743            default_format: default_format.into(),
744            default_destination: default_destination.into(),
745            file_sinks: Vec::new(),
746        }
747    }
748
749    pub fn protocol_stream<FI, FS, DI, DS>(
750        formats: FI,
751        destinations: DI,
752        default_format: impl Into<String>,
753        default_destination: impl Into<String>,
754    ) -> Self
755    where
756        FI: IntoIterator<Item = FS>,
757        FS: Into<String>,
758        DI: IntoIterator<Item = DS>,
759        DS: Into<String>,
760    {
761        Self::Protocol {
762            lifecycle: OutputLifecycle::Stream,
763            formats: formats.into_iter().map(Into::into).collect(),
764            destinations: destinations.into_iter().map(Into::into).collect(),
765            default_format: default_format.into(),
766            default_destination: default_destination.into(),
767            file_sinks: Vec::new(),
768        }
769    }
770
771    pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
772    where
773        I: IntoIterator<Item = S>,
774        S: Into<String>,
775    {
776        let values = sinks.into_iter().map(Into::into).collect();
777        match &mut self {
778            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
779                *file_sinks = values;
780            }
781        }
782        self
783    }
784
785    pub(super) fn file_sinks_ref(&self) -> &[String] {
786        match self {
787            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
788        }
789    }
790}
791
792/// Stable build-time registry error.
793#[derive(Clone, Debug, PartialEq, Eq)]
794pub struct CliSpecError {
795    pub rule: &'static str,
796    pub message: String,
797}
798
799impl CliSpecError {
800    pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
801        Self {
802            rule,
803            message: message.into(),
804        }
805    }
806}
807
808impl std::fmt::Display for CliSpecError {
809    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810        write!(f, "{}: {}", self.rule, self.message)
811    }
812}
813
814impl std::error::Error for CliSpecError {}