Skip to main content

ctl_core/
surface.rs

1//! Clap-derived operator metadata and validation.
2//!
3//! Clap remains the command grammar. [`Surface`] extracts the stable operator
4//! view used by committed skills, installed instructions, and contract tests.
5//! MiniJinja rendering is separately gated behind `surface-templates`.
6
7use 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/// Operator-facing projection of one Clap command graph.
21#[derive(Clone, Debug, Eq, PartialEq)]
22#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
23pub struct Surface {
24    /// Executable name declared by the root Clap command.
25    pub binary: String,
26    /// Mise task name used for mounted invocations.
27    pub mount: String,
28    /// Root command description.
29    pub about: String,
30    /// Root package version, when Clap declares one.
31    pub version: Option<String>,
32    /// Arguments and flags declared directly on the root, in Clap order.
33    pub arguments: Vec<SurfaceArgument>,
34    /// Ancestor global arguments, always empty for the root.
35    pub inherited_arguments: Vec<SurfaceArgument>,
36    /// Root subcommands in Clap declaration order, including hidden commands.
37    pub commands: Vec<SurfaceCommand>,
38    /// Usage KDL for the mounted task name.
39    pub usage_kdl: String,
40    /// Exact `#USAGE mount` line for a served mise task.
41    pub mount_line: String,
42    /// Consumer-owned operator notes keyed for skill or instruction templates.
43    pub notes: BTreeMap<String, String>,
44}
45
46/// One command in a [`Surface`].
47#[derive(Clone, Debug, Eq, PartialEq)]
48#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
49pub struct SurfaceCommand {
50    /// Command name.
51    pub name: String,
52    /// Full command path below the root.
53    pub path: String,
54    /// All declared aliases, including hidden aliases.
55    pub aliases: Vec<String>,
56    /// Aliases Clap exposes in help and completion.
57    pub visible_aliases: Vec<String>,
58    /// Whether Clap hides this command.
59    pub hidden: bool,
60    /// Command description.
61    pub about: String,
62    /// Arguments and flags declared directly on this command, in Clap order.
63    ///
64    /// Ancestor globals remain on their declaring command instead of being
65    /// duplicated into every descendant.
66    pub arguments: Vec<SurfaceArgument>,
67    /// Ancestor global arguments this command also accepts, root-first.
68    pub inherited_arguments: Vec<SurfaceArgument>,
69    /// Nested subcommands in Clap declaration order.
70    pub commands: Vec<Self>,
71}
72
73/// One positional argument or flag in a [`Surface`].
74#[derive(Clone, Debug, Eq, PartialEq)]
75#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
76pub struct SurfaceArgument {
77    /// Clap argument identifier.
78    pub id: String,
79    /// Positional index, absent for options and flags.
80    pub index: Option<usize>,
81    /// Short flag name.
82    pub short: Option<char>,
83    /// Long flag name without leading dashes.
84    pub long: Option<String>,
85    /// Visible short aliases.
86    pub visible_short_aliases: Vec<char>,
87    /// All short aliases, including hidden aliases.
88    pub short_aliases: Vec<char>,
89    /// Visible long aliases.
90    pub visible_aliases: Vec<String>,
91    /// All long aliases, including hidden aliases.
92    pub aliases: Vec<String>,
93    /// Value names shown by Clap.
94    pub value_names: Vec<String>,
95    /// Argument description.
96    pub help: String,
97    /// Whether Clap requires the argument.
98    pub requirement: SurfaceRequirement,
99    /// Whether Clap propagates the argument to subcommands.
100    pub scope: SurfaceScope,
101    /// Whether Clap hides the argument.
102    pub hidden: bool,
103    /// Whether the argument action accepts values.
104    pub takes_values: bool,
105}
106
107/// Whether Clap requires an argument.
108#[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    /// The invocation can omit this argument.
113    Optional,
114    /// The invocation must provide this argument.
115    Required,
116}
117
118/// How far Clap propagates an argument.
119#[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    /// The argument belongs only to its declaring command.
124    Local,
125    /// The argument remains available to nested commands.
126    Global,
127}
128
129impl Surface {
130    /// Extract an operator surface from a Clap
131    /// [`CommandFactory`](clap::CommandFactory).
132    #[must_use]
133    pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
134        Self::from_command(C::command(), mount)
135    }
136
137    /// Extract an operator surface from a Clap command graph.
138    #[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    /// Add consumer-owned prose for a template audience or section.
178    #[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    /// Long flags that have no short, as `(command path, long name)`.
185    ///
186    /// The root path is empty. Inherited globals are not repeated on children.
187    /// Hidden flags are included so a long-only option cannot hide from the
188    /// check. [`FormatLong`](crate::flags::FormatLong) and
189    /// [`ColorLong`](crate::flags::ColorLong) leave `--format` / `--color`
190    /// without shorts on purpose; pass those names to [`Self::require_shorts`].
191    #[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    /// Fail when a long option has no operator-visible short, except tokens in
202    /// `allow`.
203    ///
204    /// A root allowance is the displayed long option (`--format`). A nested
205    /// allowance includes its command path (`status --archived`). Chassis
206    /// mixins that deliberately leave a letter for the consumer (`--format`
207    /// for `-f`/`--file`, `--color` for `-c`) belong there.
208    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        /// Show current state.
426        #[command(alias = "state", visible_alias = "ls")]
427        Status {
428            /// Include archived records.
429            #[arg(long, action = ArgAction::SetTrue)]
430            archived: bool,
431        },
432        /// Mutate one item.
433        Item {
434            #[command(subcommand)]
435            command: ItemCommand,
436        },
437        #[command(hide = true)]
438        Internal,
439    }
440
441    #[derive(Subcommand)]
442    enum ItemCommand {
443        /// Create one item.
444        Add {
445            /// Item name.
446            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}