1#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
2use crate::error::Error;
3use anyhow::{Context, Result};
4use bon::bon;
5use itertools::Itertools;
6use log::{
7 Level::{Debug, Info},
8 {debug, error, info, log_enabled, warn},
9};
10use regex::Regex;
11use std::{
12 collections::HashMap,
13 env, fs,
14 path::Path,
15 process::{self, Command},
16 sync::mpsc::{self, RecvTimeoutError},
17 thread::{self, JoinHandle},
18 time::Duration,
19};
20use which::which;
21
22#[cfg(target_family = "unix")]
23use std::os::unix::prelude::*;
24
25enum ThreadMessage {
26 Terminate,
27}
28
29#[derive(Debug)]
30pub struct Exec<'a> {
31 exe: &'a str,
32 args: Vec<&'a str>,
33 num_paths: usize,
34 env: HashMap<String, String>,
35 ok_exit_codes: &'a [i32],
36 ignore_stderr: Vec<Regex>,
37 in_dir: Option<&'a Path>,
38 pub loggable_command: String,
39}
40
41#[derive(Debug)]
42pub struct Output {
43 pub exit_code: i32,
44 pub stdout: Option<String>,
45 pub stderr: Option<String>,
46}
47
48#[bon]
49impl<'a> Exec<'a> {
50 #[builder]
51 pub fn new(
52 exe: &'a str,
53 #[builder(default)] args: Vec<&'a str>,
54 #[builder(default)] num_paths: usize,
55 #[builder(default)] env: HashMap<String, String>,
56 ok_exit_codes: &'a [i32],
57 #[builder(default)] ignore_stderr: Vec<Regex>,
58 in_dir: Option<&'a Path>,
59 ) -> Self {
60 let mut s = Self {
61 exe,
62 args,
63 num_paths,
64 env,
65 ok_exit_codes,
66 ignore_stderr,
67 in_dir,
68 loggable_command: String::new(),
69 };
70 s.loggable_command = s.make_loggable_command();
73
74 s
75 }
76
77 #[must_use]
78 pub fn make_loggable_command(&self) -> String {
79 let mut cmd = vec![self.exe];
80
81 let mut args = self.args.iter();
82
83 if self.num_paths == 0 || self.args.len() <= 3 {
86 cmd.extend(args);
87 return cmd.join(" ");
88 }
89
90 let num_non_paths = self.args.len() - self.num_paths;
91
92 cmd.extend(args.by_ref().take(num_non_paths));
95
96 if args.len() <= 3 {
98 cmd.extend(args);
99 return cmd.join(" ");
100 }
101
102 cmd.extend(args.by_ref().take(2));
106
107 let and_more = format!("... and {} more paths", args.len());
108 cmd.push(&and_more);
109
110 cmd.join(" ")
111 }
112
113 pub fn run(self) -> Result<Output> {
114 if which(self.exe).is_err() {
115 let path = match env::var("PATH") {
116 Ok(p) => p,
117 Err(e) => format!("<could not get PATH environment variable: {e}>"),
118 };
119 return Err(Error::ExecutableNotInPath {
120 exe: self.exe.to_string(),
121 path,
122 }
123 .into());
124 }
125
126 let cmd = self
127 .as_command()
128 .with_context(|| format!("Failed to prepare command '{}'", self.exe))?;
129
130 if log_enabled!(Debug) {
131 debug!(
132 "Running command [{}] with cwd = {}",
133 self.loggable_command,
134 cmd.get_current_dir()
135 .expect("we just set the current_dir in as_command so this should be Some")
136 .display(),
137 );
138 for kv in self.env.iter().sorted_by(|a, b| a.0.cmp(b.0)) {
139 debug!(r#" with env: {} = "{}""#, kv.0, kv.1);
140 }
141 }
142
143 let output = self
144 .output_from_command(cmd)
145 .with_context(|| format!(r"Failed to execute command `{}`", self.full_command()))?;
146
147 if log_enabled!(Debug) && !output.stdout.is_empty() {
148 let stdout = String::from_utf8(output.stdout.clone())
149 .context("Failed to decode stdout as UTF-8")?;
150 debug!("Stdout was:\n{stdout}");
151 }
152
153 let code = output.status.code().unwrap_or(-1);
154 if !output.stderr.is_empty() {
155 let stderr = String::from_utf8(output.stderr.clone())
156 .context("Failed to decode stderr as UTF-8")?;
157 if log_enabled!(Debug) {
158 debug!("Stderr was:\n{stderr}");
159 }
160
161 if !self.ignore_stderr.iter().any(|i| i.is_match(&stderr)) {
162 return Err(Error::UnexpectedStderr {
163 cmd: self.full_command(),
164 code,
165 stdout: String::from_utf8(output.stdout)
166 .unwrap_or("<could not turn stdout into a UTF-8 string>".to_string()),
167 stderr,
168 }
169 .into());
170 }
171 }
172
173 Ok(Output {
174 exit_code: code,
175 stdout: bytes_to_option_string(&output.stdout),
176 stderr: bytes_to_option_string(&output.stderr),
177 })
178 }
179
180 fn output_from_command(&self, mut c: process::Command) -> Result<process::Output> {
181 let status = self.maybe_spawn_status_thread();
182
183 let output = c.output().with_context(|| {
184 format!(
185 "Failed to get output from command `{}`",
186 self.full_command()
187 )
188 })?;
189 if let Some((sender, thread)) = status {
190 if let Err(err) = sender.send(ThreadMessage::Terminate) {
191 warn!("Error terminating background status thread: {err}");
192 }
193 if let Err(err) = thread.join() {
194 warn!("Error joining background status thread: {err:?}");
195 }
196 }
197
198 self.handle_output(output)
199 }
200
201 fn handle_output(&self, output: process::Output) -> Result<process::Output> {
202 if let Some(code) = output.status.code() {
203 debug!(
204 "Ran [{}] and got exit code of {}",
205 self.loggable_command, code
206 );
207 return if self.ok_exit_codes.contains(&code) {
208 Ok(output)
209 } else {
210 let stdout = String::from_utf8(output.stdout)
211 .context("Failed to decode command stdout as UTF-8")?;
212 let stderr = String::from_utf8(output.stderr)
213 .context("Failed to decode command stderr as UTF-8")?;
214 Err(Error::UnexpectedExitCode {
215 cmd: self.full_command(),
216 code,
217 stdout,
218 stderr,
219 }
220 .into())
221 };
222 }
223
224 if output.status.success() {
225 error!(
229 "The {} command was successful but it had no exit code",
230 self.loggable_command,
231 );
232 return Ok(output);
233 }
234
235 let signal = signal_from_status(output.status);
236 debug!(
237 "Ran {} which exited because of signal {}",
238 self.full_command(),
239 signal
240 );
241 let stdout = String::from_utf8(output.stdout)
242 .context("Failed to decode command stdout as UTF-8 for signal error")?;
243 let stderr = String::from_utf8(output.stderr)
244 .context("Failed to decode command stderr as UTF-8 for signal error")?;
245 Err(Error::ProcessKilledBySignal {
246 cmd: self.full_command(),
247 signal,
248 stdout,
249 stderr,
250 }
251 .into())
252 }
253
254 fn maybe_spawn_status_thread(&self) -> Option<(mpsc::Sender<ThreadMessage>, JoinHandle<()>)> {
255 if !log_enabled!(Info) {
256 return None;
257 }
258
259 let loggable_command = self.loggable_command.clone();
260 let (sender, receiver) = mpsc::channel();
261
262 let handle = thread::spawn(move || loop {
263 match receiver.recv_timeout(Duration::from_secs(5)) {
264 Ok(ThreadMessage::Terminate) => {
265 break;
266 }
267 Err(RecvTimeoutError::Timeout) => {
268 info!("Still running [{loggable_command}]");
269 }
270 Err(RecvTimeoutError::Disconnected) => {
271 warn!("Got a disconnected error receiving message from main thread");
272 break;
273 }
274 }
275 });
276
277 Some((sender, handle))
278 }
279
280 pub fn as_command(&self) -> Result<Command> {
281 let mut cmd = Command::new(self.exe);
282 cmd.args(&self.args);
283
284 let in_dir = if let Some(d) = &self.in_dir {
285 d.to_path_buf()
286 } else {
287 env::current_dir()?
288 };
289
290 let in_dir = fs::canonicalize(in_dir)?;
291 debug!("Setting current dir to {}", in_dir.display());
292
293 cmd.current_dir(in_dir);
296
297 cmd.envs(&self.env);
298
299 Ok(cmd)
300 }
301
302 #[must_use]
303 pub fn full_command(&self) -> String {
304 let mut cmd = vec![self.exe];
305 cmd.extend(&self.args);
306 cmd.join(" ")
307 }
308}
309
310fn bytes_to_option_string(v: &[u8]) -> Option<String> {
311 if v.is_empty() {
312 None
313 } else {
314 Some(String::from_utf8_lossy(v).into_owned())
315 }
316}
317
318#[cfg(target_family = "unix")]
319fn signal_from_status(status: process::ExitStatus) -> i32 {
320 status.signal().unwrap_or(0)
321}
322
323#[cfg(target_family = "windows")]
324fn signal_from_status(_: process::ExitStatus) -> i32 {
325 0
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use anyhow::{format_err, Result};
332 use pretty_assertions::assert_eq;
333 use regex::Regex;
334 use serial_test::{parallel, serial};
336 use std::{
337 collections::HashMap,
338 env, fs,
339 path::{Path, PathBuf},
340 };
341 use tempfile::tempdir;
342 use test_case::test_case;
343 use which::which;
344
345 #[test]
346 #[parallel]
347 fn run_exit_0() -> Result<()> {
348 if !which("echo").is_ok() {
349 return Ok(());
350 }
351 let res = Exec::builder()
352 .exe("echo")
353 .args(vec!["foo"])
354 .ok_exit_codes(&[0])
355 .build()
356 .run()?;
357 assert_eq!(res.exit_code, 0, "process exits 0");
358
359 Ok(())
360 }
361
362 const BASH_ECHO_TO_STDERR_SCRIPT: &str = "echo 'some stderr output' 1>&2";
364
365 #[test]
366 #[parallel]
367 fn run_exit_0_with_unexpected_stderr() -> Result<()> {
368 if which("bash").is_err() {
369 println!("Skipping test since bash is not in path");
370 return Ok(());
371 }
372
373 let res = Exec::builder()
374 .exe("bash")
375 .args(vec!["-c", BASH_ECHO_TO_STDERR_SCRIPT])
376 .ok_exit_codes(&[0])
377 .build()
378 .run();
379 assert!(res.is_err(), "run returned Err");
380 match error_from_run(res)? {
381 Error::UnexpectedStderr {
382 cmd: _,
383 code,
384 stdout,
385 stderr,
386 } => {
387 assert_eq!(code, 0, "process exited 0");
388 assert_eq!(stdout, "", "process had no stdout output");
389 assert_eq!(
390 stderr, "some stderr output\n",
391 "process had expected stderr output"
392 );
393 }
394 e => return Err(e.into()),
395 }
396 Ok(())
397 }
398
399 #[test]
400 #[parallel]
401 fn run_exit_0_with_matching_ignore_stderr() -> Result<()> {
402 if which("bash").is_err() {
403 println!("Skipping test since bash is not in path");
404 return Ok(());
405 }
406
407 let regex = Regex::new("some.+output").unwrap();
408 let res = Exec::builder()
409 .exe("bash")
410 .args(vec!["-c", BASH_ECHO_TO_STDERR_SCRIPT])
411 .ok_exit_codes(&[0])
412 .ignore_stderr(vec![regex])
413 .build()
414 .run()?;
415 assert_eq!(res.exit_code, 0, "process exits 0");
416 assert!(res.stdout.is_none(), "process has no stdout output");
417 assert_eq!(
418 res.stderr.unwrap(),
419 "some stderr output\n",
420 "process has stderr output",
421 );
422 Ok(())
423 }
424
425 #[test]
426 #[parallel]
427 fn run_exit_0_with_non_matching_ignore_stderr() -> Result<()> {
428 if which("bash").is_err() {
429 println!("Skipping test since bash is not in path");
430 return Ok(());
431 }
432
433 let regex = Regex::new("some.+output is ok").unwrap();
434 let res = Exec::builder()
435 .exe("bash")
436 .args(vec!["-c", BASH_ECHO_TO_STDERR_SCRIPT])
437 .ok_exit_codes(&[0])
438 .ignore_stderr(vec![regex])
439 .build()
440 .run();
441 assert!(res.is_err(), "run returned Err");
442 match error_from_run(res)? {
443 Error::UnexpectedStderr {
444 cmd: _,
445 code,
446 stdout,
447 stderr,
448 } => {
449 assert_eq!(code, 0, "process exited 0");
450 assert_eq!(stdout, "", "process had no stdout output");
451 assert_eq!(
452 stderr, "some stderr output\n",
453 "process had expected stderr output"
454 );
455 }
456 e => return Err(e.into()),
457 }
458 Ok(())
459 }
460
461 #[test]
462 #[parallel]
463 fn run_exit_0_with_multiple_ignore_stderr() -> Result<()> {
464 if which("bash").is_err() {
465 println!("Skipping test since bash is not in path");
466 return Ok(());
467 }
468
469 let regex1 = Regex::new("will not match").unwrap();
470 let regex2 = Regex::new("some.+output is ok").unwrap();
471 let res = Exec::builder()
472 .exe("bash")
473 .args(vec!["-c", BASH_ECHO_TO_STDERR_SCRIPT])
474 .ok_exit_codes(&[0])
475 .ignore_stderr(vec![regex1, regex2])
476 .build()
477 .run();
478 assert!(res.is_err(), "run returned Err");
479 match error_from_run(res)? {
480 Error::UnexpectedStderr {
481 cmd: _,
482 code,
483 stdout,
484 stderr,
485 } => {
486 assert_eq!(code, 0, "process exited 0");
487 assert_eq!(stdout, "", "process had no stdout output");
488 assert_eq!(
489 stderr, "some stderr output\n",
490 "process had expected stderr output"
491 );
492 }
493 e => return Err(e.into()),
494 }
495 Ok(())
496 }
497
498 #[test]
499 #[parallel]
500 fn run_with_env() -> Result<()> {
501 if which("bash").is_err() {
502 println!("Skipping test since bash is not in path");
503 return Ok(());
504 }
505
506 let env_key = "PRECIOUS_ENV_TEST";
507 let mut env = HashMap::new();
508 env.insert(String::from(env_key), String::from("foo"));
509
510 let res = Exec::builder()
511 .exe("bash")
512 .args(vec!["-c", &format!("echo ${env_key}")])
513 .ok_exit_codes(&[0])
514 .env(env)
515 .build()
516 .run()?;
517 assert_eq!(res.exit_code, 0, "process exits 0");
518 assert!(res.stdout.is_some(), "process has stdout output");
519 assert_eq!(
520 res.stdout.unwrap(),
521 String::from("foo\n"),
522 "{} env var was set when process was run",
523 env_key,
524 );
525 let val = env::var(env_key);
526 assert_eq!(
527 val.err().unwrap(),
528 std::env::VarError::NotPresent,
529 "{} env var is not set after process was run",
530 env_key,
531 );
532
533 Ok(())
534 }
535
536 #[test]
537 #[parallel]
538 fn run_exit_32() -> Result<()> {
539 if which("bash").is_err() {
540 println!("Skipping test since bash is not in path");
541 return Ok(());
542 }
543
544 let res = Exec::builder()
545 .exe("bash")
546 .args(vec!["-c", "exit 32"])
547 .ok_exit_codes(&[0])
548 .build()
549 .run();
550 assert!(res.is_err(), "process exits non-zero");
551 match error_from_run(res)? {
552 Error::UnexpectedExitCode {
553 cmd: _,
554 code,
555 stdout,
556 stderr,
557 } => {
558 assert_eq!(code, 32, "process unexpectedly exits 32");
559 assert_eq!(stdout, "", "process had no stdout");
560 assert_eq!(stderr, "", "process had no stderr");
561 }
562 e => return Err(e.into()),
563 }
564
565 Ok(())
566 }
567
568 #[test]
569 #[parallel]
570 fn run_exit_32_with_stdout() -> Result<()> {
571 if which("bash").is_err() {
572 println!("Skipping test since bash is not in path");
573 return Ok(());
574 }
575
576 let res = Exec::builder()
577 .exe("bash")
578 .args(vec!["-c", r#"echo "STDOUT" && exit 32"#])
579 .ok_exit_codes(&[0])
580 .build()
581 .run();
582 assert!(res.is_err(), "process exits non-zero");
583 let e = error_from_run(res)?;
584 let expect = r#"Got unexpected exit code 32 from `bash -c echo "STDOUT" && exit 32`.
585Stdout:
586STDOUT
587
588Stderr was empty.
589"#;
590 assert_eq!(format!("{e}"), expect, "error display output");
591
592 match e {
593 Error::UnexpectedExitCode {
594 cmd: _,
595 code,
596 stdout,
597 stderr,
598 } => {
599 assert_eq!(code, 32, "process unexpectedly exits 32");
600 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
601 assert_eq!(stderr, "", "stderr was empty");
602 }
603 e => return Err(e.into()),
604 }
605
606 Ok(())
607 }
608
609 #[test]
610 #[parallel]
611 fn run_exit_32_with_stderr() -> Result<()> {
612 if which("bash").is_err() {
613 println!("Skipping test since bash is not in path");
614 return Ok(());
615 }
616
617 let res = Exec::builder()
618 .exe("bash")
619 .args(vec!["-c", r#"echo "STDERR" 1>&2 && exit 32"#])
620 .ok_exit_codes(&[0])
621 .build()
622 .run();
623 assert!(res.is_err(), "process exits non-zero");
624 let e = error_from_run(res)?;
625 let expect = r#"Got unexpected exit code 32 from `bash -c echo "STDERR" 1>&2 && exit 32`.
626Stdout was empty.
627Stderr:
628STDERR
629
630"#;
631 assert_eq!(format!("{e}"), expect, "error display output");
632
633 match e {
634 Error::UnexpectedExitCode {
635 cmd: _,
636 code,
637 stdout,
638 stderr,
639 } => {
640 assert_eq!(
641 code, 32,
642 "process unexpectedly
643 exits 32"
644 );
645 assert_eq!(stdout, "", "stdout was empty");
646 assert_eq!(stderr, "STDERR\n", "stderr was captured");
647 }
648 e => return Err(e.into()),
649 }
650
651 Ok(())
652 }
653
654 #[test]
655 #[parallel]
656 fn run_exit_32_with_stdout_and_stderr() -> Result<()> {
657 if which("bash").is_err() {
658 println!("Skipping test since bash is not in path");
659 return Ok(());
660 }
661
662 let res = Exec::builder()
663 .exe("bash")
664 .args(vec![
665 "-c",
666 r#"echo "STDOUT" && echo "STDERR" 1>&2 && exit 32"#,
667 ])
668 .ok_exit_codes(&[0])
669 .build()
670 .run();
671 assert!(res.is_err(), "process exits non-zero");
672
673 let e = error_from_run(res)?;
674 let expect = r#"Got unexpected exit code 32 from `bash -c echo "STDOUT" && echo "STDERR" 1>&2 && exit 32`.
675Stdout:
676STDOUT
677
678Stderr:
679STDERR
680
681"#;
682 assert_eq!(format!("{e}"), expect, "error display output");
683 match e {
684 Error::UnexpectedExitCode {
685 cmd: _,
686 code,
687 stdout,
688 stderr,
689 } => {
690 assert_eq!(code, 32, "process unexpectedly exits 32");
691 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
692 assert_eq!(stderr, "STDERR\n", "stderr was captured");
693 }
694 e => return Err(e.into()),
695 }
696
697 Ok(())
698 }
699
700 #[cfg(target_family = "unix")]
701 #[test]
702 #[parallel]
703 fn run_exit_from_sig_kill() -> Result<()> {
704 if which("bash").is_err() {
705 println!("Skipping test since bash is not in path");
706 return Ok(());
707 }
708
709 let res = Exec::builder()
710 .exe("bash")
711 .args(vec!["-c", r#"sleep 0.1 && kill -TERM "$$""#])
712 .ok_exit_codes(&[0])
713 .build()
714 .run();
715 assert!(res.is_err(), "process exits non-zero");
716
717 match error_from_run(res)? {
718 Error::ProcessKilledBySignal {
719 cmd: _,
720 signal,
721 stdout,
722 stderr,
723 } => {
724 assert_eq!(signal, libc::SIGTERM, "process exited because of SIGTERM");
725 assert_eq!(stdout, "", "process had no stdout");
726 assert_eq!(stderr, "", "process had no stderr");
727 }
728 e => return Err(e.into()),
729 }
730
731 Ok(())
732 }
733
734 fn error_from_run(result: Result<super::Output>) -> Result<Error> {
735 match result {
736 Ok(_) => Err(format_err!("did not get an error in the returned Result")),
737 Err(e) => e.downcast::<super::Error>(),
738 }
739 }
740
741 #[test]
742 #[serial]
743 fn run_in_dir() -> Result<()> {
744 if cfg!(windows) {
747 return Ok(());
748 }
749
750 let td = tempdir()?;
751 let td_path = maybe_canonicalize(td.path())?;
752
753 let res = Exec::builder()
754 .exe("pwd")
755 .ok_exit_codes(&[0])
756 .in_dir(&td_path)
757 .build()
758 .run()?;
759 assert_eq!(res.exit_code, 0, "process exits 0");
760 assert!(res.stdout.is_some(), "process produced stdout output");
761
762 let stdout = res.stdout.unwrap();
763 let stdout_trimmed = stdout.trim_end();
764 assert_eq!(
765 stdout_trimmed,
766 td_path.to_string_lossy(),
767 "process runs in another dir",
768 );
769
770 Ok(())
771 }
772
773 #[test]
774 #[parallel]
775 fn executable_does_not_exist() {
776 let res = Exec::builder()
777 .exe("I hope this binary does not exist on any system!")
778 .args(vec!["--arg", "42"])
779 .ok_exit_codes(&[0])
780 .build()
781 .run();
782 assert!(res.is_err());
783 if let Err(e) = res {
784 assert!(e.to_string().contains(
785 r#"Could not find "I hope this binary does not exist on any system!" in your path"#,
786 ));
787 }
788 }
789
790 #[test_case("foo", &[], 0, "foo"; "no arguments")]
791 #[test_case("foo", &["--bar"], 0, "foo --bar"; "one flag")]
792 #[test_case("foo", &["--bar", "--baz"], 0, "foo --bar --baz"; "two flags")]
793 #[test_case(
794 "foo",
795 &["--bar", "--baz", "--buz"],
796 0,
797 "foo --bar --baz --buz";
798 "three flags"
799 )]
800 #[test_case(
801 "foo",
802 &["--bar", "--baz", "--buz", "--quux"],
803 0,
804 "foo --bar --baz --buz --quux";
805 "four flags"
806 )]
807 #[test_case(
808 "foo",
809 &["--bar", "--baz", "--buz", "--quux"],
810 0,
811 "foo --bar --baz --buz --quux";
812 "five flags"
813 )]
814 #[test_case(
815 "foo",
816 &["bar"],
817 1,
818 "foo bar";
819 "one path"
820 )]
821 #[test_case(
822 "foo",
823 &["bar", "baz"],
824 2,
825 "foo bar baz";
826 "two paths"
827 )]
828 #[test_case(
829 "foo",
830 &["bar", "baz", "buz"],
831 3,
832 "foo bar baz buz";
833 "three paths"
834 )]
835 #[test_case(
836 "foo",
837 &["bar", "baz", "buz", "quux"],
838 4,
839 "foo bar baz ... and 2 more paths";
840 "four paths"
841 )]
842 #[test_case(
843 "foo",
844 &["bar", "baz", "buz", "quux", "corge"],
845 5,
846 "foo bar baz ... and 3 more paths";
847 "five paths"
848 )]
849 #[test_case(
850 "foo",
851 &["bar", "baz", "buz", "quux", "corge", "grault"],
852 6,
853 "foo bar baz ... and 4 more paths";
854 "six paths"
855 )]
856 #[test_case(
857 "foo",
858 &["--bar", "--baz", "--buz", "--quux", "bar"],
859 1,
860 "foo --bar --baz --buz --quux bar";
861 "four flags and one path"
862 )]
863 #[test_case(
864 "foo",
865 &["--bar", "--baz", "--buz", "--quux", "bar", "baz"],
866 2,
867 "foo --bar --baz --buz --quux bar baz";
868 "four flags and two paths"
869 )]
870 #[test_case(
871 "foo",
872 &["--bar", "--baz", "--buz", "--quux", "bar", "baz", "buz"],
873 2,
874 "foo --bar --baz --buz --quux bar baz buz";
875 "four flags and three paths"
876 )]
877 #[test_case(
878 "foo",
879 &["--bar", "--baz", "--buz", "--quux", "bar", "baz", "buz", "quux"],
880 4,
881 "foo --bar --baz --buz --quux bar baz ... and 2 more paths";
882 "four flags and four paths"
883 )]
884 #[test_case(
885 "foo",
886 &["--bar", "--baz", "--buz", "--quux", "bar", "baz", "buz", "quux", "corge"],
887 5,
888 "foo --bar --baz --buz --quux bar baz ... and 3 more paths";
889 "four flags and five paths"
890 )]
891 #[test_case(
892 "foo",
893 &["--bar", "--baz", "--buz", "--quux", "bar", "baz", "buz", "quux", "corge", "grault"],
894 6,
895 "foo --bar --baz --buz --quux bar baz ... and 4 more paths";
896 "four flags and six paths"
897 )]
898 #[parallel]
899 fn loggable_command(exe: &str, args: &[&str], num_paths: usize, expect: &str) {
900 let exec = Exec::builder()
901 .exe(exe)
902 .args(args.to_vec())
903 .num_paths(num_paths)
904 .ok_exit_codes(&[0])
905 .build();
906 assert_eq!(exec.loggable_command, expect);
907 }
908
909 fn maybe_canonicalize(path: &Path) -> Result<PathBuf> {
912 if cfg!(windows) {
913 return Ok(path.to_owned());
914 }
915 Ok(fs::canonicalize(path)?)
916 }
917}