precious_command/
lib.rs

1use anyhow::{Context, Result};
2use log::{
3    Level::Debug,
4    {debug, error, log_enabled},
5};
6use std::{collections::HashMap, env, fs, path::Path, process};
7use thiserror::Error;
8use which::which;
9
10#[cfg(target_family = "unix")]
11use std::os::unix::prelude::*;
12
13#[derive(Debug, Error)]
14pub enum CommandError {
15    #[error(r#"Could not find "{exe:}" in your path ({path:}"#)]
16    ExecutableNotInPath { exe: String, path: String },
17
18    #[error(
19        "Got unexpected exit code {code:} from `{cmd:}`.{}",
20        command_output_summary(stdout, stderr)
21    )]
22    UnexpectedExitCode {
23        cmd: String,
24        code: i32,
25        stdout: String,
26        stderr: String,
27    },
28
29    #[error("Ran `{cmd:}` and it was killed by signal {signal:}")]
30    ProcessKilledBySignal { cmd: String, signal: i32 },
31
32    #[error("Got unexpected stderr output from `{cmd:}`:\n{stderr:}")]
33    UnexpectedStderr { cmd: String, stderr: String },
34}
35
36fn command_output_summary(stdout: &str, stderr: &str) -> String {
37    let mut output = if stdout.is_empty() {
38        String::from("\nStdout was empty.")
39    } else {
40        format!("\nStdout:\n{stdout}")
41    };
42    if stderr.is_empty() {
43        output.push_str("\nStderr was empty.");
44    } else {
45        output.push_str("\nStderr:\n");
46        output.push_str(stderr);
47    };
48    output.push('\n');
49    output
50}
51
52#[derive(Debug)]
53pub struct CommandOutput {
54    pub exit_code: i32,
55    pub stdout: Option<String>,
56    pub stderr: Option<String>,
57}
58
59pub fn run_command(
60    cmd: &str,
61    args: &[&str],
62    env: &HashMap<String, String>,
63    ok_exit_codes: &[i32],
64    expect_stderr: bool,
65    in_dir: Option<&Path>,
66) -> Result<CommandOutput> {
67    if which(cmd).is_err() {
68        let path = match env::var("PATH") {
69            Ok(p) => p,
70            Err(e) => format!("<could not get PATH environment variable: {e}>"),
71        };
72        return Err(CommandError::ExecutableNotInPath {
73            exe: cmd.to_string(),
74            path,
75        }
76        .into());
77    }
78
79    let mut c = process::Command::new(cmd);
80    for a in args.iter() {
81        c.arg(a);
82    }
83
84    // We are canonicalizing this primarily for the benefit of our debugging
85    // output, because otherwise we might see the current dir as just `.`,
86    // which is not helpful.
87    let cwd = if let Some(d) = in_dir {
88        fs::canonicalize(d)?
89    } else {
90        fs::canonicalize(env::current_dir()?)?
91    };
92    c.current_dir(cwd.clone());
93
94    c.envs(env);
95
96    if log_enabled!(Debug) {
97        let cstr = command_string(cmd, args);
98        debug!("Running command [{}] with cwd = {}", cstr, cwd.display());
99    }
100
101    let output = output_from_command(c, ok_exit_codes, cmd, args).with_context(|| {
102        format!(
103            r#"Failed to execute command `{}`"#,
104            command_string(cmd, args)
105        )
106    })?;
107
108    if log_enabled!(Debug) && !output.stdout.is_empty() {
109        debug!("Stdout was:\n{}", String::from_utf8(output.stdout.clone())?);
110    }
111
112    if !output.stderr.is_empty() {
113        if log_enabled!(Debug) {
114            debug!("Stderr was:\n{}", String::from_utf8(output.stderr.clone())?);
115        }
116
117        if !expect_stderr {
118            return Err(CommandError::UnexpectedStderr {
119                cmd: command_string(cmd, args),
120                stderr: String::from_utf8(output.stderr)?,
121            }
122            .into());
123        }
124    }
125
126    let code = output.status.code().unwrap_or(-1);
127
128    Ok(CommandOutput {
129        exit_code: code,
130        stdout: to_option_string(output.stdout),
131        stderr: to_option_string(output.stderr),
132    })
133}
134
135fn output_from_command(
136    mut c: process::Command,
137    ok_exit_codes: &[i32],
138    cmd: &str,
139    args: &[&str],
140) -> Result<process::Output> {
141    let output = c.output()?;
142    match output.status.code() {
143        Some(code) => {
144            let cstr = command_string(cmd, args);
145            debug!("Ran {} and got exit code of {}", cstr, code);
146            if !ok_exit_codes.contains(&code) {
147                return Err(CommandError::UnexpectedExitCode {
148                    cmd: cstr,
149                    code,
150                    stdout: String::from_utf8(output.stdout)?,
151                    stderr: String::from_utf8(output.stderr)?,
152                }
153                .into());
154            }
155        }
156        None => {
157            let cstr = command_string(cmd, args);
158            if output.status.success() {
159                error!("Ran {} successfully but it had no exit code", cstr);
160            } else {
161                let signal = signal_from_status(output.status);
162                debug!("Ran {} which exited because of signal {}", cstr, signal);
163                return Err(CommandError::ProcessKilledBySignal { cmd: cstr, signal }.into());
164            }
165        }
166    }
167
168    Ok(output)
169}
170
171fn command_string(cmd: &str, args: &[&str]) -> String {
172    let mut cstr = cmd.to_string();
173    if !args.is_empty() {
174        cstr.push(' ');
175        cstr.push_str(args.join(" ").as_str());
176    }
177    cstr
178}
179
180fn to_option_string(v: Vec<u8>) -> Option<String> {
181    if v.is_empty() {
182        None
183    } else {
184        Some(String::from_utf8_lossy(&v).into_owned())
185    }
186}
187
188#[cfg(target_family = "unix")]
189fn signal_from_status(status: process::ExitStatus) -> i32 {
190    status.signal().unwrap_or(0)
191}
192
193#[cfg(target_family = "windows")]
194fn signal_from_status(_: process::ExitStatus) -> i32 {
195    0
196}
197
198#[cfg(test)]
199mod tests {
200    use super::CommandError;
201    use anyhow::{format_err, Result};
202    use pretty_assertions::assert_eq;
203    use std::{
204        collections::HashMap,
205        env, fs,
206        path::{Path, PathBuf},
207    };
208    use tempfile::tempdir;
209
210    #[test]
211    fn command_string() {
212        assert_eq!(
213            super::command_string("foo", &[]),
214            String::from("foo"),
215            "command without args",
216        );
217        assert_eq!(
218            super::command_string("foo", &["bar"],),
219            String::from("foo bar"),
220            "command with one arg"
221        );
222        assert_eq!(
223            super::command_string("foo", &["--bar", "baz"],),
224            String::from("foo --bar baz"),
225            "command with multiple args",
226        );
227    }
228
229    #[test]
230    fn run_command_exit_0() -> Result<()> {
231        let res = super::run_command("echo", &["foo"], &HashMap::new(), &[0], false, None)?;
232        assert_eq!(res.exit_code, 0, "command exits 0");
233
234        Ok(())
235    }
236
237    #[test]
238    fn run_command_wth_env() -> Result<()> {
239        let env_key = "PRECIOUS_ENV_TEST";
240        let mut env = HashMap::new();
241        env.insert(String::from(env_key), String::from("foo"));
242        let res = super::run_command(
243            "sh",
244            &["-c", &format!("echo ${env_key}")],
245            &env,
246            &[0],
247            false,
248            None,
249        )?;
250        assert_eq!(res.exit_code, 0, "command exits 0");
251        assert!(res.stdout.is_some(), "command has stdout output");
252        assert_eq!(
253            res.stdout.unwrap(),
254            String::from("foo\n"),
255            "{} env var was set when command was run",
256            env_key,
257        );
258        let val = env::var(env_key);
259        assert_eq!(
260            val.err().unwrap(),
261            std::env::VarError::NotPresent,
262            "{} env var is not set after command was run",
263            env_key,
264        );
265
266        Ok(())
267    }
268
269    #[test]
270    fn run_command_exit_32() -> Result<()> {
271        let res = super::run_command("sh", &["-c", "exit 32"], &HashMap::new(), &[0], false, None);
272        assert!(res.is_err(), "command exits non-zero");
273        match error_from_run_command(res)? {
274            CommandError::UnexpectedExitCode {
275                cmd: _,
276                code,
277                stdout,
278                stderr,
279            } => {
280                assert_eq!(code, 32, "command unexpectedly exits 32");
281                assert_eq!(stdout, "", "command had no stdout");
282                assert_eq!(stderr, "", "command had no stderr");
283            }
284            e => return Err(e.into()),
285        }
286
287        Ok(())
288    }
289
290    #[test]
291    fn run_command_exit_32_with_stdout() -> Result<()> {
292        let res = super::run_command(
293            "sh",
294            &["-c", r#"echo "STDOUT" && exit 32"#],
295            &HashMap::new(),
296            &[0],
297            false,
298            None,
299        );
300        assert!(res.is_err(), "command exits non-zero");
301        let e = error_from_run_command(res)?;
302        let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && exit 32`.
303Stdout:
304STDOUT
305
306Stderr was empty.
307"#;
308        assert_eq!(format!("{e}"), expect, "error display output");
309
310        match e {
311            CommandError::UnexpectedExitCode {
312                cmd: _,
313                code,
314                stdout,
315                stderr,
316            } => {
317                assert_eq!(code, 32, "command unexpectedly exits 32");
318                assert_eq!(stdout, "STDOUT\n", "stdout was captured");
319                assert_eq!(stderr, "", "stderr was empty");
320            }
321            e => return Err(e.into()),
322        }
323
324        Ok(())
325    }
326
327    #[test]
328    fn run_command_exit_32_with_stderr() -> Result<()> {
329        let res = super::run_command(
330            "sh",
331            &["-c", r#"echo "STDERR" 1>&2 && exit 32"#],
332            &HashMap::new(),
333            &[0],
334            false,
335            None,
336        );
337        assert!(res.is_err(), "command exits non-zero");
338        let e = error_from_run_command(res)?;
339        let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDERR" 1>&2 && exit 32`.
340Stdout was empty.
341Stderr:
342STDERR
343
344"#;
345        assert_eq!(format!("{e}"), expect, "error display output");
346
347        match e {
348            CommandError::UnexpectedExitCode {
349                cmd: _,
350                code,
351                stdout,
352                stderr,
353            } => {
354                assert_eq!(
355                    code, 32,
356                    "command unexpectedly
357            exits 32"
358                );
359                assert_eq!(stdout, "", "stdout was empty");
360                assert_eq!(stderr, "STDERR\n", "stderr was captured");
361            }
362            e => return Err(e.into()),
363        }
364
365        Ok(())
366    }
367
368    #[test]
369    fn run_command_exit_32_with_stdout_and_stderr() -> Result<()> {
370        let res = super::run_command(
371            "sh",
372            &["-c", r#"echo "STDOUT" && echo "STDERR" 1>&2 && exit 32"#],
373            &HashMap::new(),
374            &[0],
375            false,
376            None,
377        );
378        assert!(res.is_err(), "command exits non-zero");
379
380        let e = error_from_run_command(res)?;
381        let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && echo "STDERR" 1>&2 && exit 32`.
382Stdout:
383STDOUT
384
385Stderr:
386STDERR
387
388"#;
389        assert_eq!(format!("{e}"), expect, "error display output");
390        match e {
391            CommandError::UnexpectedExitCode {
392                cmd: _,
393                code,
394                stdout,
395                stderr,
396            } => {
397                assert_eq!(code, 32, "command unexpectedly exits 32");
398                assert_eq!(stdout, "STDOUT\n", "stdout was captured");
399                assert_eq!(stderr, "STDERR\n", "stderr was captured");
400            }
401            e => return Err(e.into()),
402        }
403
404        Ok(())
405    }
406
407    fn error_from_run_command(result: Result<super::CommandOutput>) -> Result<CommandError> {
408        match result {
409            Ok(_) => Err(format_err!("did not get an error in the returned Result")),
410            Err(e) => e.downcast::<super::CommandError>(),
411        }
412    }
413
414    #[test]
415    fn run_command_in_dir() -> Result<()> {
416        // On windows the path we get from `pwd` is a Windows path (C:\...)
417        // but `td.path()` contains a Unix path (/tmp/...). Very confusing.
418        if cfg!(windows) {
419            return Ok(());
420        }
421
422        let td = tempdir()?;
423        let td_path = maybe_canonicalize(td.path())?;
424
425        let res = super::run_command("pwd", &[], &HashMap::new(), &[0], false, Some(&td_path))?;
426        assert_eq!(res.exit_code, 0, "command exits 0");
427        assert!(res.stdout.is_some(), "command produced stdout output");
428
429        let stdout = res.stdout.unwrap();
430        let stdout_trimmed = stdout.trim_end();
431        assert_eq!(
432            stdout_trimmed,
433            td_path.to_string_lossy(),
434            "command runs in another dir",
435        );
436
437        Ok(())
438    }
439
440    #[test]
441    fn executable_does_not_exist() {
442        let exe = "I hope this binary does not exist on any system!";
443        let args = &["--arg", "42"];
444        let res = super::run_command(exe, args, &HashMap::new(), &[0], false, None);
445        assert!(res.is_err());
446        if let Err(e) = res {
447            assert!(e.to_string().contains(
448                r#"Could not find "I hope this binary does not exist on any system!" in your path"#,
449            ));
450        }
451    }
452
453    // The temp directory on macOS in GitHub Actions appears to be a symlink, but
454    // canonicalizing on Windows breaks tests for some reason.
455    pub fn maybe_canonicalize(path: &Path) -> Result<PathBuf> {
456        if cfg!(windows) {
457            return Ok(path.to_owned());
458        }
459        Ok(fs::canonicalize(path)?)
460    }
461}