command_error/
command_ext.rs

1use std::fmt::Debug;
2use std::fmt::Display;
3use std::process::Child;
4use std::process::ExitStatus;
5use std::process::{Command, Output};
6
7use utf8_command::Utf8Output;
8
9use crate::ChildContext;
10use crate::Error;
11use crate::ExecError;
12use crate::OutputContext;
13use crate::OutputConversionError;
14use crate::OutputLike;
15use crate::Utf8ProgramAndArgs;
16
17/// Extension trait for [`Command`].
18///
19/// [`CommandExt`] methods check the exit status of the command (or perform user-supplied
20/// validation logic) and produced detailed, helpful error messages when they fail:
21///
22/// ```
23/// # use indoc::indoc;
24/// use std::process::Command;
25/// use command_error::CommandExt;
26///
27/// let err = Command::new("sh")
28///     .args(["-c", "echo puppy; false"])
29///     .output_checked_utf8()
30///     .unwrap_err();
31///
32/// assert_eq!(
33///     err.to_string(),
34///     indoc!(
35///         "`sh` failed: exit status: 1
36///         Command failed: `sh -c 'echo puppy; false'`
37///         Stdout:
38///           puppy"
39///     )
40/// );
41/// ```
42///
43/// With the `tracing` feature enabled, commands will be logged before they run.
44///
45/// # Method overview
46///
47/// | Method | Output decoding | Errors |
48/// | ------ | --------------- | ------ |
49/// | [`output_checked`][CommandExt::output_checked`] | Bytes | If non-zero exit code |
50/// | [`output_checked_with`][CommandExt::output_checked_with`] | Arbitrary | Custom |
51/// | [`output_checked_as`][CommandExt::output_checked_as`] | Arbitrary | Custom, with arbitrary error type |
52/// | [`output_checked_utf8`][CommandExt::output_checked_utf8`] | UTF-8 | If non-zero exit code |
53/// | [`output_checked_with_utf8`][CommandExt::output_checked_with_utf8`] | UTF-8 | Custom |
54/// | [`status_checked`][CommandExt::status_checked`] | None | If non-zero exit code |
55/// | [`status_checked_with`][CommandExt::status_checked_with`] | None | Custom |
56/// | [`status_checked_as`][CommandExt::status_checked_as`] | None | Custom, with arbitrary error type |
57pub trait CommandExt: Sized {
58    /// The error type returned from methods on this trait.
59    type Error: From<Error> + Send + Sync;
60
61    /// The type of child process produced.
62    type Child;
63
64    /// Run a command, capturing its output. `succeeded` is called and returned to determine if the
65    /// command succeeded.
66    ///
67    /// See [`Command::output`] for more information.
68    ///
69    /// This is the most general [`CommandExt`] method, and gives the caller full control over
70    /// success logic and the output and errors produced.
71    ///
72    /// ```
73    /// # use indoc::indoc;
74    /// # use std::process::Command;
75    /// # use std::process::Output;
76    /// # use command_error::CommandExt;
77    /// # use command_error::OutputContext;
78    /// # mod serde_json {
79    /// #     /// Teehee!
80    /// #     pub fn from_slice(_input: &[u8]) -> Result<Vec<String>, String> {
81    /// #         Err("EOF while parsing a list at line 4 column 11".into())
82    /// #     }
83    /// # }
84    /// let err = Command::new("cat")
85    ///     .arg("tests/data/incomplete.json")
86    ///     .output_checked_as(|context: OutputContext<Output>| {
87    ///         serde_json::from_slice(&context.output().stdout)
88    ///             .map_err(|err| context.error_msg(err))
89    ///     })
90    ///     .unwrap_err();
91    ///
92    /// assert_eq!(
93    ///     err.to_string(),
94    ///     indoc!(
95    ///         r#"`cat` failed: EOF while parsing a list at line 4 column 11
96    ///         exit status: 0
97    ///         Command failed: `cat tests/data/incomplete.json`
98    ///         Stdout:
99    ///           [
100    ///               "cuppy",
101    ///               "dog",
102    ///               "city","#
103    ///     )
104    /// );
105    /// ```
106    ///
107    /// Note that the closure takes the output as raw bytes but the error message contains the
108    /// output decoded as UTF-8. In this example, the decoding only happens in the error case, but
109    /// if you request an [`OutputContext<Utf8Output>`], the decoded data will be reused for the
110    /// error message.
111    ///
112    /// The [`OutputContext`] passed to the closure contains information about the command's
113    /// [`Output`] (including its [`ExitStatus`]), the command that ran (the program name and its
114    /// arguments), and methods for constructing detailed error messages (with or without
115    /// additional context information).
116    #[track_caller]
117    fn output_checked_as<O, R, E>(
118        &mut self,
119        succeeded: impl Fn(OutputContext<O>) -> Result<R, E>,
120    ) -> Result<R, E>
121    where
122        O: Debug + OutputLike + TryFrom<Output> + Send + Sync + 'static,
123        <O as TryFrom<Output>>::Error: Display + Send + Sync,
124        E: From<Self::Error> + Send + Sync;
125
126    /// Run a command, capturing its output. `succeeded` is called and used to determine if the
127    /// command succeeded and (optionally) to add an additional message to the error returned.
128    ///
129    /// This method is best if you want to consider a command successful if it has a non-zero exit
130    /// code, or if its output contains some special string. If you'd like to additionally produce
131    /// output that can't be produced with [`TryFrom<Output>`] (such as to deserialize a data
132    /// structure), [`CommandExt::output_checked_as`] provides full control over the produced
133    /// result.
134    ///
135    /// See [`Command::output`] for more information.
136    ///
137    /// ```
138    /// # use indoc::indoc;
139    /// # use std::process::Command;
140    /// # use std::process::Output;
141    /// # use command_error::CommandExt;
142    /// let output = Command::new("sh")
143    ///     .args(["-c", "echo puppy && exit 2"])
144    ///     .output_checked_with(|output: &Output| {
145    ///         if let Some(2) = output.status.code() {
146    ///             Ok(())
147    ///         } else {
148    ///             // Don't add any additional context to the error message:
149    ///             Err(None::<String>)
150    ///         }
151    ///     })
152    ///     .unwrap();
153    ///
154    /// assert_eq!(
155    ///     output.status.code(),
156    ///     Some(2),
157    /// );
158    /// ```
159    ///
160    /// Note that due to the generic error parameter, you'll need to annotate [`None`] return
161    /// values with a [`Display`]able type — try [`String`] or any [`std::error::Error`] type in
162    /// scope.
163    ///
164    /// [`Command::output_checked_with`] can also be used to convert the output to any type that
165    /// implements [`TryFrom<Output>`] before running `succeeded`:
166    ///
167    /// ```
168    /// # use indoc::indoc;
169    /// # use std::process::Command;
170    /// # use command_error::CommandExt;
171    /// # use utf8_command::Utf8Output;
172    /// let err = Command::new("sh")
173    ///     .args(["-c", "echo kitty && kill -9 \"$$\""])
174    ///     .output_checked_with(|output: &Utf8Output| {
175    ///         if output.status.success() && output.stdout.trim() == "puppy" {
176    ///             Ok(())
177    ///         } else {
178    ///             Err(Some("didn't find any puppy!"))
179    ///         }
180    ///     })
181    ///     .unwrap_err();
182    ///
183    /// assert_eq!(
184    ///     err.to_string(),
185    ///     indoc!(
186    ///         r#"`sh` failed: didn't find any puppy!
187    ///         signal: 9 (SIGKILL)
188    ///         Command failed: `sh -c 'echo kitty && kill -9 "$$"'`
189    ///         Stdout:
190    ///           kitty"#
191    ///     )
192    /// );
193    /// ```
194    #[track_caller]
195    fn output_checked_with<O, E>(
196        &mut self,
197        succeeded: impl Fn(&O) -> Result<(), Option<E>>,
198    ) -> Result<O, Self::Error>
199    where
200        O: Debug + OutputLike + TryFrom<Output> + Send + Sync + 'static,
201        <O as TryFrom<Output>>::Error: Display + Send + Sync,
202        E: Debug + Display + Send + Sync + 'static,
203    {
204        self.output_checked_as(|context| match succeeded(context.output()) {
205            Ok(()) => Ok(context.into_output()),
206            Err(user_error) => Err(context.maybe_error_msg(user_error).into()),
207        })
208    }
209
210    /// Run a command, capturing its output. If the command exits with a non-zero exit code, an
211    /// error is raised.
212    ///
213    /// Error messages are detailed and contain information about the command that was run and its
214    /// output:
215    ///
216    /// ```
217    /// # use pretty_assertions::assert_eq;
218    /// # use indoc::indoc;
219    /// # use std::process::Command;
220    /// # use command_error::CommandExt;
221    /// let err = Command::new("ooby-gooby")
222    ///     .output_checked()
223    ///     .unwrap_err();
224    ///
225    /// assert_eq!(
226    ///     err.to_string(),
227    ///     "Failed to execute `ooby-gooby`: No such file or directory (os error 2)"
228    /// );
229    ///
230    /// let err = Command::new("sh")
231    ///     .args(["-c", "echo puppy && exit 1"])
232    ///     .output_checked()
233    ///     .unwrap_err();
234    /// assert_eq!(
235    ///     err.to_string(),
236    ///     indoc!(
237    ///         "`sh` failed: exit status: 1
238    ///         Command failed: `sh -c 'echo puppy && exit 1'`
239    ///         Stdout:
240    ///           puppy"
241    ///     )
242    /// );
243    /// ```
244    ///
245    /// If the command fails, output will be decoded as UTF-8 for display in error messages, but
246    /// otherwise no output decoding is performed. To decode output as UTF-8, use
247    /// [`CommandExt::output_checked_utf8`]. To decode as other formats, use
248    /// [`CommandExt::output_checked_with`].
249    ///
250    /// See [`Command::output`] for more information.
251    #[track_caller]
252    fn output_checked(&mut self) -> Result<Output, Self::Error> {
253        self.output_checked_with(|output: &Output| {
254            if output.status.success() {
255                Ok(())
256            } else {
257                Err(None::<String>)
258            }
259        })
260    }
261
262    /// Run a command, capturing its output and decoding it as UTF-8. If the command exits with a
263    /// non-zero exit code or if its output contains invalid UTF-8, an error is raised.
264    ///
265    /// See [`CommandExt::output_checked`] and [`Command::output`] for more information.
266    ///
267    /// ```
268    /// # use pretty_assertions::assert_eq;
269    /// # use indoc::indoc;
270    /// # use std::process::Command;
271    /// # use std::process::ExitStatus;
272    /// # use command_error::CommandExt;
273    /// # use utf8_command::Utf8Output;
274    /// let output = Command::new("echo")
275    ///     .arg("puppy")
276    ///     .output_checked_utf8()
277    ///     .unwrap();
278    ///
279    /// assert_eq!(
280    ///     output,
281    ///     Utf8Output {
282    ///         status: ExitStatus::default(),
283    ///         stdout: "puppy\n".into(),
284    ///         stderr: "".into(),
285    ///     },
286    /// );
287    /// ```
288    #[track_caller]
289    fn output_checked_utf8(&mut self) -> Result<Utf8Output, Self::Error> {
290        self.output_checked_with_utf8(|output| {
291            if output.status.success() {
292                Ok(())
293            } else {
294                Err(None::<String>)
295            }
296        })
297    }
298
299    /// Run a command, capturing its output and decoding it as UTF-8. `succeeded` is called and
300    /// used to determine if the command succeeded and (optionally) to add an additional message to
301    /// the error returned.
302    ///
303    /// See [`CommandExt::output_checked_with`] and [`Command::output`] for more information.
304    ///
305    /// ```
306    /// # use pretty_assertions::assert_eq;
307    /// # use indoc::indoc;
308    /// # use std::process::Command;
309    /// # use std::process::ExitStatus;
310    /// # use command_error::CommandExt;
311    /// # use utf8_command::Utf8Output;
312    /// let output = Command::new("sh")
313    ///     .args(["-c", "echo puppy; exit 1"])
314    ///     .output_checked_with_utf8(|output| {
315    ///         if output.stdout.contains("puppy") {
316    ///             Ok(())
317    ///         } else {
318    ///             Err(None::<String>)
319    ///         }
320    ///     })
321    ///     .unwrap();
322    ///
323    /// assert_eq!(output.stdout, "puppy\n");
324    /// assert_eq!(output.status.code(), Some(1));
325    /// ```
326    #[track_caller]
327    fn output_checked_with_utf8<E>(
328        &mut self,
329        succeeded: impl Fn(&Utf8Output) -> Result<(), Option<E>>,
330    ) -> Result<Utf8Output, Self::Error>
331    where
332        E: Display + Debug + Send + Sync + 'static,
333    {
334        self.output_checked_with(succeeded)
335    }
336
337    /// Run a command without capturing its output. `succeeded` is called and returned to determine
338    /// if the command succeeded.
339    ///
340    /// This gives the caller full control over success logic and the output and errors produced.
341    ///
342    /// ```
343    /// # use pretty_assertions::assert_eq;
344    /// # use indoc::indoc;
345    /// # use std::process::Command;
346    /// # use std::process::ExitStatus;
347    /// # use command_error::CommandExt;
348    /// # use command_error::OutputContext;
349    /// let succeeded = |context: OutputContext<ExitStatus>| {
350    ///     match context.status().code() {
351    ///         Some(code) => Ok(code),
352    ///         None => Err(context.error_msg("no exit code")),
353    ///     }
354    /// };
355    ///
356    /// let code = Command::new("true")
357    ///     .status_checked_as(succeeded)
358    ///     .unwrap();
359    /// assert_eq!(code, 0);
360    ///
361    /// let err = Command::new("sh")
362    ///     .args(["-c", "kill \"$$\""])
363    ///     .status_checked_as(succeeded)
364    ///     .unwrap_err();
365    /// assert_eq!(
366    ///     err.to_string(),
367    ///     indoc!(
368    ///         r#"`sh` failed: no exit code
369    ///         signal: 15 (SIGTERM)
370    ///         Command failed: `sh -c 'kill "$$"'`"#
371    ///     )
372    /// );
373    /// ```
374    ///
375    /// To error on non-zero exit codes, use [`CommandExt::status_checked`].
376    ///
377    /// See [`Command::status`] for more information.
378    #[track_caller]
379    fn status_checked_as<R, E>(
380        &mut self,
381        succeeded: impl Fn(OutputContext<ExitStatus>) -> Result<R, E>,
382    ) -> Result<R, E>
383    where
384        E: From<Self::Error>;
385
386    /// Run a command without capturing its output. `succeeded` is called and used to determine
387    /// if the command succeeded and (optionally) to add an additional message to the error
388    /// returned.
389    ///
390    /// ```
391    /// # use pretty_assertions::assert_eq;
392    /// # use indoc::indoc;
393    /// # use std::process::Command;
394    /// # use std::process::ExitStatus;
395    /// # use command_error::CommandExt;
396    /// # use command_error::OutputContext;
397    /// let status = Command::new("false")
398    ///     .status_checked_with(|status| {
399    ///         match status.code() {
400    ///             // Exit codes 0 and 1 are OK.
401    ///             Some(0) | Some(1) => Ok(()),
402    ///             // Other exit codes are errors.
403    ///             _ => Err(None::<String>)
404    ///         }
405    ///     })
406    ///     .unwrap();
407    /// assert_eq!(status.code(), Some(1));
408    /// ```
409    ///
410    /// See [`Command::status`] for more information.
411    #[track_caller]
412    fn status_checked_with<E>(
413        &mut self,
414        succeeded: impl Fn(ExitStatus) -> Result<(), Option<E>>,
415    ) -> Result<ExitStatus, Self::Error>
416    where
417        E: Debug + Display + Send + Sync + 'static,
418    {
419        self.status_checked_as(|status| match succeeded(status.status()) {
420            Ok(()) => Ok(status.status()),
421            Err(user_error) => Err(status.maybe_error_msg(user_error).into()),
422        })
423    }
424
425    /// Run a command without capturing its output. If the command exits with a non-zero status
426    /// code, an error is raised containing information about the command that was run:
427    ///
428    /// ```
429    /// # use pretty_assertions::assert_eq;
430    /// # use indoc::indoc;
431    /// # use std::process::Command;
432    /// # use std::process::ExitStatus;
433    /// # use command_error::CommandExt;
434    /// let err = Command::new("sh")
435    ///     .args(["-c", "exit 1"])
436    ///     .status_checked()
437    ///     .unwrap_err();
438    ///
439    /// assert_eq!(
440    ///     err.to_string(),
441    ///     indoc!(
442    ///         "`sh` failed: exit status: 1
443    ///         Command failed: `sh -c 'exit 1'`"
444    ///     )
445    /// );
446    /// ```
447    ///
448    /// See [`Command::status`] for more information.
449    #[track_caller]
450    fn status_checked(&mut self) -> Result<ExitStatus, Self::Error> {
451        self.status_checked_with(|status| {
452            if status.success() {
453                Ok(())
454            } else {
455                Err(None::<String>)
456            }
457        })
458    }
459
460    /// Spawn a command.
461    ///
462    /// The returned child contains context information about the command that produced it, which
463    /// can be used to produce detailed error messages if the child process fails.
464    ///
465    /// See [`Command::spawn`] for more information.
466    ///
467    /// ```
468    /// # use pretty_assertions::assert_eq;
469    /// # use indoc::indoc;
470    /// # use std::process::Command;
471    /// # use std::process::ExitStatus;
472    /// # use command_error::CommandExt;
473    /// let err = Command::new("ooga booga")
474    ///     .spawn_checked()
475    ///     .unwrap_err();
476    ///
477    /// assert_eq!(
478    ///     err.to_string(),
479    ///     "Failed to execute `'ooga booga'`: No such file or directory (os error 2)"
480    /// );
481    /// ```
482    #[track_caller]
483    fn spawn_checked(&mut self) -> Result<Self::Child, Self::Error>;
484
485    /// Log the command that will be run.
486    ///
487    /// With the `tracing` feature enabled, this will emit a debug-level log with message
488    /// `Executing command` and a `command` field containing the command and arguments shell-quoted.
489    fn log(&self) -> Result<(), Self::Error>;
490}
491
492impl CommandExt for Command {
493    type Error = Error;
494    type Child = ChildContext<Child>;
495
496    fn log(&self) -> Result<(), Self::Error> {
497        #[cfg(feature = "tracing")]
498        {
499            let command: Utf8ProgramAndArgs = self.into();
500            tracing::debug!(%command, "Executing command");
501        }
502        Ok(())
503    }
504
505    fn output_checked_as<O, R, E>(
506        &mut self,
507        succeeded: impl Fn(OutputContext<O>) -> Result<R, E>,
508    ) -> Result<R, E>
509    where
510        O: Debug + OutputLike + TryFrom<Output> + Send + Sync + 'static,
511        <O as TryFrom<Output>>::Error: Display + Send + Sync,
512        E: From<Self::Error> + Send + Sync,
513    {
514        self.log()?;
515        let displayed: Utf8ProgramAndArgs = (&*self).into();
516        match self.output() {
517            Ok(output) => match output.try_into() {
518                Ok(output) => succeeded(OutputContext {
519                    output,
520                    command: Box::new(displayed),
521                }),
522                Err(error) => Err(Error::from(OutputConversionError {
523                    command: Box::new(displayed),
524                    inner: Box::new(error),
525                })
526                .into()),
527            },
528            Err(inner) => Err(Error::from(ExecError {
529                command: Box::new(displayed),
530                inner,
531            })
532            .into()),
533        }
534    }
535
536    fn status_checked_as<R, E>(
537        &mut self,
538        succeeded: impl Fn(OutputContext<ExitStatus>) -> Result<R, E>,
539    ) -> Result<R, E>
540    where
541        E: From<Self::Error>,
542    {
543        self.log()?;
544        let displayed: Utf8ProgramAndArgs = (&*self).into();
545        let displayed = Box::new(displayed);
546        match self.status() {
547            Ok(status) => succeeded(OutputContext {
548                output: status,
549                command: displayed,
550            }),
551            Err(inner) => Err(Error::from(ExecError {
552                command: displayed,
553                inner,
554            })
555            .into()),
556        }
557    }
558
559    fn spawn_checked(&mut self) -> Result<Self::Child, Self::Error> {
560        let displayed: Utf8ProgramAndArgs = (&*self).into();
561        match self.spawn() {
562            Ok(child) => Ok(ChildContext {
563                child,
564                command: Box::new(displayed),
565            }),
566            Err(inner) => Err(Error::from(ExecError {
567                command: Box::new(displayed),
568                inner,
569            })),
570        }
571    }
572}