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 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 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 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}