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}