1use anyhow::{Context, Result};
2use log::{
3 Level::Debug,
4 {debug, error, log_enabled},
5};
6use regex::Regex;
7use std::{collections::HashMap, env, fs, path::Path, process};
8use thiserror::Error;
9use which::which;
10
11#[cfg(target_family = "unix")]
12use std::os::unix::prelude::*;
13
14#[derive(Debug, Error)]
15pub enum ExecError {
16 #[error(r#"Could not find "{exe:}" in your path ({path:}"#)]
17 ExecutableNotInPath { exe: String, path: String },
18
19 #[error(
20 "Got unexpected exit code {code:} from `{cmd:}`.{}",
21 exec_output_summary(stdout, stderr)
22 )]
23 UnexpectedExitCode {
24 cmd: String,
25 code: i32,
26 stdout: String,
27 stderr: String,
28 },
29
30 #[error("Ran `{cmd:}` and it was killed by signal {signal:}")]
31 ProcessKilledBySignal { cmd: String, signal: i32 },
32
33 #[error("Got unexpected stderr output from `{cmd:}` with exit code {code:}:\n{stderr:}")]
34 UnexpectedStderr {
35 cmd: String,
36 code: i32,
37 stderr: String,
38 },
39}
40
41fn exec_output_summary(stdout: &str, stderr: &str) -> String {
42 let mut output = if stdout.is_empty() {
43 String::from("\nStdout was empty.")
44 } else {
45 format!("\nStdout:\n{stdout}")
46 };
47 if stderr.is_empty() {
48 output.push_str("\nStderr was empty.");
49 } else {
50 output.push_str("\nStderr:\n");
51 output.push_str(stderr);
52 };
53 output.push('\n');
54 output
55}
56
57#[derive(Debug)]
58pub struct ExecOutput {
59 pub exit_code: i32,
60 pub stdout: Option<String>,
61 pub stderr: Option<String>,
62}
63
64pub fn run(
65 exe: &str,
66 args: &[&str],
67 env: &HashMap<String, String>,
68 ok_exit_codes: &[i32],
69 ignore_stderr: Option<&[Regex]>,
70 in_dir: Option<&Path>,
71) -> Result<ExecOutput> {
72 if which(exe).is_err() {
73 let path = match env::var("PATH") {
74 Ok(p) => p,
75 Err(e) => format!("<could not get PATH environment variable: {e}>"),
76 };
77 return Err(ExecError::ExecutableNotInPath {
78 exe: exe.to_string(),
79 path,
80 }
81 .into());
82 }
83
84 let mut c = process::Command::new(exe);
85 for a in args.iter() {
86 c.arg(a);
87 }
88
89 let cwd = if let Some(d) = in_dir {
93 fs::canonicalize(d)?
94 } else {
95 fs::canonicalize(env::current_dir()?)?
96 };
97 c.current_dir(cwd.clone());
98
99 c.envs(env);
100
101 if log_enabled!(Debug) {
102 debug!(
103 "Running command [{}] with cwd = {}",
104 exec_string(exe, args),
105 cwd.display()
106 );
107 }
108
109 let output = output_from_command(c, ok_exit_codes, exe, args)
110 .with_context(|| format!(r#"Failed to execute command `{}`"#, exec_string(exe, args)))?;
111
112 if log_enabled!(Debug) && !output.stdout.is_empty() {
113 debug!("Stdout was:\n{}", String::from_utf8(output.stdout.clone())?);
114 }
115
116 let code = output.status.code().unwrap_or(-1);
117 if !output.stderr.is_empty() {
118 let stderr = String::from_utf8(output.stderr.clone())?;
119 if log_enabled!(Debug) {
120 debug!("Stderr was:\n{stderr}");
121 }
122
123 let ok = if let Some(ignore) = ignore_stderr {
124 ignore.iter().any(|i| i.is_match(&stderr))
125 } else {
126 false
127 };
128 if !ok {
129 return Err(ExecError::UnexpectedStderr {
130 cmd: exec_string(exe, args),
131 code,
132 stderr,
133 }
134 .into());
135 }
136 }
137
138 Ok(ExecOutput {
139 exit_code: code,
140 stdout: to_option_string(output.stdout),
141 stderr: to_option_string(output.stderr),
142 })
143}
144
145fn output_from_command(
146 mut c: process::Command,
147 ok_exit_codes: &[i32],
148 exe: &str,
149 args: &[&str],
150) -> Result<process::Output> {
151 let output = c.output()?;
152 match output.status.code() {
153 Some(code) => {
154 let estr = exec_string(exe, args);
155 debug!("Ran {} and got exit code of {}", estr, code);
156 if !ok_exit_codes.contains(&code) {
157 return Err(ExecError::UnexpectedExitCode {
158 cmd: estr,
159 code,
160 stdout: String::from_utf8(output.stdout)?,
161 stderr: String::from_utf8(output.stderr)?,
162 }
163 .into());
164 }
165 }
166 None => {
167 let estr = exec_string(exe, args);
168 if output.status.success() {
169 error!("Ran {} successfully but it had no exit code", estr);
170 } else {
171 let signal = signal_from_status(output.status);
172 debug!("Ran {} which exited because of signal {}", estr, signal);
173 return Err(ExecError::ProcessKilledBySignal { cmd: estr, signal }.into());
174 }
175 }
176 }
177
178 Ok(output)
179}
180
181fn exec_string(exe: &str, args: &[&str]) -> String {
182 let mut estr = exe.to_string();
183 if !args.is_empty() {
184 estr.push(' ');
185 estr.push_str(args.join(" ").as_str());
186 }
187 estr
188}
189
190fn to_option_string(v: Vec<u8>) -> Option<String> {
191 if v.is_empty() {
192 None
193 } else {
194 Some(String::from_utf8_lossy(&v).into_owned())
195 }
196}
197
198#[cfg(target_family = "unix")]
199fn signal_from_status(status: process::ExitStatus) -> i32 {
200 status.signal().unwrap_or(0)
201}
202
203#[cfg(target_family = "windows")]
204fn signal_from_status(_: process::ExitStatus) -> i32 {
205 0
206}
207
208#[cfg(test)]
209mod tests {
210 use super::ExecError;
211 use anyhow::{format_err, Result};
212 use pretty_assertions::assert_eq;
213 use regex::Regex;
214 use std::{
215 collections::HashMap,
216 env, fs,
217 path::{Path, PathBuf},
218 };
219 use tempfile::tempdir;
220
221 #[test]
222 fn exec_string() {
223 assert_eq!(
224 super::exec_string("foo", &[]),
225 String::from("foo"),
226 "command without args",
227 );
228 assert_eq!(
229 super::exec_string("foo", &["bar"],),
230 String::from("foo bar"),
231 "command with one arg"
232 );
233 assert_eq!(
234 super::exec_string("foo", &["--bar", "baz"],),
235 String::from("foo --bar baz"),
236 "command with multiple args",
237 );
238 }
239
240 #[test]
241 fn run_exit_0() -> Result<()> {
242 let res = super::run("echo", &["foo"], &HashMap::new(), &[0], None, None)?;
243 assert_eq!(res.exit_code, 0, "process exits 0");
244
245 Ok(())
246 }
247
248 #[test]
249 fn run_exit_0_with_unexpected_stderr() -> Result<()> {
250 let args = &["-c", "echo 'some stderr output' 1>&2"];
251 let res = super::run("sh", args, &HashMap::new(), &[0], None, None);
252 assert!(res.is_err(), "run returned Err");
253 match error_from_run(res)? {
254 ExecError::UnexpectedStderr {
255 cmd: _,
256 code,
257 stderr,
258 } => {
259 assert_eq!(code, 0, "process exited 0");
260 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
261 }
262 e => return Err(e.into()),
263 }
264 Ok(())
265 }
266
267 #[test]
268 fn run_exit_0_with_matching_ignore_stderr() -> Result<()> {
269 let args = &["-c", "echo 'some stderr output' 1>&2"];
270 let res = super::run(
271 "sh",
272 args,
273 &HashMap::new(),
274 &[0],
275 Some(&[Regex::new("some.+output").unwrap()]),
276 None,
277 )?;
278 assert_eq!(res.exit_code, 0, "process exits 0");
279 assert!(res.stdout.is_none(), "process has no stdout output");
280 assert_eq!(
281 res.stderr.unwrap(),
282 "some stderr output\n",
283 "process has stderr output",
284 );
285 Ok(())
286 }
287
288 #[test]
289 fn run_exit_0_with_non_matching_ignore_stderr() -> Result<()> {
290 let args = &["-c", "echo 'some stderr output' 1>&2"];
291 let res = super::run(
292 "sh",
293 args,
294 &HashMap::new(),
295 &[0],
296 Some(&[Regex::new("some.+output is ok").unwrap()]),
297 None,
298 );
299 assert!(res.is_err(), "run returned Err");
300 match error_from_run(res)? {
301 ExecError::UnexpectedStderr {
302 cmd: _,
303 code,
304 stderr,
305 } => {
306 assert_eq!(code, 0, "process exited 0");
307 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
308 }
309 e => return Err(e.into()),
310 }
311 Ok(())
312 }
313
314 #[test]
315 fn run_exit_0_with_multiple_ignore_stderr() -> Result<()> {
316 let args = &["-c", "echo 'some stderr output' 1>&2"];
317 let res = super::run(
318 "sh",
319 args,
320 &HashMap::new(),
321 &[0],
322 Some(&[
323 Regex::new("will not match").unwrap(),
324 Regex::new("some.+output is ok").unwrap(),
325 ]),
326 None,
327 );
328 assert!(res.is_err(), "run returned Err");
329 match error_from_run(res)? {
330 ExecError::UnexpectedStderr {
331 cmd: _,
332 code,
333 stderr,
334 } => {
335 assert_eq!(code, 0, "process exited 0");
336 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
337 }
338 e => return Err(e.into()),
339 }
340 Ok(())
341 }
342
343 #[test]
344 fn run_wth_env() -> Result<()> {
345 let env_key = "PRECIOUS_ENV_TEST";
346 let mut env = HashMap::new();
347 env.insert(String::from(env_key), String::from("foo"));
348 let res = super::run(
349 "sh",
350 &["-c", &format!("echo ${env_key}")],
351 &env,
352 &[0],
353 None,
354 None,
355 )?;
356 assert_eq!(res.exit_code, 0, "process exits 0");
357 assert!(res.stdout.is_some(), "process has stdout output");
358 assert_eq!(
359 res.stdout.unwrap(),
360 String::from("foo\n"),
361 "{} env var was set when process was run",
362 env_key,
363 );
364 let val = env::var(env_key);
365 assert_eq!(
366 val.err().unwrap(),
367 std::env::VarError::NotPresent,
368 "{} env var is not set after process was run",
369 env_key,
370 );
371
372 Ok(())
373 }
374
375 #[test]
376 fn run_exit_32() -> Result<()> {
377 let res = super::run("sh", &["-c", "exit 32"], &HashMap::new(), &[0], None, None);
378 assert!(res.is_err(), "process exits non-zero");
379 match error_from_run(res)? {
380 ExecError::UnexpectedExitCode {
381 cmd: _,
382 code,
383 stdout,
384 stderr,
385 } => {
386 assert_eq!(code, 32, "process unexpectedly exits 32");
387 assert_eq!(stdout, "", "process had no stdout");
388 assert_eq!(stderr, "", "process had no stderr");
389 }
390 e => return Err(e.into()),
391 }
392
393 Ok(())
394 }
395
396 #[test]
397 fn run_exit_32_with_stdout() -> Result<()> {
398 let res = super::run(
399 "sh",
400 &["-c", r#"echo "STDOUT" && exit 32"#],
401 &HashMap::new(),
402 &[0],
403 None,
404 None,
405 );
406 assert!(res.is_err(), "process exits non-zero");
407 let e = error_from_run(res)?;
408 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && exit 32`.
409Stdout:
410STDOUT
411
412Stderr was empty.
413"#;
414 assert_eq!(format!("{e}"), expect, "error display output");
415
416 match e {
417 ExecError::UnexpectedExitCode {
418 cmd: _,
419 code,
420 stdout,
421 stderr,
422 } => {
423 assert_eq!(code, 32, "process unexpectedly exits 32");
424 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
425 assert_eq!(stderr, "", "stderr was empty");
426 }
427 e => return Err(e.into()),
428 }
429
430 Ok(())
431 }
432
433 #[test]
434 fn run_exit_32_with_stderr() -> Result<()> {
435 let res = super::run(
436 "sh",
437 &["-c", r#"echo "STDERR" 1>&2 && exit 32"#],
438 &HashMap::new(),
439 &[0],
440 None,
441 None,
442 );
443 assert!(res.is_err(), "process exits non-zero");
444 let e = error_from_run(res)?;
445 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDERR" 1>&2 && exit 32`.
446Stdout was empty.
447Stderr:
448STDERR
449
450"#;
451 assert_eq!(format!("{e}"), expect, "error display output");
452
453 match e {
454 ExecError::UnexpectedExitCode {
455 cmd: _,
456 code,
457 stdout,
458 stderr,
459 } => {
460 assert_eq!(
461 code, 32,
462 "process unexpectedly
463 exits 32"
464 );
465 assert_eq!(stdout, "", "stdout was empty");
466 assert_eq!(stderr, "STDERR\n", "stderr was captured");
467 }
468 e => return Err(e.into()),
469 }
470
471 Ok(())
472 }
473
474 #[test]
475 fn run_exit_32_with_stdout_and_stderr() -> Result<()> {
476 let res = super::run(
477 "sh",
478 &["-c", r#"echo "STDOUT" && echo "STDERR" 1>&2 && exit 32"#],
479 &HashMap::new(),
480 &[0],
481 None,
482 None,
483 );
484 assert!(res.is_err(), "process exits non-zero");
485
486 let e = error_from_run(res)?;
487 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && echo "STDERR" 1>&2 && exit 32`.
488Stdout:
489STDOUT
490
491Stderr:
492STDERR
493
494"#;
495 assert_eq!(format!("{e}"), expect, "error display output");
496 match e {
497 ExecError::UnexpectedExitCode {
498 cmd: _,
499 code,
500 stdout,
501 stderr,
502 } => {
503 assert_eq!(code, 32, "process unexpectedly exits 32");
504 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
505 assert_eq!(stderr, "STDERR\n", "stderr was captured");
506 }
507 e => return Err(e.into()),
508 }
509
510 Ok(())
511 }
512
513 fn error_from_run(result: Result<super::ExecOutput>) -> Result<ExecError> {
514 match result {
515 Ok(_) => Err(format_err!("did not get an error in the returned Result")),
516 Err(e) => e.downcast::<super::ExecError>(),
517 }
518 }
519
520 #[test]
521 fn run_in_dir() -> Result<()> {
522 if cfg!(windows) {
525 return Ok(());
526 }
527
528 let td = tempdir()?;
529 let td_path = maybe_canonicalize(td.path())?;
530
531 let res = super::run("pwd", &[], &HashMap::new(), &[0], None, Some(&td_path))?;
532 assert_eq!(res.exit_code, 0, "process exits 0");
533 assert!(res.stdout.is_some(), "process produced stdout output");
534
535 let stdout = res.stdout.unwrap();
536 let stdout_trimmed = stdout.trim_end();
537 assert_eq!(
538 stdout_trimmed,
539 td_path.to_string_lossy(),
540 "process runs in another dir",
541 );
542
543 Ok(())
544 }
545
546 #[test]
547 fn executable_does_not_exist() {
548 let exe = "I hope this binary does not exist on any system!";
549 let args = &["--arg", "42"];
550 let res = super::run(exe, args, &HashMap::new(), &[0], None, None);
551 assert!(res.is_err());
552 if let Err(e) = res {
553 assert!(e.to_string().contains(
554 r#"Could not find "I hope this binary does not exist on any system!" in your path"#,
555 ));
556 }
557 }
558
559 pub fn maybe_canonicalize(path: &Path) -> Result<PathBuf> {
562 if cfg!(windows) {
563 return Ok(path.to_owned());
564 }
565 Ok(fs::canonicalize(path)?)
566 }
567}