Skip to main content

usage/spec/
mount.rs

1use std::fmt::Display;
2
3use crate::kdl::{KdlEntry, KdlNode};
4use serde::Serialize;
5
6use crate::error::Result;
7use crate::spec::context::ParsingContext;
8use crate::spec::helpers::{string_entry, NodeHelper};
9
10#[derive(Debug, Default, Clone, Serialize)]
11#[non_exhaustive]
12pub struct SpecMount {
13    pub run: String,
14    /// Display-only arguments for this unresolved mount; never executes discovery.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub synopsis: Option<String>,
17    /// Whether a discovered command may take precedence over
18    /// [`Spec::default_subcommand`](crate::Spec::default_subcommand).
19    ///
20    /// Off by default, because resolving a mount runs a process: with a default
21    /// subcommand declared, every word that is not a known command would otherwise
22    /// pay for discovery before falling back — for a task runner, that is a
23    /// subprocess per task invocation. Turn it on when a discovered command should
24    /// win, and accept the cost.
25    pub overrides_default: bool,
26}
27
28impl SpecMount {
29    /// A mount that runs `run` to produce a spec for the subcommands here.
30    pub fn new(run: impl Into<String>) -> Self {
31        Self {
32            run: run.into(),
33            synopsis: None,
34            overrides_default: false,
35        }
36    }
37
38    /// The same, but a discovered command outranks the default subcommand.
39    pub fn overriding_default(run: impl Into<String>) -> Self {
40        Self {
41            run: run.into(),
42            synopsis: None,
43            overrides_default: true,
44        }
45    }
46}
47
48impl SpecMount {
49    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
50        let mut mount = SpecMount::default();
51        for (k, v) in node.props() {
52            match k {
53                "run" => mount.run = v.ensure_string()?,
54                "synopsis" => mount.synopsis = Some(v.ensure_string()?),
55                "overrides_default" => mount.overrides_default = v.ensure_bool()?,
56                k => bail_parse!(ctx, v.entry.span(), "unsupported mount key {k}"),
57            }
58        }
59        for child in node.children() {
60            match child.name() {
61                "run" => mount.run = child.arg(0)?.ensure_string()?,
62                "synopsis" => mount.synopsis = Some(child.arg(0)?.ensure_string()?),
63                "overrides_default" => mount.overrides_default = child.arg(0)?.ensure_bool()?,
64                k => bail_parse!(
65                    ctx,
66                    child.node.name().span(),
67                    "unsupported mount value key {k}"
68                ),
69            }
70        }
71        if mount.run.is_empty() {
72            bail_parse!(ctx, node.span(), "mount run is required")
73        }
74        Ok(mount)
75    }
76    pub fn usage(&self) -> String {
77        format!("mount:{}", self.run)
78    }
79}
80
81impl From<&SpecMount> for KdlNode {
82    fn from(mount: &SpecMount) -> KdlNode {
83        let mut node = KdlNode::new("mount");
84        node.push(string_entry(Some("run"), &mount.run));
85        if let Some(synopsis) = &mount.synopsis {
86            node.push(string_entry(Some("synopsis"), synopsis));
87        }
88        if mount.overrides_default {
89            node.push(KdlEntry::new_prop("overrides_default", true));
90        }
91        node
92    }
93}
94
95impl Display for SpecMount {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "{}", self.usage())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    #[test]
104    fn unresolved_mount_synopsis_round_trips_and_renders_without_discovery() {
105        let spec: crate::Spec = "bin ex\ncmd run {\n mount run=\"this-command-must-never-run\" synopsis=\"[TASK] [ARGS]…\"\n}\n".parse().unwrap();
106        let run = &spec.cmd.subcommands["run"];
107        assert_eq!(run.usage, "run [TASK] [ARGS]…");
108        let again: crate::Spec = spec.to_string().parse().unwrap();
109        assert_eq!(
110            again.cmd.subcommands["run"].mounts[0].synopsis.as_deref(),
111            Some("[TASK] [ARGS]…")
112        );
113        let json = serde_json::to_value(&spec).unwrap();
114        assert_eq!(
115            json["cmd"]["subcommands"]["run"]["usage"],
116            "run [TASK] [ARGS]…"
117        );
118        #[cfg(feature = "markdown")]
119        assert!(crate::docs::markdown::MarkdownRenderer::new(spec.clone())
120            .render_cmd(run)
121            .unwrap()
122            .contains("run [TASK] [ARGS]…"));
123        #[cfg(feature = "cli-help")]
124        assert!(crate::docs::cli::render_help(&spec, run, true).contains("run [TASK] [ARGS]…"));
125    }
126
127    #[test]
128    fn subcommand_required_controls_custom_placeholder() {
129        for (required, expected) in [(true, "<ACTION>"), (false, "[ACTION]")] {
130            let source = format!(
131                "bin ex\nsubcommand_required #{required}\nsubcommand_value_name ACTION\ncmd go\n"
132            );
133            let spec: crate::Spec = source.parse().unwrap();
134            assert_eq!(spec.cmd.usage, expected);
135        }
136        let spec: crate::Spec = "bin ex\nmount run=\"never-run\"\n".parse().unwrap();
137        assert_eq!(spec.cmd.usage, "");
138    }
139}