usage-cli 7.1.2

CLI for working with usage-based CLIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use std::fmt::Debug;
use std::path::PathBuf;
use std::process::Stdio;

use itertools::Itertools;
use usage::miette::IntoDiagnostic;
use usage_rs::Args;

use usage::Spec;

use crate::env;

/// What the four shell commands accept, declared once.
///
/// Not a command itself: each of `bash`, `fish`, `powershell` and `zsh` flattens it, so the
/// emitted spec lists these on all four exactly as a hand-written spec would. They cannot
/// share one struct instead — a command collects into the struct that declares it, so the
/// derive refuses two variants wrapping one — and the shell to run is the command that ran.
#[derive(Debug, Args)]
pub struct Shell {
    /// The script to run; its spec is read from the `#USAGE` comments at the top of the file
    #[usage(double_dash = "automatic")]
    script: PathBuf,

    /// Arguments to pass to the script
    ///
    /// Anything `usage` does not recognise is a value rather than a mistake, which is what
    /// lets a shebang script take flags of its own.
    #[usage(double_dash = "preserve")]
    args: Vec<String>,

    /// Print the script's help page instead of running it
    #[usage(short)]
    h: bool,

    /// Print the script's help page instead of running it
    #[usage(long)]
    help: bool,
}

/// One of the four commands that run a script, which differ only in the shell they name.
///
/// Everything true of all four is written here once. What is not — the shebang to write, the
/// program that ends up running, and the variable that overrides it — is passed in as
/// `$shell_specific`, because a page that told a `usage zsh` reader to write a `usage bash`
/// shebang would be wrong three times out of four.
///
/// One literal per shell rather than interpolation: a `concat!` in a doc attribute is still a
/// macro call by the time the derive reads it, and the derive wants a literal.
macro_rules! shell_command {
    ($ty:ident, $program:literal, $about:tt, $shell_specific:literal) => {
        #[doc = "Run a script whose usage spec is written in its own comments"]
        #[doc = ""]
        #[doc = "Usually reached through the script's shebang: the kernel hands the"]
        #[doc = "script and its arguments to `usage`, which parses them against the"]
        #[doc = "`#USAGE` lines at the top of the file, then runs the script with this"]
        #[doc = "shell. Each flag and argument reaches the script as an environment"]
        #[doc = "variable named `usage_<name>`, so `--force` becomes `usage_force` and"]
        #[doc = "`<file>` becomes `usage_file`. A value declared `var=#true` arrives as"]
        #[doc = "one string, joined with `shell_words::join()` so that an element"]
        #[doc = "containing a space stays quoted."]
        #[doc = ""]
        #[doc = "`-h` and `--help` print the script's help page rather than this one."]
        #[doc = ""]
        #[doc = $shell_specific]
        #[derive(Debug, Args)]
        // The words after the script are the script's, so a flag `usage` does not know is a
        // value to forward rather than a mistake to report — the root's `error` stops here.
        #[usage(about = $about, unknown_flags = "value", verbatim_doc_comment)]
        pub struct $ty {
            #[usage(flatten)]
            pub shell: Shell,
        }

        impl usage_rs::Run for $ty {
            type Output = usage::miette::Result<()>;

            fn run(mut self) -> Self::Output {
                self.shell.run($program)
            }
        }
    };
}

shell_command!(
    Bash,
    "bash",
    "Execute a shell script using bash",
    // Hand-wrapped for an 80-column terminal, like every other `verbatim_doc_comment` in
    // this crate: verbatim keeps these line breaks, so a longer line is re-wrapped by the
    // help renderer and comes out ragged.
    "This command's shebang is `#!/usr/bin/env -S usage bash`, and the\nprogram it runs is `bash` from PATH. `USAGECLI_SHELL_BASH` names a\ndifferent one, which is how it is pointed at Git Bash on a Windows\nmachine where `bash` is the WSL launcher."
);
shell_command!(
    Fish,
    "fish",
    "Execute a shell script using fish",
    "This command's shebang is `#!/usr/bin/env -S usage fish`, and the\nprogram it runs is `fish` from PATH. `USAGECLI_SHELL_FISH` names a\ndifferent one."
);
shell_command!(
    PowerShell,
    "pwsh",
    "Execute a shell script using PowerShell",
    "This command's shebang is `#!/usr/bin/env -S usage powershell`, and\nthe program it runs is `pwsh` from PATH. `USAGECLI_SHELL_PWSH` names a\ndifferent one, which is also how it is pointed at `powershell.exe` on a\nmachine that has only Windows PowerShell."
);
shell_command!(
    Zsh,
    "zsh",
    "Execute a shell script using zsh",
    "This command's shebang is `#!/usr/bin/env -S usage zsh`, and the\nprogram it runs is `zsh` from PATH. `USAGECLI_SHELL_ZSH` names a\ndifferent one."
);

thread_local! {
    /// The command line `Cli::run` was handed, which is not the process's when `usage` is
    /// embedded: the derive hands `Shell` only what it bound, and a `--` it consumed is gone.
    static SUPPLIED_WORDS: std::cell::RefCell<Vec<std::ffi::OsString>> = const {
        std::cell::RefCell::new(Vec::new())
    };
}

/// Remember the words the command line was parsed from, for [`Shell::run`] to ask about.
pub fn note_supplied_words(words: &[&std::ffi::OsStr]) {
    SUPPLIED_WORDS.set(words.iter().map(|word| (*word).to_owned()).collect());
}

impl Shell {
    pub fn run(&mut self, shell: &str) -> usage::miette::Result<()> {
        // Whether the command line already spent a `--` before naming the script: that one
        // was `usage`'s, so every `--` left in the arguments is the script's own.
        // Taken rather than borrowed, so a later run on this thread never reads this one's words.
        let separator_before_script = {
            let words = SUPPLIED_WORDS.take();
            // By position, not by name: the script may be spelled like the command before it.
            // The first plain word is the command, and the script is the next one that is
            // neither a flag nor, once a `--` has been seen, anything but the next word.
            let mut rest = words
                .iter()
                .skip_while(|word| word.to_string_lossy().starts_with('-'));
            rest.next();
            let mut separated = false;
            for word in rest {
                if separated || !word.to_string_lossy().starts_with('-') {
                    break;
                }
                separated |= word == "--";
            }
            separated
        };
        // Held until the shell exits: dropping it deletes the copy the shell is reading.
        let _copy = match ScriptCopy::for_stream(&self.script, shell)? {
            Some(copy) => {
                self.script = copy.path.clone();
                Some(copy)
            }
            None => None,
        };
        let spec = Spec::parse_file(&self.script)?;
        // The `--` is kept here so the script's spec reads it as the separator it is: without
        // it, `positional -- --help` reaches the spec as `positional --help`, and the help
        // request after the separator is taken for a flag.
        let mut args = self.args.clone();
        if separator_before_script {
            args.insert(0, "--".to_string());
        }
        args.insert(0, spec.bin.clone());

        // Flag parsing ends at the script, so a `-h` after it arrives as one of the script's
        // words. It still asks for the help page, but only before a `--`: what follows the
        // separator is data, and `--help` there is a value for the script.
        let asked = self
            .args
            .iter()
            .filter(|_| !separator_before_script)
            .take_while(|word| *word != "--")
            .find_map(|word| match word.as_str() {
                "-h" => Some(false),
                "--help" => Some(true),
                _ => None,
            });
        if self.h || asked == Some(false) {
            return self.help(&spec, &args, false);
        }
        if self.help || asked == Some(true) {
            return self.help(&spec, &args, true);
        }

        let parsed = usage::parse::parse(&spec, &args)?;
        debug!("{parsed:?}");

        let overridden = env::shell_program_override_entry(shell, |key| env::var(key).ok());
        let program = overridden
            .as_ref()
            .map_or_else(|| shell.to_string(), |(_, value)| value.clone());
        debug!("running {program}");

        let mut cmd = std::process::Command::new(&program);
        cmd.stdin(Stdio::inherit());
        cmd.stdout(Stdio::inherit());
        cmd.stderr(Stdio::inherit());
        let run_path = _copy.as_ref().map_or(&self.script, |copy| &copy.run_path);
        let script_path = run_path
            .to_str()
            .ok_or_else(|| usage::miette::miette!("Invalid file path: {}", run_path.display()))?
            .to_string();
        // The script is handed what it was before the separator was kept: the first `--` is
        // `usage`'s own unless one came before the script, and any other is the script's.
        let mut script_args = self.args.clone();
        if !separator_before_script
            && let Some(separator) = script_args.iter().position(|word| word == "--")
        {
            script_args.remove(separator);
        }
        let args = std::iter::once(script_path.clone())
            .chain(script_args)
            .collect_vec();
        cmd.args(&args);

        env::apply_parsed_env(&mut cmd, &parsed.as_env());

        // Name the program: the bare io error says only "No such file or directory", which for
        // an overridden shell gives no clue that the variable is what pointed here. And name the
        // variable the user actually set — the legacy `USAGE_SHELL_*` is still read, so assuming
        // the current spelling would send them to one they never touched.
        let mut child = cmd.spawn().map_err(|err| match &overridden {
            Some((key, _)) => {
                usage::miette::miette!("failed to run `{program}` (from ${key}): {err}")
            }
            None => usage::miette::miette!("failed to run `{program}`: {err}"),
        })?;
        let result = child.wait().into_diagnostic()?;

        if !result.success() {
            let code = result.code().unwrap_or(1);
            // `exit` skips destructors, so the copy is removed here or not at all.
            drop(_copy);
            if cfg!(windows)
                && overridden.is_none()
                && let Some(hint) = wsl_path_hint(shell, code, &script_path)
            {
                eprintln!("{hint}");
            }
            std::process::exit(code);
        }

        Ok(())
    }

    pub fn help(&self, spec: &Spec, args: &[String], long: bool) -> usage::miette::Result<()> {
        let parsed = usage::parse::parse_partial(spec, args)?;
        print!(
            "{}",
            // The script's own help, so `choices run=` is run and its values listed.
            usage::docs::cli::render_runtime_help(
                spec,
                &parsed.cmd,
                long,
                usage::docs::cli::Style::auto(),
                None,
            )
        );
        Ok(())
    }
}

/// A private copy of a script that can only be read once.
///
/// `usage bash <(…)` hands over a pipe. Reading the spec out of it consumes it, so the shell,
/// given the same path, would find nothing left and run an empty script. The script is read
/// once into a directory only this user can open, and both the spec and the shell read that.
struct ScriptCopy {
    dir: PathBuf,
    /// The copy the spec is read from, under the original file name so that a spec without
    /// `bin` infers the same name it would have from the original path.
    path: PathBuf,
    /// The copy the shell runs. The same file, except for PowerShell, which runs a file only
    /// when it ends in `.ps1`.
    run_path: PathBuf,
}

impl ScriptCopy {
    /// A copy of `script` when it is a pipe, FIFO or device rather than a regular file.
    fn for_stream(script: &std::path::Path, shell: &str) -> usage::miette::Result<Option<Self>> {
        // A path that does not exist is left alone so the parse reports it as before.
        match std::fs::metadata(script) {
            Ok(meta) if !meta.is_file() && !meta.is_dir() => {}
            _ => return Ok(None),
        }
        let content = std::fs::read(script).into_diagnostic()?;
        let dir = private_temp_dir()?;
        let name = script
            .file_name()
            .map_or_else(|| "script".into(), |n| n.to_os_string());
        let path = dir.join(&name);
        let run_path = if shell == "pwsh" {
            let mut ps1 = name;
            ps1.push(".ps1");
            dir.join(ps1)
        } else {
            path.clone()
        };
        // Owned by `copy` from here, so a failed write still removes the directory.
        let copy = Self {
            dir,
            path,
            run_path,
        };
        std::fs::write(&copy.path, &content).into_diagnostic()?;
        if copy.run_path != copy.path {
            std::fs::write(&copy.run_path, &content).into_diagnostic()?;
        }
        Ok(Some(copy))
    }
}

impl Drop for ScriptCopy {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

/// A new directory under the system temp dir that no other user can read.
fn private_temp_dir() -> usage::miette::Result<PathBuf> {
    #[cfg_attr(not(unix), allow(unused_mut))]
    let mut builder = std::fs::DirBuilder::new();
    #[cfg(unix)]
    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
    let base = std::env::temp_dir();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.subsec_nanos());
    // `create` fails rather than reuse a directory that is already there, so a name someone
    // else picked first is never written into.
    let mut last_err = None;
    for attempt in 0..16u32 {
        let dir = base.join(format!(
            "usage-script-{}-{nanos:x}-{attempt}",
            std::process::id()
        ));
        match builder.create(&dir) {
            Ok(()) => return Ok(dir),
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => last_err = Some(err),
            Err(err) => return Err(err).into_diagnostic(),
        }
    }
    Err(last_err.expect("the loop ran")).into_diagnostic()
}

/// Whether `path` is a path only Windows understands — a drive letter or a UNC share.
///
/// Hand-written rather than `Path::is_absolute`, whose answer depends on the platform it was
/// compiled for and so cannot be tested from the Linux CI. `/c/foo` (msys) and plain relative
/// paths are not included: both work through the WSL launcher, which translates the working
/// directory for them.
///
/// A drive letter counts even without a following separator. `C:x.sh` is drive-*relative*
/// rather than absolute, but it is still a spelling only Windows resolves — WSL looks for a
/// file named `C:x.sh` and reports 127 exactly as it does for `C:\x.sh`. Requiring the
/// separator would drop the hint for a case that needs it.
fn looks_like_windows_path(path: &str) -> bool {
    let bytes = path.as_bytes();
    let drive_letter = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
    drive_letter || path.starts_with(r"\\")
}

/// The guess to print when `bash` on Windows could not find a script it was handed.
///
/// Narrow on purpose. `bash` is the only one of the four shells that ships in the system
/// directory, which Windows searches ahead of `PATH`, so on a machine with WSL installed it is
/// the only one that silently resolves to something that cannot read a `C:\…` path. 127 is the
/// shell's own "command not found", which is what the WSL launcher passes back up.
fn wsl_path_hint(shell: &str, code: i32, script: &str) -> Option<String> {
    if shell != "bash" || code != 127 || !looks_like_windows_path(script) {
        return None;
    }
    Some(format!(
        "usage: `bash` exited 127 (command not found) and the script was given as a Windows path.\n\
         usage: On Windows the system directory is searched before $PATH, so `bash` resolves to\n\
         usage: C:\\Windows\\System32\\bash.exe — the WSL launcher — which cannot open `{script}`.\n\
         usage: If that is what happened, pass the script by relative path, or point usage at the\n\
         usage: bash you meant:\n\
         usage:     set {}=C:\\Program Files\\Git\\bin\\bash.exe\n\
         usage: If the script really did exit 127 on its own, ignore this.",
        env::shell_var_name(shell)
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn windows_paths_are_recognized() {
        for path in [r"C:\x", "C:/x", "c:/x", r"\\srv\share\x"] {
            assert!(looks_like_windows_path(path), "{path}");
        }
    }

    #[test]
    fn a_drive_relative_path_counts_too() {
        // Not absolute — `C:x.sh` means "x.sh on drive C's current directory" — but still a
        // spelling only Windows resolves. Measured: `usage bash C:x.sh` reaches WSL, which
        // looks for a file called `C:x.sh` and exits 127, so the hint belongs there.
        for path in ["C:x.sh", "c:x.sh"] {
            assert!(looks_like_windows_path(path), "{path}");
        }
    }

    #[test]
    fn paths_a_posix_shell_can_open_are_not_windows_paths() {
        for path in ["/c/x", "./x.sh", "x.sh", "/usr/local/bin/x", "", "C"] {
            assert!(!looks_like_windows_path(path), "{path}");
        }
    }

    #[test]
    fn the_hint_names_the_script_and_the_override() {
        let hint = wsl_path_hint("bash", 127, r"C:\Users\me\script.sh").unwrap();
        assert!(hint.contains(r"C:\Users\me\script.sh"), "{hint}");
        // The current spelling, since the hint is telling someone what to set. The legacy
        // `USAGE_SHELL_BASH` is still read, but pointing them at it would send them to the one
        // a mise task never receives.
        assert!(hint.contains("USAGECLI_SHELL_BASH"), "{hint}");
        // Hedged, because a script really can exit 127 on its own.
        assert!(hint.contains("ignore this"), "{hint}");
    }

    #[test]
    fn the_hint_stays_quiet_when_it_would_be_guessing() {
        // A script that failed for its own reasons.
        assert!(wsl_path_hint("bash", 1, r"C:\x").is_none());
        // Shells that do not exist in the system directory.
        assert!(wsl_path_hint("zsh", 127, r"C:\x").is_none());
        assert!(wsl_path_hint("pwsh", 127, r"C:\x").is_none());
        // A path the WSL launcher can resolve anyway.
        assert!(wsl_path_hint("bash", 127, "./x.sh").is_none());
    }
}