Skip to main content

ctl_core/
usage.rs

1//! Mise Usage spec from a clap [`Command`](clap::Command).
2//!
3//! One hidden flag, one mount line, one operator form. A consumer writes
4//! `--usage-spec[=BIN]` (hidden, `require_equals`). The served mise task
5//! carries [`mount_directives`], which ends with [`mount_line`]. Operators then
6//! run `mise run q status` — no `--`.
7//! The `--` in the mount is only the completion bootstrap, as mise documents.
8//!
9//! Lefthook calls the same task: `mise run q close-from-git`.
10
11use std::process::ExitCode;
12
13use ::usage::Spec;
14use clap::Command;
15
16use crate::formatdoc;
17
18/// Render a Usage KDL spec for a mise-mounted task named `bin`.
19#[must_use]
20pub fn spec(mut command: Command, bin: &str) -> String {
21    command.set_bin_name(bin);
22    let mut spec = Spec::from(&command);
23    spec.name = bin.to_string();
24    spec.bin = bin.to_string();
25    spec.to_string()
26}
27
28/// The `#USAGE mount` line a served mise file task carries.
29#[must_use]
30pub fn mount_line(task: &str) -> String {
31    formatdoc! {r#"#USAGE mount "mise run --quiet {task} -- --usage-spec={task}""#}
32}
33
34/// The mise directives a served CLI-proxy task carries, ending with
35/// [`mount_line`]. `raw_args` passes argv through untouched. `raw` connects
36/// the CLI to the caller's stdin and stdout: without it mise relays each
37/// output line itself and, whenever its own colour is on (as under
38/// `CLICOLOR_FORCE=1`, even into a pipe), appends an SGR reset to every line,
39/// so `--format json` piped to `jq` carries an ESC byte the CLI never wrote.
40/// A raw task also gets no line prefix, and mise runs raw tasks one at a time.
41#[must_use]
42pub fn mount_directives(task: &str) -> String {
43    formatdoc! {"
44        #MISE raw=true
45        #MISE raw_args=true
46        {mount}",
47        mount = mount_line(task),
48    }
49}
50
51/// If argv contains `--usage-spec[=BIN]`, print the spec for `C` and return
52/// [`ExitCode::SUCCESS`].
53#[must_use]
54pub fn take<C: clap::CommandFactory>(default_bin: &str) -> Option<ExitCode> {
55    let bin = spec_bin(std::env::args().skip(1), default_bin)?;
56    print!("{}", spec(C::command(), &bin));
57    Some(ExitCode::SUCCESS)
58}
59
60/// Parse `--usage-spec` / `--usage-spec=BIN` from argv (after the program
61/// name).
62#[must_use]
63pub fn spec_bin(
64    args: impl IntoIterator<Item = impl AsRef<str>>,
65    default_bin: &str,
66) -> Option<String> {
67    for arg in args {
68        let arg = arg.as_ref();
69        if arg == "--" {
70            break;
71        }
72        if arg == "--usage-spec" {
73            return Some(default_bin.to_owned());
74        }
75        if let Some(bin) = arg.strip_prefix("--usage-spec=") {
76            return Some(if bin.is_empty() {
77                default_bin.to_owned()
78            } else {
79                bin.to_owned()
80            });
81        }
82    }
83    None
84}
85
86#[cfg(test)]
87mod tests {
88    use clap::{CommandFactory, Parser};
89
90    use super::{mount_directives, mount_line, spec, spec_bin};
91
92    #[derive(Parser)]
93    #[command(name = "toy")]
94    struct Toy {
95        #[command(subcommand)]
96        command: ToyCommand,
97    }
98
99    #[derive(clap::Subcommand)]
100    enum ToyCommand {
101        Status,
102        Check,
103    }
104
105    #[test]
106    fn spec_bin_reads_equals_and_bare() {
107        assert_eq!(spec_bin(["--usage-spec"], "qctl").as_deref(), Some("qctl"));
108        assert_eq!(spec_bin(["--usage-spec=q"], "qctl").as_deref(), Some("q"));
109        assert_eq!(spec_bin(["status"], "qctl"), None);
110        assert_eq!(spec_bin(["status", "--", "--usage-spec"], "qctl"), None);
111        assert_eq!(
112            spec_bin(["--usage-spec=q", "--", "status"], "qctl").as_deref(),
113            Some("q")
114        );
115    }
116
117    #[test]
118    fn spec_names_the_mounted_bin() {
119        let text = spec(Toy::command(), "q");
120        assert!(text.contains("name") && text.contains("status"), "{text}");
121    }
122
123    #[test]
124    fn mount_line_is_the_mise_bootstrap() {
125        assert_eq!(
126            mount_line("q"),
127            r#"#USAGE mount "mise run --quiet q -- --usage-spec=q""#
128        );
129    }
130
131    #[test]
132    fn mount_directives_end_with_the_mount_line() {
133        let directives = mount_directives("q");
134        assert_eq!(
135            directives.lines().collect::<Vec<_>>(),
136            [
137                "#MISE raw=true",
138                "#MISE raw_args=true",
139                mount_line("q").as_str()
140            ]
141        );
142    }
143}