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 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#[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#[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 Uuid,
264}
265
266#[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 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#[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 #[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 #[serde(default, skip_serializing_if = "is_false")]
359 pub sensitive: bool,
360 #[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 #[must_use]
410 pub fn uuid(mut self) -> Self {
411 self.value_type = ArgValueType::Uuid;
412 self
413 }
414
415 #[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 pub fn sensitive(mut self) -> Self {
524 self.sensitive = true;
525 self
526 }
527
528 #[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 #[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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
674#[serde(rename_all = "snake_case")]
675pub enum OutputLifecycle {
676 Finite,
677 Stream,
678}
679
680#[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#[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 {}