1use std::collections::{BTreeMap, BTreeSet};
8
9use clap::{Arg, Command};
10
11use crate::usage;
12
13#[cfg(feature = "surface-templates")]
14mod templates;
15#[cfg(feature = "surface-templates")]
16pub use templates::{
17 COMMANDS_FRAGMENT, INVOCATION_FRAGMENT, VERSION_FRAGMENT, add_fragments, environment, render,
18};
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
23pub struct Surface {
24 pub binary: String,
26 pub mount: String,
28 pub about: String,
30 pub version: Option<String>,
32 pub arguments: Vec<SurfaceArgument>,
34 pub inherited_arguments: Vec<SurfaceArgument>,
36 pub commands: Vec<SurfaceCommand>,
38 pub usage_kdl: String,
40 pub mount_line: String,
42 pub notes: BTreeMap<String, String>,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
48#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
49pub struct SurfaceCommand {
50 pub name: String,
52 pub path: String,
54 pub aliases: Vec<String>,
56 pub visible_aliases: Vec<String>,
58 pub hidden: bool,
60 pub about: String,
62 pub arguments: Vec<SurfaceArgument>,
67 pub inherited_arguments: Vec<SurfaceArgument>,
69 pub commands: Vec<Self>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
75#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
76pub struct SurfaceArgument {
77 pub id: String,
79 pub index: Option<usize>,
81 pub short: Option<char>,
83 pub long: Option<String>,
85 pub visible_short_aliases: Vec<char>,
87 pub short_aliases: Vec<char>,
89 pub visible_aliases: Vec<String>,
91 pub aliases: Vec<String>,
93 pub value_names: Vec<String>,
95 pub help: String,
97 pub requirement: SurfaceRequirement,
99 pub scope: SurfaceScope,
101 pub hidden: bool,
103 pub takes_values: bool,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
110#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
111pub enum SurfaceRequirement {
112 Optional,
114 Required,
116}
117
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
120#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
121#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
122pub enum SurfaceScope {
123 Local,
125 Global,
127}
128
129impl Surface {
130 #[must_use]
133 pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
134 Self::from_command(C::command(), mount)
135 }
136
137 #[must_use]
139 pub fn from_command(mut command: Command, mount: impl Into<String>) -> Self {
140 let mut declared_arguments = BTreeMap::new();
141 collect_declarations(&command, "", &mut declared_arguments);
142 command.build();
143 let mount = mount.into();
144 let binary = command.get_name().to_owned();
145 let about = command
146 .get_about()
147 .map(ToString::to_string)
148 .unwrap_or_default();
149 let version = command.get_version().map(ToOwned::to_owned);
150 let arguments = declared_arguments_for(&command, "", &declared_arguments);
151 let inherited_arguments = arguments
152 .iter()
153 .filter(|argument| argument.scope == SurfaceScope::Global)
154 .cloned()
155 .collect::<Vec<_>>();
156 let commands = command
157 .get_subcommands()
158 .filter(|child| declared_arguments.contains_key(child.get_name()))
159 .map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
160 .collect();
161 let usage_kdl = usage::spec(command, &mount);
162 let mount_line = usage::mount_line(&mount);
163 Self {
164 binary,
165 mount,
166 about,
167 version,
168 arguments,
169 inherited_arguments: Vec::new(),
170 commands,
171 usage_kdl,
172 mount_line,
173 notes: BTreeMap::new(),
174 }
175 }
176
177 #[must_use]
179 pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
180 self.notes.insert(name.into(), value.into());
181 self
182 }
183
184 #[must_use]
192 pub fn long_options_without_short(&self) -> Vec<(String, String)> {
193 let mut missing = Vec::new();
194 collect_missing_shorts("", &self.arguments, &mut missing);
195 for command in &self.commands {
196 collect_command_missing_shorts(command, &mut missing);
197 }
198 missing
199 }
200
201 pub fn require_shorts<'a>(
214 &self,
215 allow: impl IntoIterator<Item = &'a str>,
216 ) -> Result<(), String> {
217 let allowed: BTreeSet<&str> = allow.into_iter().collect();
218 let missing: Vec<(String, String)> = self
219 .long_options_without_short()
220 .into_iter()
221 .filter(|(path, long)| !allowed.contains(scoped_long(path, long).as_str()))
222 .collect();
223 if missing.is_empty() {
224 return Ok(());
225 }
226 let listing = missing
227 .iter()
228 .map(|(path, long)| {
229 if path.is_empty() {
230 format!("--{long}")
231 } else {
232 format!("{path} --{long}")
233 }
234 })
235 .collect::<Vec<_>>()
236 .join(", ");
237 Err(format!("long option has no short: {listing}"))
238 }
239}
240
241fn scoped_long(path: &str, long: &str) -> String {
242 if path.is_empty() {
243 format!("--{long}")
244 } else {
245 format!("{path} --{long}")
246 }
247}
248
249fn collect_missing_shorts(
250 path: &str,
251 arguments: &[SurfaceArgument],
252 missing: &mut Vec<(String, String)>,
253) {
254 for argument in arguments {
255 if let Some(long) = &argument.long
256 && argument.short.is_none()
257 && argument.visible_short_aliases.is_empty()
258 {
259 missing.push((path.to_owned(), long.clone()));
260 }
261 }
262}
263
264fn collect_command_missing_shorts(command: &SurfaceCommand, missing: &mut Vec<(String, String)>) {
265 collect_missing_shorts(&command.path, &command.arguments, missing);
266 for child in &command.commands {
267 collect_command_missing_shorts(child, missing);
268 }
269}
270
271fn surface_command(
272 command: &Command,
273 parent: &str,
274 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
275 inherited_arguments: &[SurfaceArgument],
276) -> SurfaceCommand {
277 let name = command.get_name().to_owned();
278 let path = if parent.is_empty() {
279 name.clone()
280 } else {
281 format!("{parent} {name}")
282 };
283 let arguments = declared_arguments_for(command, &path, declared_arguments);
284 let mut child_inherited_arguments = inherited_arguments.to_vec();
285 child_inherited_arguments.extend(
286 arguments
287 .iter()
288 .filter(|argument| argument.scope == SurfaceScope::Global)
289 .cloned(),
290 );
291 SurfaceCommand {
292 name,
293 path: path.clone(),
294 aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
295 visible_aliases: command
296 .get_visible_aliases()
297 .map(ToOwned::to_owned)
298 .collect(),
299 hidden: command.is_hide_set(),
300 about: command
301 .get_about()
302 .map(ToString::to_string)
303 .unwrap_or_default(),
304 arguments,
305 inherited_arguments: inherited_arguments.to_vec(),
306 commands: command
307 .get_subcommands()
308 .filter(|child| {
309 declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
310 })
311 .map(|child| {
312 surface_command(child, &path, declared_arguments, &child_inherited_arguments)
313 })
314 .collect(),
315 }
316}
317
318fn declared_arguments_for(
319 command: &Command,
320 path: &str,
321 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
322) -> Vec<SurfaceArgument> {
323 command
324 .get_arguments()
325 .filter(|argument| declared_argument(declared_arguments, path, argument))
326 .map(argument)
327 .collect()
328}
329
330fn collect_declarations(
331 command: &Command,
332 path: &str,
333 declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
334) {
335 declared_arguments.insert(
336 path.to_owned(),
337 command
338 .get_arguments()
339 .map(|argument| argument.get_id().to_string())
340 .collect(),
341 );
342 for child in command.get_subcommands() {
343 let child_path = if path.is_empty() {
344 child.get_name().to_owned()
345 } else {
346 format!("{path} {}", child.get_name())
347 };
348 collect_declarations(child, &child_path, declared_arguments);
349 }
350}
351
352fn declared_argument(
353 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
354 path: &str,
355 argument: &Arg,
356) -> bool {
357 declared_arguments
358 .get(path)
359 .is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
360}
361
362fn argument(argument: &Arg) -> SurfaceArgument {
363 SurfaceArgument {
364 id: argument.get_id().to_string(),
365 index: argument.get_index(),
366 short: argument.get_short(),
367 long: argument.get_long().map(ToOwned::to_owned),
368 visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
369 short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
370 visible_aliases: argument
371 .get_visible_aliases()
372 .unwrap_or_default()
373 .iter()
374 .map(|alias| (*alias).to_owned())
375 .collect(),
376 aliases: argument
377 .get_all_aliases()
378 .unwrap_or_default()
379 .iter()
380 .map(|alias| (*alias).to_owned())
381 .collect(),
382 value_names: argument
383 .get_value_names()
384 .unwrap_or_default()
385 .iter()
386 .map(ToString::to_string)
387 .collect(),
388 help: argument
389 .get_help()
390 .map(ToString::to_string)
391 .unwrap_or_default(),
392 requirement: if argument.is_required_set() {
393 SurfaceRequirement::Required
394 } else {
395 SurfaceRequirement::Optional
396 },
397 scope: if argument.is_global_set() {
398 SurfaceScope::Global
399 } else {
400 SurfaceScope::Local
401 },
402 hidden: argument.is_hide_set(),
403 takes_values: argument.get_action().takes_values(),
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use clap::{ArgAction, Parser, Subcommand};
410 #[cfg(feature = "surface-templates")]
411 use indoc::indoc;
412 #[cfg(feature = "surface-templates")]
413 use serde::Serialize;
414
415 #[cfg(feature = "surface-templates")]
416 use super::render;
417 use super::{Surface, SurfaceArgument, SurfaceScope};
418
419 #[derive(Parser)]
420 #[command(name = "toy", version = "1.2.3", about = "Control toys")]
421 struct Cli {
422 #[arg(short, long, global = true, help = "Select a profile")]
423 profile: Option<String>,
424 #[command(subcommand)]
425 command: Commands,
426 }
427
428 #[derive(Subcommand)]
429 enum Commands {
430 #[command(alias = "state", visible_alias = "ls")]
432 Status {
433 #[arg(long, action = ArgAction::SetTrue)]
435 archived: bool,
436 },
437 Item {
439 #[command(subcommand)]
440 command: ItemCommand,
441 },
442 #[command(hide = true)]
443 Internal,
444 }
445
446 #[derive(Subcommand)]
447 enum ItemCommand {
448 Add {
450 name: String,
452 },
453 }
454
455 #[test]
456 fn extracts_the_complete_clap_surface() {
457 let surface = Surface::new::<Cli>("t");
458 assert_eq!(surface.binary, "toy");
459 assert_eq!(surface.mount, "t");
460 assert_eq!(surface.version.as_deref(), Some("1.2.3"));
461 assert_eq!(surface.about, "Control toys");
462 assert_eq!(
463 surface.inherited_arguments.as_slice(),
464 &[] as &[SurfaceArgument]
465 );
466 assert!(surface.usage_kdl.contains("status"));
467 assert_eq!(
468 surface.mount_line,
469 r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
470 );
471 let status = &surface.commands[0];
472 assert_eq!(status.visible_aliases, ["ls"]);
473 assert_eq!(status.aliases, ["state", "ls"]);
474 assert_eq!(status.about, "Show current state");
475 assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
476 assert!(!status.arguments[0].takes_values);
477 assert_eq!(status.arguments.len(), 1);
478 assert_eq!(status.inherited_arguments.len(), 1);
479 assert_eq!(
480 status.inherited_arguments[0].long.as_deref(),
481 Some("profile")
482 );
483 assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
484 assert!(
485 status
486 .arguments
487 .iter()
488 .all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
489 );
490 let item = &surface.commands[1];
491 assert_eq!(item.commands[0].path, "item add");
492 assert_eq!(item.commands[0].arguments[0].index, Some(1));
493 assert_eq!(item.commands[0].inherited_arguments.len(), 1);
494 assert_eq!(
495 item.commands[0].inherited_arguments[0].long.as_deref(),
496 Some("profile")
497 );
498 assert!(surface.commands[2].hidden);
499 assert!(
500 surface
501 .arguments
502 .iter()
503 .any(|arg| arg.long.as_deref() == Some("profile"))
504 );
505 assert!(
506 surface
507 .arguments
508 .iter()
509 .all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
510 );
511 let noted = surface.note("skill", "Prefer the mounted task.");
512 assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
513 }
514
515 #[cfg(feature = "surface-templates")]
516 #[derive(Serialize)]
517 struct Content<'a> {
518 version: &'a str,
519 invocations: [&'a str; 2],
520 }
521
522 #[cfg(feature = "surface-templates")]
523 #[test]
524 fn shared_fragments_render_committed_operator_blocks() {
525 let surface = Surface::new::<Cli>("t");
526 let template = indoc! {r#"
527 {%- from "ctl/version.md.jinja" import version_line -%}
528 {%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
529 {%- from "ctl/commands.md.jinja" import command_inventory -%}
530 ---
531 {{ version_line(content.version) }}
532 ---
533
534 {{ mounted_invocation(surface, content.invocations) }}
535
536 {{ command_inventory(surface) -}}
537 "#};
538 let rendered = render(
539 "operator.md.jinja",
540 template,
541 &surface,
542 &Content {
543 version: "1.2.3",
544 invocations: ["status", "item add demo"],
545 },
546 )
547 .unwrap_or_else(|error| panic!("render operator template: {error}"));
548 let expected = indoc! {r"
549 ---
550 version: 1.2.3
551 ---
552
553 ## Invocation
554
555 ```sh
556 mise run t status
557 mise run t item add demo
558 ```
559
560 Never `mise run t --`. The `--` in `#USAGE mount` is mise's
561 completion bootstrap.
562
563 ## Commands
564
565 | Command | Aliases | Purpose |
566 |:--|:--|:--|
567 | `status` | `ls` | Show current state |
568 | `item` | — | Mutate one item |
569 "};
570 assert_eq!(rendered, expected);
571 assert!(!rendered.contains("internal"));
572 }
573
574 #[test]
575 fn long_only_status_flag_fails_require_shorts() {
576 let error = Surface::new::<Cli>("t")
577 .require_shorts([])
578 .expect_err("archived is long-only");
579 assert!(error.contains("--archived"), "{error}");
580 }
581
582 #[derive(Parser)]
583 struct ShortsCli {
584 #[arg(long, global = true)]
585 verbose: bool,
586 #[command(subcommand)]
587 command: ShortsCommand,
588 }
589
590 #[derive(Subcommand)]
591 enum ShortsCommand {
592 Status {
593 #[arg(long)]
594 archived: bool,
595 },
596 #[command(hide = true)]
597 Internal {
598 #[arg(long)]
599 secret: bool,
600 },
601 }
602
603 #[test]
604 fn reports_hidden_flags_and_inherited_globals_once() {
605 let surface = Surface::new::<ShortsCli>("t");
606 assert_eq!(
607 surface.long_options_without_short(),
608 [
609 (String::new(), "verbose".to_owned()),
610 ("status".to_owned(), "archived".to_owned()),
611 ("internal".to_owned(), "secret".to_owned()),
612 ]
613 );
614 let error = surface
615 .require_shorts([])
616 .expect_err("three flags are long-only");
617 assert_eq!(
618 error,
619 "long option has no short: --verbose, status --archived, internal --secret"
620 );
621 }
622
623 #[test]
624 fn require_shorts_accepts_an_allow_list() {
625 Surface::new::<ShortsCli>("t")
626 .require_shorts(["--verbose", "status --archived", "internal --secret"])
627 .unwrap_or_else(|error| panic!("{error}"));
628 }
629
630 #[test]
631 fn a_root_allowance_does_not_exempt_a_nested_flag() {
632 let error = Surface::new::<ShortsCli>("t")
633 .require_shorts(["--verbose", "--archived", "internal --secret"])
634 .expect_err("archived needs its command path");
635 assert_eq!(error, "long option has no short: status --archived");
636 }
637
638 #[derive(Parser)]
639 struct ShortAliasCli {
640 #[arg(long, visible_short_alias = 'x')]
641 expanded: bool,
642 }
643
644 #[test]
645 fn a_short_alias_satisfies_the_contract() {
646 Surface::new::<ShortAliasCli>("t")
647 .require_shorts([])
648 .unwrap_or_else(|error| panic!("{error}"));
649 }
650
651 #[derive(Parser)]
652 struct HiddenShortAliasCli {
653 #[arg(long, short_alias = 'x')]
654 expanded: bool,
655 }
656
657 #[test]
658 fn a_hidden_short_alias_is_not_an_operator_short() {
659 let error = Surface::new::<HiddenShortAliasCli>("t")
660 .require_shorts([])
661 .expect_err("the only short is hidden");
662 assert_eq!(error, "long option has no short: --expanded");
663 }
664
665 #[derive(Parser)]
666 struct OwnedFile {
667 #[command(flatten)]
668 format: crate::flags::FormatLong,
669 #[command(flatten)]
670 color: crate::flags::ColorLong,
671 #[arg(short = 'f', long)]
672 file: Option<String>,
673 #[arg(short = 'c', long)]
674 config: Option<String>,
675 }
676
677 #[test]
678 fn format_long_and_color_long_are_the_allow_list() {
679 let surface = Surface::new::<OwnedFile>("x");
680 let error = surface
681 .require_shorts([])
682 .expect_err("format/color/no-color are long-only");
683 assert!(error.contains("--format"), "{error}");
684 assert!(error.contains("--color"), "{error}");
685 assert!(error.contains("--no-color"), "{error}");
686 surface
687 .require_shorts(["--format", "--color", "--no-color"])
688 .unwrap_or_else(|error| panic!("{error}"));
689 }
690
691 #[derive(Parser)]
692 struct DefaultOutput {
693 #[command(flatten)]
694 output: crate::flags::OutputArgs,
695 }
696
697 #[test]
698 fn output_args_only_exempt_no_color() {
699 Surface::new::<DefaultOutput>("x")
700 .require_shorts(["--no-color"])
701 .unwrap_or_else(|error| panic!("{error}"));
702 }
703
704 #[cfg(feature = "surface-serde")]
705 #[test]
706 fn surface_serde_does_not_require_templates() {
707 fn assert_serializable<T: serde::Serialize>() {}
708 assert_serializable::<Surface>();
709 }
710}