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    /// The sources this argument accepts beside a literal value.
361    ///
362    /// Declared rather than assumed: a source turns an argument into a reader
363    /// of files and environment variables, which is right for a credential and
364    /// wrong for most everything else. The core validates that a value names
365    /// only a scheme in this set, and renders the syntax into help so no host
366    /// repeats it in an `about` string. Reading happens in the host, when it
367    /// chooses — see [`crate::value_source`].
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub sources: Option<SourceSet>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub about: Option<String>,
372}
373
374impl ArgSpec {
375    pub fn flag(long: impl Into<String>) -> Self {
376        Self::long(long, ArgValueType::Flag, None::<String>)
377    }
378
379    pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
380        Self::long(long, ArgValueType::String, Some(value_name.into()))
381    }
382
383    pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
384        Self::long(long, ArgValueType::I64, Some(value_name.into()))
385    }
386
387    pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
388        Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
389    }
390
391    pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
392        Self::long(long, ArgValueType::Json, Some(value_name.into()))
393    }
394
395    pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
396    where
397        I: IntoIterator<Item = S>,
398        S: Into<String>,
399    {
400        let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
401        spec.enum_values = values.into_iter().map(Into::into).collect();
402        spec
403    }
404
405    /// Read this argument as a canonical RFC 4122 UUID.
406    ///
407    /// The resolved value is still a `CliValue::String`; what changes is that a
408    /// malformed one is rejected as a usage error before the command runs.
409    #[must_use]
410    pub fn uuid(mut self) -> Self {
411        self.value_type = ArgValueType::Uuid;
412        self
413    }
414
415    /// Constrain an `I64` argument to an inclusive range.
416    ///
417    /// Use it for a count that must fit a narrower integer than `i64` — the
418    /// check then reports as a usage error, beside the other argument
419    /// failures, rather than as a domain failure from inside the handler.
420    #[must_use]
421    pub fn range(mut self, minimum: i64, maximum: i64) -> Self {
422        self.value_type = ArgValueType::I64;
423        self.range = Some([minimum, maximum]);
424        self
425    }
426
427    pub fn positional(
428        argument_id: impl Into<String>,
429        index: usize,
430        value_name: impl Into<String>,
431    ) -> Self {
432        Self {
433            argument_id: argument_id.into(),
434            syntax: ArgSyntax::Positional { index },
435            value_type: ArgValueType::String,
436            value_name: nonempty(value_name.into()),
437            enum_values: Vec::new(),
438            range: None,
439            default: None,
440            repeatable: false,
441            sensitive: false,
442            sources: None,
443            about: None,
444        }
445    }
446
447    pub fn positional_json(
448        argument_id: impl Into<String>,
449        index: usize,
450        value_name: impl Into<String>,
451    ) -> Self {
452        Self {
453            value_type: ArgValueType::Json,
454            ..Self::positional(argument_id, index, value_name)
455        }
456    }
457
458    pub fn positional_enum<I, S>(
459        argument_id: impl Into<String>,
460        index: usize,
461        value_name: impl Into<String>,
462        values: I,
463    ) -> Self
464    where
465        I: IntoIterator<Item = S>,
466        S: Into<String>,
467    {
468        let mut spec = Self {
469            value_type: ArgValueType::Enum,
470            ..Self::positional(argument_id, index, value_name)
471        };
472        spec.enum_values = values.into_iter().map(Into::into).collect();
473        spec
474    }
475
476    fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
477        let long = long.into();
478        let argument_id = long
479            .strip_prefix("--")
480            .unwrap_or(long.as_str())
481            .replace('-', "_");
482        Self {
483            argument_id,
484            syntax: ArgSyntax::Long { name: long },
485            value_type,
486            value_name: value_name.and_then(nonempty),
487            enum_values: Vec::new(),
488            range: None,
489            default: None,
490            repeatable: false,
491            sensitive: false,
492            sources: None,
493            about: None,
494        }
495    }
496
497    pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
498        self.value_name = nonempty(value_name.into());
499        self
500    }
501
502    pub fn default(mut self, value: impl Into<String>) -> Self {
503        self.default = Some(CliValue::String(value.into()));
504        self
505    }
506
507    pub fn default_i64(mut self, value: i64) -> Self {
508        self.default = Some(CliValue::I64(value));
509        self
510    }
511
512    pub fn default_f64(mut self, value: f64) -> Self {
513        self.default = Some(CliValue::FiniteF64(value));
514        self
515    }
516
517    pub fn repeatable(mut self) -> Self {
518        self.repeatable = true;
519        self
520    }
521
522    /// Mark this argument's value as one that must never be echoed back.
523    pub fn sensitive(mut self) -> Self {
524        self.sensitive = true;
525        self
526    }
527
528    /// Accept the value indirectly, from any source in `sources`.
529    ///
530    /// The help text follows from the set, so `about` should say what the value
531    /// *is* and leave the syntax to this. Reading is the host's, at the moment
532    /// it chooses: [`crate::cli_spec::SourceSet::parse`] on the resolved
533    /// string, then `read` or `read_secret`.
534    #[must_use]
535    pub fn sources(mut self, sources: SourceSet) -> Self {
536        self.sources = Some(sources);
537        self
538    }
539
540    pub fn about(mut self, about: impl Into<String>) -> Self {
541        self.about = nonempty(about.into());
542        self
543    }
544
545    /// What a reader is told about this argument: what the value means, plus
546    /// how it may be sourced.
547    ///
548    /// Hosts declare those separately — `about` says what the value *is*, the
549    /// source set says where it may come from — and every rendering path joins
550    /// them here. That is the whole reason the set is declared rather than
551    /// written into prose: a single flag's syntax is otherwise repeated across
552    /// every row of a generated reference that mentions it — dozens, for a
553    /// widely-used one — each a place to forget when a source is added.
554    #[must_use]
555    pub fn rendered_about(&self) -> Option<String> {
556        match (&self.about, &self.sources) {
557            (Some(about), Some(sources)) => Some(format!("{about} ({})", sources.syntax_summary())),
558            (Some(about), None) => Some(about.clone()),
559            (None, Some(sources)) => Some(sources.syntax_summary()),
560            (None, None) => None,
561        }
562    }
563}
564
565/// A finite fixed enum constraint.
566#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
567#[serde(untagged)]
568pub enum FixedValue {
569    Value(String),
570    OneOf { one_of: Vec<String> },
571}
572
573impl FixedValue {
574    pub(super) fn values(&self) -> &[String] {
575        match self {
576            Self::Value(value) => std::slice::from_ref(value),
577            Self::OneOf { one_of } => one_of,
578        }
579    }
580}
581
582/// One named legal application invocation shape.
583#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
584pub struct Combination {
585    pub combination_id: String,
586    pub action_id: String,
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub about: Option<String>,
589    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
590    pub fixed: BTreeMap<String, FixedValue>,
591    #[serde(default, skip_serializing_if = "Vec::is_empty")]
592    pub required: Vec<String>,
593    #[serde(default, skip_serializing_if = "Vec::is_empty")]
594    pub optional: Vec<String>,
595    pub output: OutputSpec,
596}
597
598impl Combination {
599    pub fn new(combination_id: impl Into<String>) -> Self {
600        Self {
601            combination_id: combination_id.into(),
602            action_id: String::new(),
603            about: None,
604            fixed: BTreeMap::new(),
605            required: Vec::new(),
606            optional: Vec::new(),
607            output: OutputSpec::protocol_finite(
608                ["json", "yaml", "plain"],
609                ["split", "stdout", "stderr"],
610                "json",
611                "split",
612            ),
613        }
614    }
615
616    pub fn action(mut self, action_id: impl Into<String>) -> Self {
617        self.action_id = action_id.into();
618        self
619    }
620
621    pub fn about(mut self, about: impl Into<String>) -> Self {
622        self.about = nonempty(about.into());
623        self
624    }
625
626    pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
627        self.fixed
628            .insert(argument_id.into(), FixedValue::Value(value.into()));
629        self
630    }
631
632    pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
633    where
634        I: IntoIterator<Item = S>,
635        S: Into<String>,
636    {
637        self.fixed.insert(
638            argument_id.into(),
639            FixedValue::OneOf {
640                one_of: values.into_iter().map(Into::into).collect(),
641            },
642        );
643        self
644    }
645
646    pub fn required<I, S>(mut self, argument_ids: I) -> Self
647    where
648        I: IntoIterator<Item = S>,
649        S: Into<String>,
650    {
651        self.required
652            .extend(argument_ids.into_iter().map(Into::into));
653        self
654    }
655
656    pub fn optional<I, S>(mut self, argument_ids: I) -> Self
657    where
658        I: IntoIterator<Item = S>,
659        S: Into<String>,
660    {
661        self.optional
662            .extend(argument_ids.into_iter().map(Into::into));
663        self
664    }
665
666    pub fn output(mut self, output: OutputSpec) -> Self {
667        self.output = output;
668        self
669    }
670}
671
672/// Protocol output lifecycle.
673#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
674#[serde(rename_all = "snake_case")]
675pub enum OutputLifecycle {
676    Finite,
677    Stream,
678}
679
680/// Closed output contract for one combination.
681#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(tag = "kind", rename_all = "snake_case")]
683pub enum OutputSpec {
684    Raw {
685        #[serde(default)]
686        file_sinks: Vec<String>,
687    },
688    Protocol {
689        lifecycle: OutputLifecycle,
690        formats: Vec<String>,
691        destinations: Vec<String>,
692        default_format: String,
693        default_destination: String,
694        #[serde(default)]
695        file_sinks: Vec<String>,
696    },
697}
698
699impl OutputSpec {
700    pub fn raw() -> Self {
701        Self::Raw {
702            file_sinks: Vec::new(),
703        }
704    }
705
706    pub fn protocol_finite<FI, FS, DI, DS>(
707        formats: FI,
708        destinations: DI,
709        default_format: impl Into<String>,
710        default_destination: impl Into<String>,
711    ) -> Self
712    where
713        FI: IntoIterator<Item = FS>,
714        FS: Into<String>,
715        DI: IntoIterator<Item = DS>,
716        DS: Into<String>,
717    {
718        Self::Protocol {
719            lifecycle: OutputLifecycle::Finite,
720            formats: formats.into_iter().map(Into::into).collect(),
721            destinations: destinations.into_iter().map(Into::into).collect(),
722            default_format: default_format.into(),
723            default_destination: default_destination.into(),
724            file_sinks: Vec::new(),
725        }
726    }
727
728    pub fn protocol_stream<FI, FS, DI, DS>(
729        formats: FI,
730        destinations: DI,
731        default_format: impl Into<String>,
732        default_destination: impl Into<String>,
733    ) -> Self
734    where
735        FI: IntoIterator<Item = FS>,
736        FS: Into<String>,
737        DI: IntoIterator<Item = DS>,
738        DS: Into<String>,
739    {
740        Self::Protocol {
741            lifecycle: OutputLifecycle::Stream,
742            formats: formats.into_iter().map(Into::into).collect(),
743            destinations: destinations.into_iter().map(Into::into).collect(),
744            default_format: default_format.into(),
745            default_destination: default_destination.into(),
746            file_sinks: Vec::new(),
747        }
748    }
749
750    pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
751    where
752        I: IntoIterator<Item = S>,
753        S: Into<String>,
754    {
755        let values = sinks.into_iter().map(Into::into).collect();
756        match &mut self {
757            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
758                *file_sinks = values;
759            }
760        }
761        self
762    }
763
764    pub(super) fn file_sinks_ref(&self) -> &[String] {
765        match self {
766            Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
767        }
768    }
769}
770
771/// Stable build-time registry error.
772#[derive(Clone, Debug, PartialEq, Eq)]
773pub struct CliSpecError {
774    pub rule: &'static str,
775    pub message: String,
776}
777
778impl CliSpecError {
779    pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
780        Self {
781            rule,
782            message: message.into(),
783        }
784    }
785}
786
787impl std::fmt::Display for CliSpecError {
788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        write!(f, "{}: {}", self.rule, self.message)
790    }
791}
792
793impl std::error::Error for CliSpecError {}