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//! Rendering with `minijinja` 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    ///
209    /// # Errors
210    ///
211    /// Returns a message that lists every long option without a short,
212    /// outside `allow`.
213    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        /// Show current state.
431        #[command(alias = "state", visible_alias = "ls")]
432        Status {
433            /// Include archived records.
434            #[arg(long, action = ArgAction::SetTrue)]
435            archived: bool,
436        },
437        /// Mutate one item.
438        Item {
439            #[command(subcommand)]
440            command: ItemCommand,
441        },
442        #[command(hide = true)]
443        Internal,
444    }
445
446    #[derive(Subcommand)]
447    enum ItemCommand {
448        /// Create one item.
449        Add {
450            /// Item name.
451            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}