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 pub lifecycle_output: OutputSpec,
22 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub exit_codes: Vec<ExitCodeSpec>,
29 pub commands: Vec<CommandSpec>,
30}
31
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct ExitCodeSpec {
35 pub code: u8,
36 pub meaning: String,
38}
39
40impl CliSpec {
41 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
43 Self {
44 schema: "cli-spec-v1".to_string(),
45 name: name.into(),
46 version: version.into(),
47 display_name: None,
48 build: None,
49 about: None,
50 lifecycle_output: OutputSpec::protocol_finite(
51 ["json", "yaml", "plain"],
52 ["split", "stdout", "stderr"],
53 "json",
54 "split",
55 ),
56 exit_codes: Vec::new(),
57 commands: Vec::new(),
58 }
59 }
60
61 pub fn about(mut self, about: impl Into<String>) -> Self {
62 self.about = nonempty(about.into());
63 self
64 }
65
66 pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
67 self.display_name = nonempty(display_name.into());
68 self
69 }
70
71 pub fn build_id(mut self, build: impl Into<String>) -> Self {
74 self.build = nonempty(build.into());
75 self
76 }
77
78 pub fn lifecycle_output(mut self, output: OutputSpec) -> Self {
79 self.lifecycle_output = output;
80 self
81 }
82
83 pub fn exit_code(mut self, code: u8, meaning: impl Into<String>) -> Self {
86 self.exit_codes.push(ExitCodeSpec {
87 code,
88 meaning: meaning.into(),
89 });
90 self
91 }
92
93 pub fn command(mut self, command: CommandSpec) -> Self {
94 self.commands.push(command);
95 self
96 }
97
98 pub fn build(self) -> Result<BuiltCliSpec, CliSpecError> {
100 validate_spec(&self)?;
101 Ok(BuiltCliSpec { spec: self })
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct CommandSpec {
108 pub command_path: Vec<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub about: Option<String>,
111 pub arguments: Vec<ArgSpec>,
112 pub combinations: Vec<Combination>,
113}
114
115impl CommandSpec {
116 pub fn root() -> Self {
117 Self::new(std::iter::empty::<String>())
118 }
119
120 pub fn new<I, S>(command_path: I) -> Self
121 where
122 I: IntoIterator<Item = S>,
123 S: Into<String>,
124 {
125 Self {
126 command_path: command_path.into_iter().map(Into::into).collect(),
127 about: None,
128 arguments: Vec::new(),
129 combinations: Vec::new(),
130 }
131 }
132
133 pub fn about(mut self, about: impl Into<String>) -> Self {
134 self.about = nonempty(about.into());
135 self
136 }
137
138 pub fn arg(mut self, argument: ArgSpec) -> Self {
139 self.arguments.push(argument);
140 self
141 }
142
143 pub fn combination(mut self, combination: Combination) -> Self {
144 self.combinations.push(combination);
145 self
146 }
147}
148
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum ArgSyntax {
153 Long { name: String },
154 Positional { index: usize },
155}
156
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ArgValueType {
161 Flag,
162 String,
163 I64,
164 FiniteF64,
165 Enum,
166 Json,
167}
168
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
178#[serde(untagged)]
179pub enum CliValue {
180 Bool(bool),
181 String(String),
182 I64(i64),
183 FiniteF64(f64),
184 Json(String),
185 List(Vec<CliValue>),
186}
187
188impl CliValue {
189 pub fn as_bool(&self) -> Option<bool> {
190 match self {
191 Self::Bool(value) => Some(*value),
192 _ => None,
193 }
194 }
195
196 pub fn as_str(&self) -> Option<&str> {
197 match self {
198 Self::String(value) => Some(value),
199 _ => None,
200 }
201 }
202
203 pub fn as_i64(&self) -> Option<i64> {
204 match self {
205 Self::I64(value) => Some(*value),
206 _ => None,
207 }
208 }
209
210 pub fn as_f64(&self) -> Option<f64> {
211 match self {
212 Self::FiniteF64(value) => Some(*value),
213 _ => None,
214 }
215 }
216
217 pub fn as_json_str(&self) -> Option<&str> {
219 match self {
220 Self::Json(value) => Some(value),
221 _ => None,
222 }
223 }
224
225 pub fn as_list(&self) -> Option<&[CliValue]> {
226 match self {
227 Self::List(values) => Some(values),
228 _ => None,
229 }
230 }
231}
232
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct ArgSpec {
236 pub argument_id: String,
237 pub syntax: ArgSyntax,
238 pub value_type: ArgValueType,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub value_name: Option<String>,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub enum_values: Vec<String>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub default: Option<CliValue>,
245 #[serde(default, skip_serializing_if = "is_false")]
246 pub repeatable: bool,
247 #[serde(default, skip_serializing_if = "is_false")]
254 pub sensitive: bool,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub about: Option<String>,
257}
258
259impl ArgSpec {
260 pub fn flag(long: impl Into<String>) -> Self {
261 Self::long(long, ArgValueType::Flag, None::<String>)
262 }
263
264 pub fn option(long: impl Into<String>, value_name: impl Into<String>) -> Self {
265 Self::long(long, ArgValueType::String, Some(value_name.into()))
266 }
267
268 pub fn option_i64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
269 Self::long(long, ArgValueType::I64, Some(value_name.into()))
270 }
271
272 pub fn option_f64(long: impl Into<String>, value_name: impl Into<String>) -> Self {
273 Self::long(long, ArgValueType::FiniteF64, Some(value_name.into()))
274 }
275
276 pub fn option_json(long: impl Into<String>, value_name: impl Into<String>) -> Self {
277 Self::long(long, ArgValueType::Json, Some(value_name.into()))
278 }
279
280 pub fn option_enum<I, S>(long: impl Into<String>, values: I) -> Self
281 where
282 I: IntoIterator<Item = S>,
283 S: Into<String>,
284 {
285 let mut spec = Self::long(long, ArgValueType::Enum, Some("VALUE".to_string()));
286 spec.enum_values = values.into_iter().map(Into::into).collect();
287 spec
288 }
289
290 pub fn positional(
291 argument_id: impl Into<String>,
292 index: usize,
293 value_name: impl Into<String>,
294 ) -> Self {
295 Self {
296 argument_id: argument_id.into(),
297 syntax: ArgSyntax::Positional { index },
298 value_type: ArgValueType::String,
299 value_name: nonempty(value_name.into()),
300 enum_values: Vec::new(),
301 default: None,
302 repeatable: false,
303 sensitive: false,
304 about: None,
305 }
306 }
307
308 pub fn positional_json(
309 argument_id: impl Into<String>,
310 index: usize,
311 value_name: impl Into<String>,
312 ) -> Self {
313 Self {
314 value_type: ArgValueType::Json,
315 ..Self::positional(argument_id, index, value_name)
316 }
317 }
318
319 pub fn positional_enum<I, S>(
320 argument_id: impl Into<String>,
321 index: usize,
322 value_name: impl Into<String>,
323 values: I,
324 ) -> Self
325 where
326 I: IntoIterator<Item = S>,
327 S: Into<String>,
328 {
329 let mut spec = Self {
330 value_type: ArgValueType::Enum,
331 ..Self::positional(argument_id, index, value_name)
332 };
333 spec.enum_values = values.into_iter().map(Into::into).collect();
334 spec
335 }
336
337 fn long(long: impl Into<String>, value_type: ArgValueType, value_name: Option<String>) -> Self {
338 let long = long.into();
339 let argument_id = long
340 .strip_prefix("--")
341 .unwrap_or(long.as_str())
342 .replace('-', "_");
343 Self {
344 argument_id,
345 syntax: ArgSyntax::Long { name: long },
346 value_type,
347 value_name: value_name.and_then(nonempty),
348 enum_values: Vec::new(),
349 default: None,
350 repeatable: false,
351 sensitive: false,
352 about: None,
353 }
354 }
355
356 pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
357 self.value_name = nonempty(value_name.into());
358 self
359 }
360
361 pub fn default(mut self, value: impl Into<String>) -> Self {
362 self.default = Some(CliValue::String(value.into()));
363 self
364 }
365
366 pub fn default_i64(mut self, value: i64) -> Self {
367 self.default = Some(CliValue::I64(value));
368 self
369 }
370
371 pub fn default_f64(mut self, value: f64) -> Self {
372 self.default = Some(CliValue::FiniteF64(value));
373 self
374 }
375
376 pub fn repeatable(mut self) -> Self {
377 self.repeatable = true;
378 self
379 }
380
381 pub fn sensitive(mut self) -> Self {
383 self.sensitive = true;
384 self
385 }
386
387 pub fn about(mut self, about: impl Into<String>) -> Self {
388 self.about = nonempty(about.into());
389 self
390 }
391}
392
393#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(untagged)]
396pub enum FixedValue {
397 Value(String),
398 OneOf { one_of: Vec<String> },
399}
400
401impl FixedValue {
402 pub(super) fn values(&self) -> &[String] {
403 match self {
404 Self::Value(value) => std::slice::from_ref(value),
405 Self::OneOf { one_of } => one_of,
406 }
407 }
408}
409
410#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
412pub struct Combination {
413 pub combination_id: String,
414 pub action_id: String,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub about: Option<String>,
417 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
418 pub fixed: BTreeMap<String, FixedValue>,
419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
420 pub required: Vec<String>,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
422 pub optional: Vec<String>,
423 pub output: OutputSpec,
424}
425
426impl Combination {
427 pub fn new(combination_id: impl Into<String>) -> Self {
428 Self {
429 combination_id: combination_id.into(),
430 action_id: String::new(),
431 about: None,
432 fixed: BTreeMap::new(),
433 required: Vec::new(),
434 optional: Vec::new(),
435 output: OutputSpec::protocol_finite(
436 ["json", "yaml", "plain"],
437 ["split", "stdout", "stderr"],
438 "json",
439 "split",
440 ),
441 }
442 }
443
444 pub fn action(mut self, action_id: impl Into<String>) -> Self {
445 self.action_id = action_id.into();
446 self
447 }
448
449 pub fn about(mut self, about: impl Into<String>) -> Self {
450 self.about = nonempty(about.into());
451 self
452 }
453
454 pub fn fixed(mut self, argument_id: impl Into<String>, value: impl Into<String>) -> Self {
455 self.fixed
456 .insert(argument_id.into(), FixedValue::Value(value.into()));
457 self
458 }
459
460 pub fn fixed_one_of<I, S>(mut self, argument_id: impl Into<String>, values: I) -> Self
461 where
462 I: IntoIterator<Item = S>,
463 S: Into<String>,
464 {
465 self.fixed.insert(
466 argument_id.into(),
467 FixedValue::OneOf {
468 one_of: values.into_iter().map(Into::into).collect(),
469 },
470 );
471 self
472 }
473
474 pub fn required<I, S>(mut self, argument_ids: I) -> Self
475 where
476 I: IntoIterator<Item = S>,
477 S: Into<String>,
478 {
479 self.required
480 .extend(argument_ids.into_iter().map(Into::into));
481 self
482 }
483
484 pub fn optional<I, S>(mut self, argument_ids: I) -> Self
485 where
486 I: IntoIterator<Item = S>,
487 S: Into<String>,
488 {
489 self.optional
490 .extend(argument_ids.into_iter().map(Into::into));
491 self
492 }
493
494 pub fn output(mut self, output: OutputSpec) -> Self {
495 self.output = output;
496 self
497 }
498}
499
500#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum OutputLifecycle {
504 Finite,
505 Stream,
506}
507
508#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(tag = "kind", rename_all = "snake_case")]
511pub enum OutputSpec {
512 Raw {
513 #[serde(default)]
514 file_sinks: Vec<String>,
515 },
516 Protocol {
517 lifecycle: OutputLifecycle,
518 formats: Vec<String>,
519 destinations: Vec<String>,
520 default_format: String,
521 default_destination: String,
522 #[serde(default)]
523 file_sinks: Vec<String>,
524 },
525}
526
527impl OutputSpec {
528 pub fn raw() -> Self {
529 Self::Raw {
530 file_sinks: Vec::new(),
531 }
532 }
533
534 pub fn protocol_finite<FI, FS, DI, DS>(
535 formats: FI,
536 destinations: DI,
537 default_format: impl Into<String>,
538 default_destination: impl Into<String>,
539 ) -> Self
540 where
541 FI: IntoIterator<Item = FS>,
542 FS: Into<String>,
543 DI: IntoIterator<Item = DS>,
544 DS: Into<String>,
545 {
546 Self::Protocol {
547 lifecycle: OutputLifecycle::Finite,
548 formats: formats.into_iter().map(Into::into).collect(),
549 destinations: destinations.into_iter().map(Into::into).collect(),
550 default_format: default_format.into(),
551 default_destination: default_destination.into(),
552 file_sinks: Vec::new(),
553 }
554 }
555
556 pub fn protocol_stream<FI, FS, DI, DS>(
557 formats: FI,
558 destinations: DI,
559 default_format: impl Into<String>,
560 default_destination: impl Into<String>,
561 ) -> Self
562 where
563 FI: IntoIterator<Item = FS>,
564 FS: Into<String>,
565 DI: IntoIterator<Item = DS>,
566 DS: Into<String>,
567 {
568 Self::Protocol {
569 lifecycle: OutputLifecycle::Stream,
570 formats: formats.into_iter().map(Into::into).collect(),
571 destinations: destinations.into_iter().map(Into::into).collect(),
572 default_format: default_format.into(),
573 default_destination: default_destination.into(),
574 file_sinks: Vec::new(),
575 }
576 }
577
578 pub fn file_sinks<I, S>(mut self, sinks: I) -> Self
579 where
580 I: IntoIterator<Item = S>,
581 S: Into<String>,
582 {
583 let values = sinks.into_iter().map(Into::into).collect();
584 match &mut self {
585 Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => {
586 *file_sinks = values;
587 }
588 }
589 self
590 }
591
592 pub(super) fn file_sinks_ref(&self) -> &[String] {
593 match self {
594 Self::Raw { file_sinks } | Self::Protocol { file_sinks, .. } => file_sinks,
595 }
596 }
597}
598
599#[derive(Clone, Debug, PartialEq, Eq)]
601pub struct CliSpecError {
602 pub rule: &'static str,
603 pub message: String,
604}
605
606impl CliSpecError {
607 pub(super) fn new(rule: &'static str, message: impl Into<String>) -> Self {
608 Self {
609 rule,
610 message: message.into(),
611 }
612 }
613}
614
615impl std::fmt::Display for CliSpecError {
616 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617 write!(f, "{}: {}", self.rule, self.message)
618 }
619}
620
621impl std::error::Error for CliSpecError {}