xtask_lib/tasks/util/cmd.rs
1//! Command execution helpers.
2//!
3//! All tasks use [`run`] to execute external processes. It prints the command
4//! before running it (for CI log visibility) and converts non-zero exit codes
5//! into descriptive [`anyhow::Error`] values so callers can use `?`.
6
7use std::{
8 ffi::OsStr,
9 path::Path,
10 process::{Command, ExitStatus},
11};
12
13use anyhow::{Context, Result, bail};
14
15/// Runs an external command, streaming its output to the terminal.
16///
17/// The command is printed before execution so CI logs show exactly what ran.
18/// A non-zero exit status is converted to an error.
19///
20/// # Arguments
21///
22/// * `program` – The executable to run (looked up via `PATH`).
23/// * `args` – Arguments passed to the program.
24/// * `dir` – Working directory for the process.
25/// * `env` – Additional environment variables (key-value pairs).
26///
27/// # Errors
28///
29/// Returns an error if the process cannot be spawned or exits with a non-zero
30/// status code.
31///
32/// # Examples
33///
34/// ```no_run
35/// use std::path::Path;
36/// use xtask_lib::tasks::util::cmd::run;
37///
38/// // Run `cargo fmt --check` in the workspace root.
39/// run("cargo", &["fmt", "--check"], Path::new("."), &[])?;
40/// # Ok::<(), anyhow::Error>(())
41/// ```
42pub fn run(program: &str, args: &[&str], dir: &Path, env: &[(&str, &str)]) -> Result<()> {
43 let status = build_command(program, args, dir, env)
44 .status()
45 .with_context(|| format!("failed to spawn `{program}`"))?;
46
47 check_status(program, status)
48}
49
50/// Runs an external command and captures its stdout as a `String`.
51///
52/// Stderr is inherited (printed to the terminal). The returned string has
53/// leading/trailing whitespace trimmed.
54///
55/// # Errors
56///
57/// Returns an error if the process cannot be spawned, exits with a non-zero
58/// status, or produces non-UTF-8 output.
59///
60/// # Examples
61///
62/// ```no_run
63/// use std::path::Path;
64/// use xtask_lib::tasks::util::cmd::output;
65///
66/// let version = output("cargo", &["--version"], Path::new("."), &[])?;
67/// assert!(version.starts_with("cargo "));
68/// # Ok::<(), anyhow::Error>(())
69/// ```
70pub fn output(program: &str, args: &[&str], dir: &Path, env: &[(&str, &str)]) -> Result<String> {
71 let out = build_command(program, args, dir, env)
72 .output()
73 .with_context(|| format!("failed to spawn `{program}`"))?;
74
75 check_status(program, out.status)?;
76
77 let stdout = String::from_utf8(out.stdout)
78 .with_context(|| format!("`{program}` produced non-UTF-8 output"))?;
79
80 Ok(stdout.trim().to_owned())
81}
82
83fn build_command(program: &str, args: &[&str], dir: &Path, env: &[(&str, &str)]) -> Command {
84 let display_args = args.join(" ");
85 println!("$ {program} {display_args}");
86
87 let mut cmd = Command::new(program);
88 cmd.args(args).current_dir(dir);
89
90 for (key, value) in env {
91 cmd.env(OsStr::new(key), OsStr::new(value));
92 }
93
94 cmd
95}
96
97fn check_status(program: &str, status: ExitStatus) -> Result<()> {
98 if status.success() {
99 return Ok(());
100 }
101
102 match status.code() {
103 Some(code) => bail!("`{program}` exited with status {code}"),
104 None => bail!("`{program}` was terminated by a signal"),
105 }
106}