1#![feature(error_generic_member_access, exit_status_error)]
10
11use core::fmt::Display;
12use std::path::Path;
13use std::path::PathBuf;
14use std::process::Command;
15use std::process::ExitStatus;
16use std::process::Output;
17use std::process::Stdio;
18
19use rootcause::prelude::ResultExt as _;
20
21pub trait CmdExt {
23 fn exec(&mut self) -> Result<Output, CmdError>;
32}
33
34impl CmdExt for Command {
35 fn exec(&mut self) -> Result<Output, CmdError> {
36 let output = self.output().map_err(|source| CmdError::Io {
37 cmd: Cmd::from(&*self),
38 source,
39 })?;
40 if !output.status.success() {
41 return Err(CmdError::CmdFailure {
42 cmd: Cmd::from(&*self),
43 stderr: to_utf8_string(self, output.stderr)?,
44 stdout: to_utf8_string(self, output.stdout)?,
45 status: output.status,
46 });
47 }
48 Ok(output)
49 }
50}
51
52pub fn extract_success_output(output: &Output) -> rootcause::Result<String> {
57 output.status.exit_ok().context("command exited with non-zero status")?;
58 Ok(std::str::from_utf8(&output.stdout)
59 .context("error decoding command stdout")?
60 .trim()
61 .into())
62}
63
64fn to_utf8_string(cmd: &Command, bytes: Vec<u8>) -> Result<String, CmdError> {
65 String::from_utf8(bytes).map_err(|error| CmdError::FromUtf8 {
66 cmd: Cmd::from(cmd),
67 source: error,
68 })
69}
70
71#[derive(Debug, thiserror::Error)]
76pub enum CmdError {
77 #[error("CmdFailure(\n{cmd}\nstatus={status:?}\nstderr=\n{stderr}\nstdout=\n{stdout})")]
79 CmdFailure {
80 cmd: Cmd,
82 stderr: String,
84 stdout: String,
86 status: ExitStatus,
88 },
89 #[error("{source} {cmd}")]
91 Io {
92 cmd: Cmd,
94 #[backtrace]
95 source: std::io::Error,
97 },
98 #[error("{source} {cmd}")]
100 Utf8 {
101 cmd: Cmd,
103 #[backtrace]
104 source: core::str::Utf8Error,
106 },
107 #[error("{source} {cmd}")]
109 FromUtf8 {
110 cmd: Cmd,
112 #[backtrace]
113 source: std::string::FromUtf8Error,
115 },
116}
117
118#[derive(Debug)]
122pub struct Cmd {
123 args: Vec<String>,
125 cur_dir: Option<PathBuf>,
127 name: String,
129}
130
131impl Display for Cmd {
133 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134 write!(
135 f,
136 "Cmd=(name={:?} args={:?} cur_dir={:?})",
137 self.name, self.args, self.cur_dir,
138 )
139 }
140}
141
142impl From<&Command> for Cmd {
144 fn from(value: &Command) -> Self {
145 Self {
146 name: value.get_program().to_string_lossy().into_owned(),
148 args: value.get_args().map(|x| x.to_string_lossy().into_owned()).collect(),
149 cur_dir: value.get_current_dir().map(Path::to_path_buf),
150 }
151 }
152}
153
154impl From<&mut Command> for Cmd {
156 fn from(value: &mut Command) -> Self {
157 Self {
158 name: value.get_program().to_string_lossy().into_owned(),
160 args: value.get_args().map(|x| x.to_string_lossy().into_owned()).collect(),
161 cur_dir: value.get_current_dir().map(Path::to_path_buf),
162 }
163 }
164}
165
166pub fn silent_cmd(program: &str) -> Command {
171 let mut cmd = Command::new(program);
172 if !cfg!(debug_assertions) {
173 cmd.stdout(Stdio::null()).stderr(Stdio::null());
174 }
175 cmd
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn exec_success_returns_output() {
184 let mut cmd = Command::new("bash");
185 cmd.args(["-c", "echo -n ok"]);
186
187 assert2::assert!(let Ok(out) = cmd.exec());
188 assert!(out.status.success());
189 assert_eq!(String::from_utf8(out.stdout).unwrap(), "ok");
190 assert_eq!(String::from_utf8(out.stderr).unwrap(), "");
191 }
192
193 #[test]
194 fn exec_captures_non_zero_status() {
195 let mut cmd = Command::new("bash");
196 cmd.args(["-c", "echo foo error 1>&2; exit 7"]);
197
198 assert2::assert!(let
199 Err(CmdError::CmdFailure {
200 status,
201 stderr,
202 stdout,
203 ..
204 }) = cmd.exec()
205 );
206 assert_eq!(status.code(), Some(7));
207 assert!(stderr.contains("foo err"));
208 assert!(stdout.is_empty());
209 }
210}