Skip to main content

usage_cli/cli/
mod.rs

1use std::collections::HashMap;
2use std::ffi::OsStr;
3
4use usage::miette::Result;
5use usage_rs::{Cli as DeriveCli, Subcommands};
6
7mod complete_delegate;
8pub mod complete_word;
9mod diff;
10mod exec;
11mod explain;
12pub(crate) mod generate;
13mod lint;
14mod mcp;
15mod shell;
16mod sponsors;
17
18/// Generate completions, docs, manpages, and SDKs from a usage spec, and run scripts that carry one
19///
20/// A usage spec describes a CLI's commands, flags, and arguments once, in KDL. Most commands
21/// here read one: a `.usage.kdl` file given with `-f`, a script whose `#USAGE` comments declare
22/// it, or `-` for stdin. A binary built with the Rust framework prints its own spec, so a
23/// pipeline like `mycli __usage_spec__ | usage generate markdown -f -` needs no file at all.
24///
25/// Guides and a reference for every command: https://usage.jdx.dev/cli/
26// `usage` parses its own command line with the parser it ships: the tables below are the same
27// ones an adopter's CLI compiles into, and `--usage-spec` prints the spec they emit rather
28// than a transcription of a clap command. Said here rather than in the doc comment, which is
29// the help page a user reads.
30//
31// 3.6 added `effect=` and 4.0 added it on flags and args; older `usage` CLIs reject the spec
32// outright with "unsupported cmd prop effect", so this moves in lockstep with the fields the
33// spec actually carries.
34//
35// Owed a bump: the four shell commands flatten `Shell`, which now emits a `flagset`, and no
36// 4.0 can read that node. The floor is whichever release carries flagsets, so the number waits
37// for that release rather than being guessed at here — and this crate warning about its own
38// spec until then is worse than the stale claim.
39#[derive(DeriveCli)]
40#[usage(completion = true)]
41#[usage(
42    bin = "usage",
43    version,
44    // The project's mark in block characters: the flag on its pole, the two dashes it carries,
45    // and the copies behind it. In a file rather than in the attribute because art is edited by
46    // looking at it, and a raw string indented to match this list would not be what prints.
47    logo = include_str!("logo.txt"),
48    logo_style = "magenta",
49    min_usage_version = "6.5",
50    repository = "https://github.com/jdx/usage",
51    // The command path is not the file path: command names are hyphenated where the files
52    // that implement them are snake_case, a command with subcommands lives in its directory's
53    // `mod.rs`, and the four shell commands are all served by a single `shell.rs`.
54    //
55    // Unindented, because a raw string keeps every leading space it is given and only the
56    // `{%-`/`-%}` markers take any back — so indenting to match the attribute would be
57    // trusting each line to be surrounded by them.
58    source_code_link_template = r#"{%- set path = path | replace(from='-', to='_') -%}
59{%- if cmd.subcommands | length > 0 -%}
60{%- set path = path ~ "/mod.rs" -%}
61{%- elif path in ["bash", "fish", "powershell", "zsh"] -%}
62{%- set path = "shell.rs" -%}
63{%- else -%}
64{%- set path = path ~ ".rs" -%}
65{%- endif -%}
66https://github.com/jdx/usage/blob/main/cli/src/cli/{{path}}"#,
67    usage = "Usage: usage <COMMAND>\n       usage --completions <COMPLETIONS>\n       usage --usage-spec",
68    // Every flag `usage` accepts is one it declares, so an unrecognised one is a mistake and
69    // saying so beats offering it to a positional — `usage lint --nope f.kdl` would otherwise
70    // make `--nope` the file and call the real file unexpected.
71    //
72    // Declared once, on the root: every subcommand inherits it. The five that hand a command
73    // line to somebody else's script say `value` for themselves, which is the whole reason
74    // both halves are needed.
75    unknown_flags = "error"
76)]
77pub struct Cli {
78    #[usage(subcommand)]
79    command: Command,
80
81    /// Print a completion script for the `usage` CLI itself, for the named shell
82    // `--completions <shell>` is normally answered in `crate::run` before a parse happens,
83    // because a shell init script asks for it on every new shell and it does not need a
84    // subcommand to answer. It remains a real parsed field so direct callers of `Cli::run`
85    // and the help, spec, and completions all read the same declaration.
86    //
87    // A flag, which is how it is typed. The clap declaration made it a *positional* — so the
88    // spec, the docs and the generated completions all described a `[COMPLETIONS]` argument
89    // that nothing accepts, while the flag that does work went undocumented. Carried over
90    // faithfully at first, wrong included; the point of emitting the spec from the
91    // declaration is that the two cannot disagree, so the declaration is what changes.
92    #[usage(long)]
93    completions: Option<String>,
94
95    /// Print the usage spec for the `usage` CLI itself
96    #[usage(long)]
97    usage_spec: bool,
98}
99
100/// What `--version` and `-v` answer with.
101///
102/// The binary's name, not the crate's. They differ here — `usage-cli` ships `usage` — and
103/// everything else this CLI says about itself now comes from the spec, where the name is
104/// `usage`. Read from the spec rather than written out again, so a rename cannot leave the
105/// version line saying something the help page above it contradicts.
106pub(crate) fn version() -> String {
107    let spec = Cli::spec();
108    format!(
109        "{} {}",
110        spec.bin.unwrap_or(spec.name),
111        spec.version.unwrap_or(env!("CARGO_PKG_VERSION"))
112    )
113}
114
115/// What `usage` can be asked to do.
116///
117/// Each command's description is its struct's doc comment, in the file that owns it — and so
118/// is the code that carries it out: `run` generates the match that hands the selected command
119/// to its `usage_rs::Run` implementation, so this list is the only place a command is named.
120#[derive(Subcommands)]
121#[usage(run)]
122enum Command {
123    Bash(shell::Bash),
124    CompleteWord(complete_word::CompleteWord),
125    Diff(diff::Diff),
126    Exec(exec::Exec),
127    Explain(explain::Explain),
128    Fish(shell::Fish),
129    Generate(generate::Generate),
130    Lint(lint::Lint),
131    Mcp(mcp::Mcp),
132    #[usage(name = "powershell")]
133    PowerShell(shell::PowerShell),
134    Sponsors(sponsors::Sponsors),
135    Zsh(shell::Zsh),
136}
137
138impl Cli {
139    pub fn run(argv: &[String]) -> Result<()> {
140        // `parse_from` takes the command line without the program name, and hands back what
141        // went wrong instead of ending the process — which is what lets the error come out
142        // through the same path as every other failure here.
143        let words: Vec<&OsStr> = argv.iter().skip(1).map(OsStr::new).collect();
144        // `exec` owns `-h` and `--help`: once COMMAND and BIN are present they describe the
145        // wrapped script, not this command. With no script to describe, answer the ordinary
146        // command-help request before its required positionals are checked. `usage help exec`
147        // reaches the same page through the parser's synthesized help command.
148        if let [command, help] = words.as_slice()
149            && matches!(command.to_str(), Some("exec" | "x"))
150            && matches!(help.to_str(), Some("-h" | "--help"))
151        {
152            let spec = Self::spec();
153            let exec = spec
154                .root
155                .subcommands
156                .iter()
157                .find(|subcommand| subcommand.cmd.name == "exec")
158                .expect("the CLI declares its exec command");
159            if let Some(page) = usage_rs::help::render_styled(
160                spec,
161                exec.cmd,
162                *help == OsStr::new("--help"),
163                usage_rs::help::Style::auto(),
164            ) {
165                print!("{page}");
166            }
167            return Ok(());
168        }
169        shell::note_supplied_words(&words);
170        let cli = match Self::parse_from(&words) {
171            Ok(cli) => cli,
172            // Not failures: someone asked a question, and the answer goes to stdout.
173            Err(usage_rs::Error::Help { cmd, long }) => {
174                if let Some(page) = usage_rs::help::render_styled(
175                    Self::spec(),
176                    cmd,
177                    long,
178                    usage_rs::help::Style::auto(),
179                ) {
180                    print!("{page}");
181                }
182                return Ok(());
183            }
184            Err(usage_rs::Error::HelpAll { cmd }) => {
185                if let Some(page) = usage_rs::help::render_all_styled(
186                    Self::spec(),
187                    cmd,
188                    usage_rs::help::Style::auto(),
189                ) {
190                    print!("{page}");
191                }
192                return Ok(());
193            }
194            Err(usage_rs::Error::Version { .. }) => {
195                println!("{}", version());
196                return Ok(());
197            }
198            Err(err) => {
199                eprint!("{}", usage_rs::render_failure(Self::spec(), &words, &err));
200                // clap's status for a command line it could not parse, which is what the
201                // scripts that call this have been checking for.
202                std::process::exit(2);
203            }
204        };
205        if let Some(shell) = cli.completions.as_deref() {
206            return crate::usage_spec::complete(shell);
207        }
208        if cli.usage_spec {
209            return crate::usage_spec::generate();
210        }
211        // The match that used to be here, one arm per command, is generated from the enum
212        // above — so a command added there cannot be left unrouted, and no arm can route to
213        // the wrong handler.
214        usage_rs::Run::run(cli.command)
215    }
216}
217
218/// A spec that declares nothing, as the answer to every mount in the tree.
219///
220/// Keyed by the exact `run` string, which is how injected answers are looked up. It is a
221/// whole spec rather than an empty string because that is what a mount's stdout is.
222///
223/// Shared by `lint` and `explain`, which want it for the same reason: usage-lib resolves a
224/// command's mounts on the way *into* it, so a spec that mounts anything cannot be parsed
225/// without either spawning the mounted program or being handed its answer — and a command
226/// that reads a file and prints a report should not spawn whatever that file names.
227pub(crate) fn empty_mount_answers(cmd: &usage::SpecCommand) -> HashMap<String, String> {
228    let mut answers = HashMap::new();
229    collect_mount_answers(cmd, &mut answers);
230    answers
231}
232
233fn collect_mount_answers(cmd: &usage::SpecCommand, answers: &mut HashMap<String, String>) {
234    for mount in &cmd.mounts {
235        answers.insert(
236            mount.run.clone(),
237            "name \"mounted\"\nbin \"mounted\"\n".to_string(),
238        );
239    }
240    for sub in cmd.subcommands.values() {
241        collect_mount_answers(sub, answers);
242    }
243}
244
245/// How a command that can print either prose or JSON was asked to print.
246///
247/// Shared rather than declared twice: `lint` and `explain` both offer it, and a third
248/// copy of the same four-line `FromStr` is how the two spellings drift apart.
249#[derive(Debug, Clone, Copy, Default, usage_rs::ValueEnum)]
250pub(crate) enum OutputFormat {
251    #[default]
252    Text,
253    Json,
254}
255
256impl std::str::FromStr for OutputFormat {
257    type Err = String;
258
259    fn from_str(value: &str) -> Result<Self, Self::Err> {
260        // Delegated rather than matched again: the derive already lists the words, and a
261        // second list beside it is one more thing that can fall out of step with the type.
262        use usage_rs::spec::ValueEnum;
263        Self::from_choice(value).ok_or_else(|| {
264            format!(
265                "`{value}` is not one of: {}",
266                Self::ACCEPTED_CHOICES.join(", ")
267            )
268        })
269    }
270}