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