Skip to main content

ctl_core/
surface.rs

1//! Clap-derived operator metadata and shared MiniJinja fragments.
2//!
3//! Clap remains the command grammar. [`Surface`] extracts the stable operator
4//! view used by committed skills and installed instructions, while the shared
5//! fragments own repeated invocation, version, and command-inventory prose.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use clap::{Arg, Command};
10use minijinja::{Environment, Error, context};
11use serde::Serialize;
12
13use crate::usage;
14
15/// Shared fragment that renders a skill frontmatter version line.
16pub const VERSION_FRAGMENT: &str = r"{% macro version_line(version) -%}
17version: {{ version }}
18{%- endmacro %}";
19
20/// Shared fragment that renders mounted invocations and the no-`--` rule.
21pub const INVOCATION_FRAGMENT: &str = r"{% macro mounted_invocation(surface, examples) -%}
22## Invocation
23
24```sh
25{% for example in examples -%}
26mise run {{ surface.mount }} {{ example }}
27{% endfor -%}
28```
29
30Never `mise run {{ surface.mount }} --`. The `--` in `#USAGE mount` is mise's
31completion bootstrap.
32{%- endmacro %}";
33
34/// Shared fragment that renders the visible top-level Clap commands.
35pub const COMMANDS_FRAGMENT: &str = r#"{% macro command_inventory(surface) -%}
36## Commands
37
38| Command | Aliases | Purpose |
39|:--|:--|:--|
40{% for command in surface.commands if not command.hidden -%}
41| `{{ command.name }}` | {% if command.visible_aliases %}`{{ command.visible_aliases | join("`, `") }}`{% else %}—{% endif %} | {{ command.about | replace("|", "\\|") | replace("\n", " ") }} |
42{% endfor -%}
43{{- "" -}}
44{%- endmacro %}"#;
45
46const FRAGMENTS: [(&str, &str); 3] = [
47    ("ctl/version.md.jinja", VERSION_FRAGMENT),
48    ("ctl/invocation.md.jinja", INVOCATION_FRAGMENT),
49    ("ctl/commands.md.jinja", COMMANDS_FRAGMENT),
50];
51
52/// Serializable operator-facing projection of one Clap command graph.
53#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54pub struct Surface {
55    /// Executable name declared by the root Clap command.
56    pub binary: String,
57    /// Mise task name used for mounted invocations.
58    pub mount: String,
59    /// Root command description.
60    pub about: String,
61    /// Root package version, when Clap declares one.
62    pub version: Option<String>,
63    /// Arguments and flags declared directly on the root, in Clap order.
64    pub arguments: Vec<SurfaceArgument>,
65    /// Ancestor global arguments, always empty for the root.
66    pub inherited_arguments: Vec<SurfaceArgument>,
67    /// Root subcommands in Clap declaration order, including hidden commands.
68    pub commands: Vec<SurfaceCommand>,
69    /// Usage KDL for the mounted task name.
70    pub usage_kdl: String,
71    /// Exact `#USAGE mount` line for a served mise task.
72    pub mount_line: String,
73    /// Consumer-owned operator notes keyed for skill or instruction templates.
74    pub notes: BTreeMap<String, String>,
75}
76
77/// One command in a [`Surface`].
78#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct SurfaceCommand {
80    /// Command name.
81    pub name: String,
82    /// Full command path below the root.
83    pub path: String,
84    /// All declared aliases, including hidden aliases.
85    pub aliases: Vec<String>,
86    /// Aliases Clap exposes in help and completion.
87    pub visible_aliases: Vec<String>,
88    /// Whether Clap hides this command.
89    pub hidden: bool,
90    /// Command description.
91    pub about: String,
92    /// Arguments and flags declared directly on this command, in Clap order.
93    ///
94    /// Ancestor globals remain on their declaring command instead of being
95    /// duplicated into every descendant.
96    pub arguments: Vec<SurfaceArgument>,
97    /// Ancestor global arguments this command also accepts, root-first.
98    pub inherited_arguments: Vec<SurfaceArgument>,
99    /// Nested subcommands in Clap declaration order.
100    pub commands: Vec<Self>,
101}
102
103/// One positional argument or flag in a [`Surface`].
104#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
105pub struct SurfaceArgument {
106    /// Clap argument identifier.
107    pub id: String,
108    /// Positional index, absent for options and flags.
109    pub index: Option<usize>,
110    /// Short flag name.
111    pub short: Option<char>,
112    /// Long flag name without leading dashes.
113    pub long: Option<String>,
114    /// Visible short aliases.
115    pub visible_short_aliases: Vec<char>,
116    /// All short aliases, including hidden aliases.
117    pub short_aliases: Vec<char>,
118    /// Visible long aliases.
119    pub visible_aliases: Vec<String>,
120    /// All long aliases, including hidden aliases.
121    pub aliases: Vec<String>,
122    /// Value names shown by Clap.
123    pub value_names: Vec<String>,
124    /// Argument description.
125    pub help: String,
126    /// Whether Clap requires the argument.
127    pub requirement: SurfaceRequirement,
128    /// Whether Clap propagates the argument to subcommands.
129    pub scope: SurfaceScope,
130    /// Whether Clap hides the argument.
131    pub hidden: bool,
132    /// Whether the argument action accepts values.
133    pub takes_values: bool,
134}
135
136/// Whether Clap requires an argument.
137#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
138#[serde(rename_all = "snake_case")]
139pub enum SurfaceRequirement {
140    /// The invocation can omit this argument.
141    Optional,
142    /// The invocation must provide this argument.
143    Required,
144}
145
146/// How far Clap propagates an argument.
147#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
148#[serde(rename_all = "snake_case")]
149pub enum SurfaceScope {
150    /// The argument belongs only to its declaring command.
151    Local,
152    /// The argument remains available to nested commands.
153    Global,
154}
155
156impl Surface {
157    /// Extract an operator surface from a Clap
158    /// [`CommandFactory`](clap::CommandFactory).
159    #[must_use]
160    pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
161        Self::from_command(C::command(), mount)
162    }
163
164    /// Extract an operator surface from a Clap command graph.
165    #[must_use]
166    pub fn from_command(mut command: Command, mount: impl Into<String>) -> Self {
167        let mut declared_arguments = BTreeMap::new();
168        collect_declarations(&command, "", &mut declared_arguments);
169        command.build();
170        let mount = mount.into();
171        let binary = command.get_name().to_owned();
172        let about = command
173            .get_about()
174            .map(ToString::to_string)
175            .unwrap_or_default();
176        let version = command.get_version().map(ToOwned::to_owned);
177        let arguments = declared_arguments_for(&command, "", &declared_arguments);
178        let inherited_arguments = arguments
179            .iter()
180            .filter(|argument| argument.scope == SurfaceScope::Global)
181            .cloned()
182            .collect::<Vec<_>>();
183        let commands = command
184            .get_subcommands()
185            .filter(|child| declared_arguments.contains_key(child.get_name()))
186            .map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
187            .collect();
188        let usage_kdl = usage::spec(command, &mount);
189        let mount_line = usage::mount_line(&mount);
190        Self {
191            binary,
192            mount,
193            about,
194            version,
195            arguments,
196            inherited_arguments: Vec::new(),
197            commands,
198            usage_kdl,
199            mount_line,
200            notes: BTreeMap::new(),
201        }
202    }
203
204    /// Add consumer-owned prose for a template audience or section.
205    #[must_use]
206    pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
207        self.notes.insert(name.into(), value.into());
208        self
209    }
210}
211
212/// Add ctl-core's shared operator fragments to an existing environment.
213pub fn add_fragments(environment: &mut Environment<'static>) -> Result<(), Error> {
214    for (name, source) in FRAGMENTS {
215        environment.add_template(name, source)?;
216    }
217    Ok(())
218}
219
220/// Create a strict `MiniJinja` environment containing the shared fragments.
221pub fn environment() -> Result<Environment<'static>, Error> {
222    let mut environment = Environment::new();
223    environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
224    environment.set_keep_trailing_newline(true);
225    add_fragments(&mut environment)?;
226    Ok(environment)
227}
228
229/// Render one operator template with a [`Surface`] and consumer-owned context.
230pub fn render<T: Serialize>(
231    name: &'static str,
232    source: &'static str,
233    surface: &Surface,
234    content: &T,
235) -> Result<String, Error> {
236    let mut environment = environment()?;
237    environment.add_template(name, source)?;
238    environment
239        .get_template(name)?
240        .render(context! { surface, content })
241}
242
243fn surface_command(
244    command: &Command,
245    parent: &str,
246    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
247    inherited_arguments: &[SurfaceArgument],
248) -> SurfaceCommand {
249    let name = command.get_name().to_owned();
250    let path = if parent.is_empty() {
251        name.clone()
252    } else {
253        format!("{parent} {name}")
254    };
255    let arguments = declared_arguments_for(command, &path, declared_arguments);
256    let mut child_inherited_arguments = inherited_arguments.to_vec();
257    child_inherited_arguments.extend(
258        arguments
259            .iter()
260            .filter(|argument| argument.scope == SurfaceScope::Global)
261            .cloned(),
262    );
263    SurfaceCommand {
264        name,
265        path: path.clone(),
266        aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
267        visible_aliases: command
268            .get_visible_aliases()
269            .map(ToOwned::to_owned)
270            .collect(),
271        hidden: command.is_hide_set(),
272        about: command
273            .get_about()
274            .map(ToString::to_string)
275            .unwrap_or_default(),
276        arguments,
277        inherited_arguments: inherited_arguments.to_vec(),
278        commands: command
279            .get_subcommands()
280            .filter(|child| {
281                declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
282            })
283            .map(|child| {
284                surface_command(child, &path, declared_arguments, &child_inherited_arguments)
285            })
286            .collect(),
287    }
288}
289
290fn declared_arguments_for(
291    command: &Command,
292    path: &str,
293    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
294) -> Vec<SurfaceArgument> {
295    command
296        .get_arguments()
297        .filter(|argument| declared_argument(declared_arguments, path, argument))
298        .map(argument)
299        .collect()
300}
301
302fn collect_declarations(
303    command: &Command,
304    path: &str,
305    declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
306) {
307    declared_arguments.insert(
308        path.to_owned(),
309        command
310            .get_arguments()
311            .map(|argument| argument.get_id().to_string())
312            .collect(),
313    );
314    for child in command.get_subcommands() {
315        let child_path = if path.is_empty() {
316            child.get_name().to_owned()
317        } else {
318            format!("{path} {}", child.get_name())
319        };
320        collect_declarations(child, &child_path, declared_arguments);
321    }
322}
323
324fn declared_argument(
325    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
326    path: &str,
327    argument: &Arg,
328) -> bool {
329    declared_arguments
330        .get(path)
331        .is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
332}
333
334fn argument(argument: &Arg) -> SurfaceArgument {
335    SurfaceArgument {
336        id: argument.get_id().to_string(),
337        index: argument.get_index(),
338        short: argument.get_short(),
339        long: argument.get_long().map(ToOwned::to_owned),
340        visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
341        short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
342        visible_aliases: argument
343            .get_visible_aliases()
344            .unwrap_or_default()
345            .iter()
346            .map(|alias| (*alias).to_owned())
347            .collect(),
348        aliases: argument
349            .get_all_aliases()
350            .unwrap_or_default()
351            .iter()
352            .map(|alias| (*alias).to_owned())
353            .collect(),
354        value_names: argument
355            .get_value_names()
356            .unwrap_or_default()
357            .iter()
358            .map(ToString::to_string)
359            .collect(),
360        help: argument
361            .get_help()
362            .map(ToString::to_string)
363            .unwrap_or_default(),
364        requirement: if argument.is_required_set() {
365            SurfaceRequirement::Required
366        } else {
367            SurfaceRequirement::Optional
368        },
369        scope: if argument.is_global_set() {
370            SurfaceScope::Global
371        } else {
372            SurfaceScope::Local
373        },
374        hidden: argument.is_hide_set(),
375        takes_values: argument.get_action().takes_values(),
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use clap::{ArgAction, Parser, Subcommand};
382    use indoc::indoc;
383    use serde::Serialize;
384
385    use super::{Surface, SurfaceArgument, SurfaceScope, render};
386
387    #[derive(Parser)]
388    #[command(name = "toy", version = "1.2.3", about = "Control toys")]
389    struct Cli {
390        #[arg(short, long, global = true, help = "Select a profile")]
391        profile: Option<String>,
392        #[command(subcommand)]
393        command: Commands,
394    }
395
396    #[derive(Subcommand)]
397    enum Commands {
398        /// Show current state.
399        #[command(alias = "state", visible_alias = "ls")]
400        Status {
401            /// Include archived records.
402            #[arg(long, action = ArgAction::SetTrue)]
403            archived: bool,
404        },
405        /// Mutate one item.
406        Item {
407            #[command(subcommand)]
408            command: ItemCommand,
409        },
410        #[command(hide = true)]
411        Internal,
412    }
413
414    #[derive(Subcommand)]
415    enum ItemCommand {
416        /// Create one item.
417        Add {
418            /// Item name.
419            name: String,
420        },
421    }
422
423    #[test]
424    fn extracts_the_complete_clap_surface() {
425        let surface = Surface::new::<Cli>("t");
426        assert_eq!(surface.binary, "toy");
427        assert_eq!(surface.mount, "t");
428        assert_eq!(surface.version.as_deref(), Some("1.2.3"));
429        assert_eq!(surface.about, "Control toys");
430        assert_eq!(
431            surface.inherited_arguments.as_slice(),
432            &[] as &[SurfaceArgument]
433        );
434        assert!(surface.usage_kdl.contains("status"));
435        assert_eq!(
436            surface.mount_line,
437            r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
438        );
439        let status = &surface.commands[0];
440        assert_eq!(status.visible_aliases, ["ls"]);
441        assert_eq!(status.aliases, ["state", "ls"]);
442        assert_eq!(status.about, "Show current state");
443        assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
444        assert!(!status.arguments[0].takes_values);
445        assert_eq!(status.arguments.len(), 1);
446        assert_eq!(status.inherited_arguments.len(), 1);
447        assert_eq!(
448            status.inherited_arguments[0].long.as_deref(),
449            Some("profile")
450        );
451        assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
452        assert!(
453            status
454                .arguments
455                .iter()
456                .all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
457        );
458        let item = &surface.commands[1];
459        assert_eq!(item.commands[0].path, "item add");
460        assert_eq!(item.commands[0].arguments[0].index, Some(1));
461        assert_eq!(item.commands[0].inherited_arguments.len(), 1);
462        assert_eq!(
463            item.commands[0].inherited_arguments[0].long.as_deref(),
464            Some("profile")
465        );
466        assert!(surface.commands[2].hidden);
467        assert!(
468            surface
469                .arguments
470                .iter()
471                .any(|arg| arg.long.as_deref() == Some("profile"))
472        );
473        assert!(
474            surface
475                .arguments
476                .iter()
477                .all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
478        );
479        let noted = surface.note("skill", "Prefer the mounted task.");
480        assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
481    }
482
483    #[derive(Serialize)]
484    struct Content<'a> {
485        version: &'a str,
486        invocations: [&'a str; 2],
487    }
488
489    #[test]
490    fn shared_fragments_render_committed_operator_blocks() {
491        let surface = Surface::new::<Cli>("t");
492        let template = indoc! {r#"
493            {%- from "ctl/version.md.jinja" import version_line -%}
494            {%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
495            {%- from "ctl/commands.md.jinja" import command_inventory -%}
496            ---
497            {{ version_line(content.version) }}
498            ---
499
500            {{ mounted_invocation(surface, content.invocations) }}
501
502            {{ command_inventory(surface) -}}
503        "#};
504        let rendered = render(
505            "operator.md.jinja",
506            template,
507            &surface,
508            &Content {
509                version: "1.2.3",
510                invocations: ["status", "item add demo"],
511            },
512        )
513        .unwrap_or_else(|error| panic!("render operator template: {error}"));
514        let expected = indoc! {r"
515            ---
516            version: 1.2.3
517            ---
518
519            ## Invocation
520
521            ```sh
522            mise run t status
523            mise run t item add demo
524            ```
525
526            Never `mise run t --`. The `--` in `#USAGE mount` is mise's
527            completion bootstrap.
528
529            ## Commands
530
531            | Command | Aliases | Purpose |
532            |:--|:--|:--|
533            | `status` | `ls` | Show current state |
534            | `item` | — | Mutate one item |
535        "};
536        assert_eq!(rendered, expected);
537        assert!(!rendered.contains("internal"));
538    }
539}