Skip to main content

ctl_core/
usage.rs

1//! Mise Usage spec from a clap [`Command`].
2//!
3//! A CLI answers `--usage-spec[=BIN]` before Clap parses, so it declares no
4//! such flag. A mise file task that runs the CLI carries
5//! [`mount_directives`], which end with [`mount_line`]: a `#USAGE mount`
6//! line, from which mise asks the CLI for its spec. A user then runs
7//! `mise run toy status` with no `--`. The `--` in the mount line is only how
8//! mise fetches the spec, as mise documents.
9//!
10//! [`App::mounted_as`] answers the request for an [`App`], and [`take`] does
11//! the same for a binary without one.
12#![cfg_attr(
13    feature = "app",
14    doc = "",
15    doc = "[`App::mounted_as`]: crate::App::mounted_as",
16    doc = "[`App`]: crate::App"
17)]
18#![cfg_attr(
19    not(feature = "app"),
20    doc = "",
21    doc = "[`App::mounted_as`]: crate#cargo-features",
22    doc = "[`App`]: crate#cargo-features"
23)]
24
25mod kdl;
26mod nodes;
27mod plain;
28
29use std::io::Write as _;
30use std::process::ExitCode;
31
32use clap::Command;
33
34use crate::formatdoc;
35
36/// Render the Usage KDL spec of `command` as a mise task named `bin`.
37///
38/// Both the spec's `name` and `bin` are set to `bin`, so mise shows the task
39/// name and not the real binary name. Every string reads back unchanged
40/// under KDL v2: a character KDL v2 forbids literally is written escaped.
41///
42/// # Panics
43///
44/// Panics when an argument conflicts with an id that is neither an argument
45/// nor a group of its command, as [`Command::get_arg_conflicts_with`] does.
46#[must_use]
47pub fn spec(mut command: Command, bin: &str) -> String {
48    command.set_bin_name(bin);
49    nodes::document(command, bin).to_string()
50}
51
52/// The `#USAGE mount` line a mise file task carries for the task named
53/// `task`.
54///
55/// The line runs `mise run --quiet <task> -- --usage-spec=<task>`. That
56/// requests the task's own spec.
57#[must_use]
58pub fn mount_line(task: &str) -> String {
59    formatdoc! {r#"#USAGE mount "mise run --quiet {task} -- --usage-spec={task}""#}
60}
61
62/// The mise directives a file task that runs a CLI carries for the task named
63/// `task`, ending with [`mount_line`].
64///
65/// - `raw_args` passes argv through untouched.
66/// - `raw` connects the CLI to the caller's stdin and stdout. Without it mise
67///   relays each output line itself. Whenever mise's own colour is on, as under
68///   `CLICOLOR_FORCE=1` even into a pipe, it appends an SGR reset to every
69///   line. Then `--format json` piped to `jq` would carry an ESC byte the CLI
70///   never wrote.
71///
72/// A raw task also gets no line prefix, and mise runs raw tasks one at a
73/// time.
74#[must_use]
75pub fn mount_directives(task: &str) -> String {
76    formatdoc! {"
77        #MISE raw=true
78        #MISE raw_args=true
79        {mount}",
80        mount = mount_line(task),
81    }
82}
83
84/// If the process argv asks for `--usage-spec[=BIN]`, print the spec of `C` to
85/// stdout and return the exit code to end with.
86///
87/// `default_bin` names the task when the flag has no `=BIN`. The result is
88/// [`ExitCode::SUCCESS`] when the write worked and [`ExitCode::FAILURE`]
89/// otherwise, and `None` when argv has no such flag.
90#[must_use]
91pub fn take<C: clap::CommandFactory>(default_bin: &str) -> Option<ExitCode> {
92    let bin = spec_bin(std::env::args().skip(1), default_bin)?;
93    let written = write!(std::io::stdout().lock(), "{}", spec(C::command(), &bin));
94    Some(if written.is_ok() {
95        ExitCode::SUCCESS
96    } else {
97        ExitCode::FAILURE
98    })
99}
100
101/// Read `--usage-spec` or `--usage-spec=BIN` from `args`, which exclude the
102/// program name.
103///
104/// Returns the task name the spec is for: `BIN`, or `default_bin` for a bare
105/// flag or an empty `BIN`. Scanning stops at `--`, so an argument after it
106/// never counts.
107#[must_use]
108pub fn spec_bin(
109    args: impl IntoIterator<Item = impl AsRef<str>>,
110    default_bin: &str,
111) -> Option<String> {
112    for arg in args {
113        let arg = arg.as_ref();
114        if arg == "--" {
115            break;
116        }
117        if arg == "--usage-spec" {
118            return Some(default_bin.to_owned());
119        }
120        if let Some(bin) = arg.strip_prefix("--usage-spec=") {
121            return Some(if bin.is_empty() {
122                default_bin.to_owned()
123            } else {
124                bin.to_owned()
125            });
126        }
127    }
128    None
129}
130
131#[cfg(test)]
132mod tests {
133    use clap::{CommandFactory, Parser};
134
135    use super::{mount_directives, mount_line, spec, spec_bin};
136
137    #[derive(Parser)]
138    #[command(name = "toy")]
139    struct Toy {
140        #[command(subcommand)]
141        command: ToyCommand,
142    }
143
144    #[derive(clap::Subcommand)]
145    enum ToyCommand {
146        Status,
147        Check,
148    }
149
150    #[test]
151    fn spec_bin_reads_equals_and_bare() {
152        assert_eq!(spec_bin(["--usage-spec"], "qctl").as_deref(), Some("qctl"));
153        assert_eq!(spec_bin(["--usage-spec=q"], "qctl").as_deref(), Some("q"));
154        assert_eq!(spec_bin(["status"], "qctl"), None);
155        assert_eq!(spec_bin(["status", "--", "--usage-spec"], "qctl"), None);
156        assert_eq!(
157            spec_bin(["--usage-spec=q", "--", "status"], "qctl").as_deref(),
158            Some("q")
159        );
160    }
161
162    #[test]
163    fn spec_names_the_mounted_bin() {
164        let text = spec(Toy::command(), "q");
165        assert!(text.contains("name") && text.contains("status"), "{text}");
166    }
167
168    #[test]
169    fn mount_line_is_the_mise_bootstrap() {
170        assert_eq!(
171            mount_line("q"),
172            r#"#USAGE mount "mise run --quiet q -- --usage-spec=q""#
173        );
174    }
175
176    #[test]
177    fn mount_directives_end_with_the_mount_line() {
178        let directives = mount_directives("q");
179        assert_eq!(
180            directives.lines().collect::<Vec<_>>(),
181            [
182                "#MISE raw=true",
183                "#MISE raw_args=true",
184                mount_line("q").as_str()
185            ]
186        );
187    }
188}