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(skip_serializing_if = "Option::is_none")]
361 pub about: Option<String>,
362}
363
364impl ArgSpec {
365 pub fn flag(long: impl Into<String>) -> Self {
366 Self::long(long, ArgValueType::Flag, None::<String>)
367 }
368
369 pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
370 Self::long(long, ArgValueType::String, Some(value_name.into()))
371 }
372
373 pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
374 Self::long(long, ArgValueType::I64, Some(value_name.into()))
375 }
376
377 pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
378 Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
379 }
380
381 pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
382 Self::long(long, ArgValueType::Json, Some(value_name.into()))
383 }
384
385 pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
386 where
387 I: IntoIterator<Item = S>,
388 S: Into<String>,
389 {
390 let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
391 spec.enum_values = values.into_iter().map(Into::into).collect();
392 spec
393 }
394
395 #[must_use]
400 pub fn uuid(mut self) -> Self {
401 self.value_type = ArgValueType::Uuid;
402 self
403 }
404
405 #[must_use]
411 pub fn range(mut self, minimum: i64, maximum: i64) -> Self {
412 self.value_type = ArgValueType::I64;
413 self.range = Some([minimum, maximum]);
414 self
415 }
416
417 pub fn positional(
418 argument_id: impl Into<String>,
419 index: usize,
420 value_name: impl Into<String>,
421 ) -> Self {
422 Self {
423 argument_id: argument_id.into(),
424 syntax: ArgSyntax::Positional { index },
425 value_type: ArgValueType::String,
426 value_name: nonempty(value_name.into()),
427 enum_values: Vec::new(),
428 range: None,
429 default: None,
430 repeatable: false,
431 sensitive: false,
432 about: None,
433 }
434 }
435
436 pub fn positional_json(
437 argument_id: impl Into<String>,
438 index: usize,
439 value_name: impl Into<String>,
440 ) -> Self {
441 Self {
442 value_type: ArgValueType::Json,
443 ..Self::positional(argument_id, index, value_name)
444 }
445 }
446
447 pub fn positional_enum<I, S>(
448 argument_id: impl Into<String>,
449 index: usize,
450 value_name: impl Into<String>,
451 values: I,
452 ) -> Self
453 where
454 I: IntoIterator<Item = S>,
455 S: Into<String>,
456 {
457 let mut spec = Self {
458 value_type: ArgValueType::Enum,
459 ..Self::positional(argument_id, index, value_name)
460 };
461 spec.enum_values = values.into_iter().map(Into::into).collect();
462 spec
463 }
464
465 fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
466 let long = long.into();
467 let argument_id = long
468 .strip_prefix("--")
469 .unwrap_or(long.as_str())
470 .replace('-', "_");
471 Self {
472 argument_id,
473 syntax: ArgSyntax::Long { name: long },
474 value_type,
475 value_name: value_name.and_then(nonempty),
476 enum_values: Vec::new(),
477 range: None,
478 default: None,
479 repeatable: false,
480 sensitive: false,
481 about: None,
482 }
483 }
484
485 pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
486 self.value_name = nonempty(value_name.into());
487 self
488 }
489
490 pub fn default(mut self, value: impl Into<String>) -> Self {
491 self.default = Some(CliValue::String(value.into()));
492 self
493 }
494
495 pub fn default_i64(mut self, value: i64) -> Self {
496 self.default = Some(CliValue::I64(value));
497 self
498 }
499
500 pub fn default_f64(mut self, value: f64) -> Self {
501 self.default = Some(CliValue::FiniteF64(value));
502 self
503 }
504
505 pub fn repeatable(mut self) -> Self {
506 self.repeatable = true;
507 self
508 }
509
510 pub fn sensitive(mut self) -> Self {
512 self.sensitive = true;
513 self
514 }
515
516 pub fn about(mut self, about: impl Into<String>) -> Self {
517 self.about = nonempty(about.into());
518 self
519 }
520}
521
522#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(untagged)]
525pub enum FixedValue {
526 Value(String),
527 OneOf { one_of: Vec<String> },
528}
529
530impl FixedValue {
531 pub(super) fn values(&self) -> &[String] {
532 match self {
533 Self::Value(value) => std::slice::from_ref(value),
534 Self::OneOf { one_of } => one_of,
535 }
536 }
537}
538
539#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
541pub struct Combination {
542 pub combination_id: String,
543 pub action_id: String,
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub about: Option<String>,
546 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
547 pub fixed: BTreeMap<String, FixedValue>,
548 #[serde(default, skip_serializing_if = "Vec::is_empty")]
549 pub required: Vec<String>,
550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
551 pub optional: Vec<String>,
552 pub output: OutputSpec,
553}
554
555impl Combination {
556 pub fn new(combination_id: impl Into<String>) -> Self {
557 Self {
558 combination_id: combination_id.into(),
559 action_id: String::new(),
560 about: None,
561 fixed: BTreeMap::new(),
562 required: Vec::new(),
563 optional: Vec::new(),
564 output: OutputSpec::protocol_finite(
565 ["json", "yaml", "plain"],
566 ["split", "stdout", "stderr"],
567 "json",
568 "split",
569 ),
570 }
571 }
572
573 pub fn action(mut self, action_id: impl Into<String>) -> Self {
574 self.action_id = action_id.into();
575 self
576 }
577
578 pub fn about(mut self, about: impl Into<String>) -> Self {
579 self.about = nonempty(about.into());
580 self
581 }
582
583 pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
584 self.fixed
585 .insert(argument_id.into(), FixedValue::Value(value.into()));
586 self
587 }
588
589 pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
590 where
591 I: IntoIterator<Item = S>,
592 S: Into<String>,
593 {
594 self.fixed.insert(
595 argument_id.into(),
596 FixedValue::OneOf {
597 one_of: values.into_iter().map(Into::into).collect(),
598 },
599 );
600 self
601 }
602
603 pub fn required<I, S>(mut self, argument_ids: I) -> Self
604 where
605 I: IntoIterator<Item = S>,
606 S: Into<String>,
607 {
608 self.required
609 .extend(argument_ids.into_iter().map(Into::into));
610 self
611 }
612
613 pub fn optional<I, S>(mut self, argument_ids: I) -> Self
614 where
615 I: IntoIterator<Item = S>,
616 S: Into<String>,
617 {
618 self.optional
619 .extend(argument_ids.into_iter().map(Into::into));
620 self
621 }
622
623 pub fn output(mut self, output: OutputSpec) -> Self {
624 self.output = output;
625 self
626 }
627}
628
629#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(rename_all = "snake_case")]
632pub enum OutputLifecycle {
633 Finite,
634 Stream,
635}
636
637#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
639#[serde(tag = "kind", rename_all = "snake_case")]
640pub enum OutputSpec {
641 Raw {
642 #[serde(default)]
643 file_sinks: Vec<String>,
644 },
645 Protocol {
646 lifecycle: OutputLifecycle,
647 formats: Vec<String>,
648 destinations: Vec<String>,
649 default_format: String,
650 default_destination: String,
651 #[serde(default)]
652 file_sinks: Vec<String>,
653 },
654}
655
656impl OutputSpec {
657 pub fn raw() -> Self {
658 Self::Raw {
659 file_sinks: Vec::new(),
660 }
661 }
662
663 pub fn protocol_finite<FI, FS, DI, DS>(
664 formats: FI,
665 destinations: DI,
666 default_format: impl Into<String>,
667 default_destination: impl Into<String>,
668 ) -> Self
669 where
670 FI: IntoIterator<Item = FS>,
671 FS: Into<String>,
672 DI: IntoIterator<Item = DS>,
673 DS: Into<String>,
674 {
675 Self::Protocol {
676 lifecycle: OutputLifecycle::Finite,
677 formats: formats.into_iter().map(Into::into).collect(),
678 destinations: destinations.into_iter().map(Into::into).collect(),
679 default_format: default_format.into(),
680 default_destination: default_destination.into(),
681 file_sinks: Vec::new(),
682 }
683 }
684
685 pub fn protocol_stream<FI, FS, DI, DS>(
686 formats: FI,
687 destinations: DI,
688 default_format: impl Into<String>,
689 default_destination: impl Into<String>,
690 ) -> Self
691 where
692 FI: IntoIterator<Item = FS>,
693 FS: Into<String>,
694 DI: IntoIterator<Item = DS>,
695 DS: Into<String>,
696 {
697 Self::Protocol {
698 lifecycle: OutputLifecycle::Stream,
699 formats: formats.into_iter().map(Into::into).collect(),
700 destinations: destinations.into_iter().map(Into::into).collect(),
701 default_format: default_format.into(),
702 default_destination: default_destination.into(),
703 file_sinks: Vec::new(),
704 }
705 }
706
707 pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
708 where
709 I: IntoIterator<Item = S>,
710 S: Into<String>,
711 {
712 let values = sinks.into_iter().map(Into::into).collect();
713 match &mut self {
714 Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
715 *file_sinks = values;
716 }
717 }
718 self
719 }
720
721 pub(super) fn file_sinks_ref(&self) -> &[String] {
722 match self {
723 Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
724 }
725 }
726}
727
728#[derive(Clone, Debug, PartialEq, Eq)]
730pub struct CliSpecError {
731 pub rule: &'static str,
732 pub message: String,
733}
734
735impl CliSpecError {
736 pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
737 Self {
738 rule,
739 message: message.into(),
740 }
741 }
742}
743
744impl std::fmt::Display for CliSpecError {
745 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746 write!(f, "{}: {}", self.rule, self.message)
747 }
748}
749
750impl std::error::Error for CliSpecError {}