1use super::build::validate_spec;
2use super::*;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
8pub struct CliSpec {
9 pub schema: String,
10 pub name: String,
11 pub version: String,
12 #[serde(skip_serializing_if = "Option::is_none")]
14 pub display_name: Option<String>,
15 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub shared_arguments: Vec<ArgSpec>,
29 pub lifecycle_output: OutputSpec,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub exit_codes: Vec<ExitCodeSpec>,
37 pub commands: Vec<CommandSpec>,
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct ExitCodeSpec {
43 pub code: u8,
44 pub meaning: String,
46}
47
48impl CliSpec {
49 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 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 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 #[must_use]
124 pub fn shared_arg(mut self, argument: ArgSpec) -> Self {
125 self.shared_arguments.push(argument);
126 self
127 }
128
129 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 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 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 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#[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 #[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 #[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#[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#[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 Uuid,
285}
286
287#[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 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#[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 #[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 #[serde(default, skip_serializing_if = "is_false")]
380 pub sensitive: bool,
381 #[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 #[must_use]
431 pub fn uuid(mut self) -> Self {
432 self.value_type = ArgValueType::Uuid;
433 self
434 }
435
436 #[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 pub fn sensitive(mut self) -> Self {
545 self.sensitive = true;
546 self
547 }
548
549 #[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 #[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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
695#[serde(rename_all = "snake_case")]
696pub enum OutputLifecycle {
697 Finite,
698 Stream,
699}
700
701#[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#[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 {}