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