Skip to main content

precious_helpers/
exec.rs

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        // We use this a bunch of times so we'll just calculate it once. The full command is only
71        // used when we return an error, so it's okay to generate that on demand.
72        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 we don't have any paths, or if we have <= 3 arguments, we'll just include the whole
84        // thing, no matter whether those args are paths or not.
85        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        // At this point, we know we have more than 3 arguments. We will always include all the
93        // arguments that are _not_ paths.
94        cmd.extend(args.by_ref().take(num_non_paths));
95
96        // If we have 3 paths or less, we'll include all of them.
97        if args.len() <= 3 {
98            cmd.extend(args);
99            return cmd.join(" ");
100        }
101
102        // Otherwise we'll include 2 paths and then "and N more paths". We know that N will always
103        // be >= 2. We never want to include "... and 1 more path", since in that case we might as
104        // well have included that 1 path instead.
105        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            // I don't know under what circumstances this would happen. How does a process exit
226            // successfully without a status code? Is this a Windows-only thing? But the way the
227            // `process::Output` API works, this is a possibility, so we're gonna check for it.
228            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        // We are canonicalizing this primarily for the benefit of our debugging output, because
294        // otherwise we might see the current dir as just `.`, which is not helpful.
295        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    // Anything that does pushd must be run serially or else chaos ensues.
335    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    // This gets used for a number of tests, so we'll just define it once.
363    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        // On windows the path we get from `pwd` is a Windows path (C:\...)
745        // but `td.path()` contains a Unix path (/tmp/...). Very confusing.
746        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    // The temp directory on macOS in GitHub Actions appears to be a symlink, but
910    // canonicalizing on Windows breaks tests for some reason.
911    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}