1#![cfg_attr(
14 not(feature = "surface-templates"),
15 doc = "",
16 doc = "[`render`]: crate#cargo-features"
17)]
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use clap::{Arg, Command};
22
23use crate::usage;
24
25#[cfg(feature = "surface-templates")]
26mod templates;
27#[cfg(feature = "surface-templates")]
28pub use templates::{
29 COMMANDS_FRAGMENT, INVOCATION_FRAGMENT, VERSION_FRAGMENT, add_fragments, environment, render,
30};
31
32#[derive(Clone, Debug, Eq, PartialEq)]
38#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
39#[non_exhaustive]
40pub struct Surface {
41 pub binary: String,
43 pub mount: String,
45 pub about: String,
47 pub version: Option<String>,
49 pub arguments: Vec<SurfaceArgument>,
51 pub inherited_arguments: Vec<SurfaceArgument>,
53 pub commands: Vec<SurfaceCommand>,
55 pub usage_kdl: String,
57 pub mount_line: String,
59 pub notes: BTreeMap<String, String>,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
66#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
67#[non_exhaustive]
68pub struct SurfaceCommand {
69 pub name: String,
71 pub path: String,
73 pub aliases: Vec<String>,
75 pub visible_aliases: Vec<String>,
77 pub hidden: bool,
79 pub about: String,
81 pub arguments: Vec<SurfaceArgument>,
86 pub inherited_arguments: Vec<SurfaceArgument>,
88 pub commands: Vec<Self>,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
95#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
96#[non_exhaustive]
97pub struct SurfaceArgument {
98 pub id: String,
100 pub index: Option<usize>,
102 pub short: Option<char>,
104 pub long: Option<String>,
106 pub visible_short_aliases: Vec<char>,
108 pub short_aliases: Vec<char>,
110 pub visible_aliases: Vec<String>,
112 pub aliases: Vec<String>,
114 pub value_names: Vec<String>,
116 pub help: String,
118 pub requirement: SurfaceRequirement,
120 pub scope: SurfaceScope,
122 pub hidden: bool,
124 pub takes_values: bool,
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
135#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
136pub enum SurfaceRequirement {
137 Optional,
139 Required,
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
150#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
151pub enum SurfaceScope {
152 Local,
154 Global,
156}
157
158impl Surface {
159 #[must_use]
164 pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
165 Self::from_command(C::command(), mount)
166 }
167
168 #[must_use]
177 pub fn from_command(command: Command, mount: impl Into<String>) -> Self {
178 let mut declared_arguments = BTreeMap::new();
179 let mut help_settings = BTreeMap::new();
180 collect_declarations(&command, "", &mut declared_arguments, &mut help_settings);
181 let mut command = crate::parser::with_help(command);
182 command.build();
183 let command = restore_help_settings(command, "", &help_settings);
184 let mount = mount.into();
185 let binary = command.get_name().to_owned();
186 let about = command
187 .get_about()
188 .map(ToString::to_string)
189 .unwrap_or_default();
190 let version = command.get_version().map(ToOwned::to_owned);
191 let arguments = declared_arguments_for(&command, "", &declared_arguments);
192 let inherited_arguments = arguments
193 .iter()
194 .filter(|argument| argument.scope == SurfaceScope::Global)
195 .cloned()
196 .collect::<Vec<_>>();
197 let commands = command
198 .get_subcommands()
199 .filter(|child| declared_arguments.contains_key(child.get_name()))
200 .map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
201 .collect();
202 let usage_kdl = usage::spec(command, &mount);
203 let mount_line = usage::mount_line(&mount);
204 Self {
205 binary,
206 mount,
207 about,
208 version,
209 arguments,
210 inherited_arguments: Vec::new(),
211 commands,
212 usage_kdl,
213 mount_line,
214 notes: BTreeMap::new(),
215 }
216 }
217
218 #[must_use]
222 pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
223 self.notes.insert(name.into(), value.into());
224 self
225 }
226
227 #[must_use]
235 pub fn long_options_without_short(&self) -> Vec<(String, String)> {
236 let mut missing = Vec::new();
237 collect_missing_shorts("", &self.arguments, &mut missing);
238 for command in &self.commands {
239 collect_command_missing_shorts(command, &mut missing);
240 }
241 missing
242 }
243
244 pub fn require_shorts<'a>(
257 &self,
258 allow: impl IntoIterator<Item = &'a str>,
259 ) -> Result<(), String> {
260 let allowed: BTreeSet<&str> = allow.into_iter().collect();
261 let missing: Vec<(String, String)> = self
262 .long_options_without_short()
263 .into_iter()
264 .filter(|(path, long)| !allowed.contains(scoped_long(path, long).as_str()))
265 .collect();
266 if missing.is_empty() {
267 return Ok(());
268 }
269 let listing = missing
270 .iter()
271 .map(|(path, long)| {
272 if path.is_empty() {
273 format!("--{long}")
274 } else {
275 format!("{path} --{long}")
276 }
277 })
278 .collect::<Vec<_>>()
279 .join(", ");
280 Err(format!("long option has no short: {listing}"))
281 }
282}
283
284fn scoped_long(path: &str, long: &str) -> String {
285 if path.is_empty() {
286 format!("--{long}")
287 } else {
288 format!("{path} --{long}")
289 }
290}
291
292fn collect_missing_shorts(
293 path: &str,
294 arguments: &[SurfaceArgument],
295 missing: &mut Vec<(String, String)>,
296) {
297 for argument in arguments {
298 if let Some(long) = &argument.long
299 && argument.short.is_none()
300 && argument.visible_short_aliases.is_empty()
301 {
302 missing.push((path.to_owned(), long.clone()));
303 }
304 }
305}
306
307fn collect_command_missing_shorts(command: &SurfaceCommand, missing: &mut Vec<(String, String)>) {
308 collect_missing_shorts(&command.path, &command.arguments, missing);
309 for child in &command.commands {
310 collect_command_missing_shorts(child, missing);
311 }
312}
313
314fn surface_command(
315 command: &Command,
316 parent: &str,
317 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
318 inherited_arguments: &[SurfaceArgument],
319) -> SurfaceCommand {
320 let name = command.get_name().to_owned();
321 let path = if parent.is_empty() {
322 name.clone()
323 } else {
324 format!("{parent} {name}")
325 };
326 let arguments = declared_arguments_for(command, &path, declared_arguments);
327 let mut child_inherited_arguments = inherited_arguments.to_vec();
328 child_inherited_arguments.extend(
329 arguments
330 .iter()
331 .filter(|argument| argument.scope == SurfaceScope::Global)
332 .cloned(),
333 );
334 SurfaceCommand {
335 name,
336 path: path.clone(),
337 aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
338 visible_aliases: command
339 .get_visible_aliases()
340 .map(ToOwned::to_owned)
341 .collect(),
342 hidden: command.is_hide_set(),
343 about: command
344 .get_about()
345 .map(ToString::to_string)
346 .unwrap_or_default(),
347 arguments,
348 inherited_arguments: inherited_arguments.to_vec(),
349 commands: command
350 .get_subcommands()
351 .filter(|child| {
352 declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
353 })
354 .map(|child| {
355 surface_command(child, &path, declared_arguments, &child_inherited_arguments)
356 })
357 .collect(),
358 }
359}
360
361fn declared_arguments_for(
362 command: &Command,
363 path: &str,
364 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
365) -> Vec<SurfaceArgument> {
366 command
367 .get_arguments()
368 .filter(|argument| declared_argument(declared_arguments, path, argument))
369 .map(argument)
370 .collect()
371}
372
373fn collect_declarations(
374 command: &Command,
375 path: &str,
376 declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
377 help_settings: &mut BTreeMap<String, HelpSettings>,
378) {
379 declared_arguments.insert(
380 path.to_owned(),
381 command
382 .get_arguments()
383 .map(|argument| argument.get_id().to_string())
384 .collect(),
385 );
386 let owns_help_flag = command
387 .get_arguments()
388 .any(|argument| argument.get_id() == "help");
389 let owns_help_subcommand = command.find_subcommand("help").is_some();
390 help_settings.insert(
391 path.to_owned(),
392 HelpSettings {
393 flag_off: command.is_disable_help_flag_set() && !owns_help_flag,
394 subcommand_off: command.is_disable_help_subcommand_set() && !owns_help_subcommand,
395 },
396 );
397 for child in command
398 .get_subcommands()
399 .filter(|child| !crate::parser::is_generated_help(child))
400 {
401 let child_path = child_path(path, child.get_name());
402 collect_declarations(child, &child_path, declared_arguments, help_settings);
403 }
404}
405
406fn child_path(path: &str, name: &str) -> String {
407 if path.is_empty() {
408 name.to_owned()
409 } else {
410 format!("{path} {name}")
411 }
412}
413
414#[derive(Clone, Copy, Debug)]
421struct HelpSettings {
422 flag_off: bool,
423 subcommand_off: bool,
424}
425
426fn restore_help_settings(
431 command: Command,
432 path: &str,
433 help_settings: &BTreeMap<String, HelpSettings>,
434) -> Command {
435 let command = match help_settings.get(path) {
436 Some(declared) => command
437 .disable_help_flag(declared.flag_off)
438 .disable_help_subcommand(declared.subcommand_off),
439 None => command,
440 };
441 command.mut_subcommands(|child| {
442 let child_path = child_path(path, child.get_name());
443 restore_help_settings(child, &child_path, help_settings)
444 })
445}
446
447fn declared_argument(
448 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
449 path: &str,
450 argument: &Arg,
451) -> bool {
452 declared_arguments
453 .get(path)
454 .is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
455}
456
457fn argument(argument: &Arg) -> SurfaceArgument {
458 SurfaceArgument {
459 id: argument.get_id().to_string(),
460 index: argument.get_index(),
461 short: argument.get_short(),
462 long: argument.get_long().map(ToOwned::to_owned),
463 visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
464 short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
465 visible_aliases: argument
466 .get_visible_aliases()
467 .unwrap_or_default()
468 .iter()
469 .map(|alias| (*alias).to_owned())
470 .collect(),
471 aliases: argument
472 .get_all_aliases()
473 .unwrap_or_default()
474 .iter()
475 .map(|alias| (*alias).to_owned())
476 .collect(),
477 value_names: argument
478 .get_value_names()
479 .unwrap_or_default()
480 .iter()
481 .map(ToString::to_string)
482 .collect(),
483 help: argument
484 .get_help()
485 .map(ToString::to_string)
486 .unwrap_or_default(),
487 requirement: if argument.is_required_set() {
488 SurfaceRequirement::Required
489 } else {
490 SurfaceRequirement::Optional
491 },
492 scope: if argument.is_global_set() {
493 SurfaceScope::Global
494 } else {
495 SurfaceScope::Local
496 },
497 hidden: argument.is_hide_set(),
498 takes_values: argument.get_action().takes_values(),
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use clap::{ArgAction, CommandFactory, Parser, Subcommand};
505 #[cfg(feature = "surface-templates")]
506 use indoc::indoc;
507 #[cfg(feature = "surface-templates")]
508 use serde::Serialize;
509
510 #[cfg(feature = "surface-templates")]
511 use super::render;
512 use super::{Surface, SurfaceArgument, SurfaceScope};
513
514 #[derive(Parser)]
515 #[command(name = "toy", version = "1.2.3", about = "Control toys")]
516 struct Cli {
517 #[arg(short, long, global = true, help = "Select a profile")]
518 profile: Option<String>,
519 #[command(subcommand)]
520 command: Commands,
521 }
522
523 #[derive(Subcommand)]
524 enum Commands {
525 #[command(alias = "state", visible_alias = "ls")]
527 Status {
528 #[arg(long, action = ArgAction::SetTrue)]
530 archived: bool,
531 },
532 Item {
534 #[command(subcommand)]
535 command: ItemCommand,
536 },
537 #[command(hide = true)]
538 Internal,
539 }
540
541 #[derive(Subcommand)]
542 enum ItemCommand {
543 Add {
545 name: String,
547 },
548 }
549
550 #[test]
551 fn extracts_the_complete_clap_surface() {
552 let surface = Surface::new::<Cli>("t");
553 assert_eq!(surface.binary, "toy");
554 assert_eq!(surface.mount, "t");
555 assert_eq!(surface.version.as_deref(), Some("1.2.3"));
556 assert_eq!(surface.about, "Control toys");
557 assert_eq!(
558 surface.inherited_arguments.as_slice(),
559 <&[SurfaceArgument]>::default()
560 );
561 assert!(surface.usage_kdl.contains("status"));
562 assert_eq!(
563 surface.mount_line,
564 r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
565 );
566 let status = &surface.commands[0];
567 assert_eq!(status.visible_aliases, ["ls"]);
568 assert_eq!(status.aliases, ["state", "ls"]);
569 assert_eq!(status.about, "Show current state");
570 assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
571 assert!(!status.arguments[0].takes_values);
572 assert_eq!(status.arguments.len(), 1);
573 assert_eq!(status.inherited_arguments.len(), 1);
574 assert_eq!(
575 status.inherited_arguments[0].long.as_deref(),
576 Some("profile")
577 );
578 assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
579 assert!(
580 status
581 .arguments
582 .iter()
583 .all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
584 );
585 let item = &surface.commands[1];
586 assert_eq!(item.commands[0].path, "item add");
587 assert_eq!(item.commands[0].arguments[0].index, Some(1));
588 assert_eq!(item.commands[0].inherited_arguments.len(), 1);
589 assert_eq!(
590 item.commands[0].inherited_arguments[0].long.as_deref(),
591 Some("profile")
592 );
593 assert!(surface.commands[2].hidden);
594 assert!(
595 surface
596 .arguments
597 .iter()
598 .any(|arg| arg.long.as_deref() == Some("profile"))
599 );
600 assert!(
601 surface
602 .arguments
603 .iter()
604 .all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
605 );
606 let noted = surface.note("skill", "Prefer the mounted task.");
607 assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
608 }
609
610 fn help_lines(usage: &str) -> (usize, usize) {
612 let lines = usage.lines().map(str::trim_start);
613 let flags = lines
614 .clone()
615 .filter(|line| line.starts_with(r#"flag "-h --help""#) && line.contains("action=help"))
616 .count();
617 let commands = lines.filter(|line| line.starts_with("cmd help ")).count();
618 (flags, commands)
619 }
620
621 #[test]
626 fn usage_kdl_keeps_the_declared_help() {
627 let usage = Surface::new::<Cli>("t").usage_kdl;
628 assert_eq!(help_lines(&usage), (5, 4), "{usage}");
629 assert!(
630 !usage.lines().any(|line| line.starts_with("disable_help")),
631 "{usage}"
632 );
633 for declared in ["cmd status ", "cmd item ", "cmd add ", "cmd internal "] {
634 assert!(
635 usage
636 .lines()
637 .map(str::trim_start)
638 .any(|line| { line.starts_with(declared) && !line.contains("disable_help") }),
639 "{declared}in {usage}"
640 );
641 }
642 let applied = Surface::from_command(crate::parser::apply_defaults(Cli::command()), "t");
643 assert_eq!(
644 help_lines(&applied.usage_kdl),
645 (5, 4),
646 "{}",
647 applied.usage_kdl
648 );
649 assert!(
650 !applied
651 .usage_kdl
652 .lines()
653 .any(|line| line.starts_with("disable_help")),
654 "{}",
655 applied.usage_kdl
656 );
657 for setting in ["disable_help_flag=#true", "disable_help_subcommand=#true"] {
658 assert_eq!(
659 applied.usage_kdl.matches(setting).count(),
660 usage.matches(setting).count(),
661 "{setting} in {}",
662 applied.usage_kdl
663 );
664 }
665 }
666
667 #[cfg(feature = "surface-templates")]
668 #[derive(Serialize)]
669 struct Content<'a> {
670 version: &'a str,
671 invocations: [&'a str; 2],
672 }
673
674 #[cfg(feature = "surface-templates")]
675 #[test]
676 fn shared_fragments_render_committed_operator_blocks() {
677 let surface = Surface::new::<Cli>("t");
678 let template = indoc! {r#"
679 {%- from "ctl/version.md.jinja" import version_line -%}
680 {%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
681 {%- from "ctl/commands.md.jinja" import command_inventory -%}
682 ---
683 {{ version_line(content.version) }}
684 ---
685
686 {{ mounted_invocation(surface, content.invocations) }}
687
688 {{ command_inventory(surface) -}}
689 "#};
690 let rendered = render(
691 "operator.md.jinja",
692 template,
693 &surface,
694 &Content {
695 version: "1.2.3",
696 invocations: ["status", "item add demo"],
697 },
698 )
699 .unwrap_or_else(|error| panic!("render operator template: {error}"));
700 let expected = indoc! {r"
701 ---
702 version: 1.2.3
703 ---
704
705 ## Invocation
706
707 ```sh
708 mise run t status
709 mise run t item add demo
710 ```
711
712 Never `mise run t --`.
713
714 The `--` in `#USAGE mount` is mise's completion bootstrap.
715
716 ## Commands
717
718 | Command | Aliases | Purpose |
719 |:--|:--|:--|
720 | `status` | `ls` | Show current state |
721 | `item` | — | Mutate one item |
722 "};
723 assert_eq!(rendered, expected);
724 assert!(!rendered.contains("internal"));
725 }
726
727 #[test]
728 fn long_only_status_flag_fails_require_shorts() {
729 let error = Surface::new::<Cli>("t")
730 .require_shorts([])
731 .expect_err("archived is long-only");
732 assert!(error.contains("--archived"), "{error}");
733 }
734
735 #[derive(Parser)]
736 struct ShortsCli {
737 #[arg(long, global = true)]
738 verbose: bool,
739 #[command(subcommand)]
740 command: ShortsCommand,
741 }
742
743 #[derive(Subcommand)]
744 enum ShortsCommand {
745 Status {
746 #[arg(long)]
747 archived: bool,
748 },
749 #[command(hide = true)]
750 Internal {
751 #[arg(long)]
752 secret: bool,
753 },
754 }
755
756 #[test]
757 fn reports_hidden_flags_and_inherited_globals_once() {
758 let surface = Surface::new::<ShortsCli>("t");
759 assert_eq!(
760 surface.long_options_without_short(),
761 [
762 (String::new(), "verbose".to_owned()),
763 ("status".to_owned(), "archived".to_owned()),
764 ("internal".to_owned(), "secret".to_owned()),
765 ]
766 );
767 let error = surface
768 .require_shorts([])
769 .expect_err("three flags are long-only");
770 assert_eq!(
771 error,
772 "long option has no short: --verbose, status --archived, internal --secret"
773 );
774 }
775
776 #[test]
777 fn require_shorts_accepts_an_allow_list() {
778 Surface::new::<ShortsCli>("t")
779 .require_shorts(["--verbose", "status --archived", "internal --secret"])
780 .unwrap_or_else(|error| panic!("{error}"));
781 }
782
783 #[test]
784 fn a_root_allowance_does_not_exempt_a_nested_flag() {
785 let error = Surface::new::<ShortsCli>("t")
786 .require_shorts(["--verbose", "--archived", "internal --secret"])
787 .expect_err("archived needs its command path");
788 assert_eq!(error, "long option has no short: status --archived");
789 }
790
791 #[derive(Parser)]
792 struct ShortAliasCli {
793 #[arg(long, visible_short_alias = 'x')]
794 expanded: bool,
795 }
796
797 #[test]
798 fn a_short_alias_satisfies_the_contract() {
799 Surface::new::<ShortAliasCli>("t")
800 .require_shorts([])
801 .unwrap_or_else(|error| panic!("{error}"));
802 }
803
804 #[derive(Parser)]
805 struct HiddenShortAliasCli {
806 #[arg(long, short_alias = 'x')]
807 expanded: bool,
808 }
809
810 #[test]
811 fn a_hidden_short_alias_is_not_an_operator_short() {
812 let error = Surface::new::<HiddenShortAliasCli>("t")
813 .require_shorts([])
814 .expect_err("the only short is hidden");
815 assert_eq!(error, "long option has no short: --expanded");
816 }
817
818 #[derive(Parser)]
819 struct OwnedFile {
820 #[command(flatten)]
821 format: crate::flags::FormatLong,
822 #[command(flatten)]
823 color: crate::flags::ColorLong,
824 #[arg(short = 'f', long)]
825 file: Option<String>,
826 #[arg(short = 'c', long)]
827 config: Option<String>,
828 }
829
830 #[test]
831 fn format_long_and_color_long_are_the_allow_list() {
832 let surface = Surface::new::<OwnedFile>("x");
833 let error = surface
834 .require_shorts([])
835 .expect_err("format/color/no-color are long-only");
836 assert!(error.contains("--format"), "{error}");
837 assert!(error.contains("--color"), "{error}");
838 assert!(error.contains("--no-color"), "{error}");
839 surface
840 .require_shorts(["--format", "--color", "--no-color"])
841 .unwrap_or_else(|error| panic!("{error}"));
842 }
843
844 #[derive(Parser)]
845 struct DefaultOutput {
846 #[command(flatten)]
847 output: crate::flags::OutputArgs,
848 }
849
850 #[test]
851 fn output_args_only_exempt_no_color() {
852 Surface::new::<DefaultOutput>("x")
853 .require_shorts(["--no-color"])
854 .unwrap_or_else(|error| panic!("{error}"));
855 }
856
857 #[cfg(feature = "surface-serde")]
858 #[test]
859 fn surface_serde_does_not_require_templates() {
860 fn assert_serializable<T: serde::Serialize>() {}
861 assert_serializable::<Surface>();
862 }
863}