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 color_eyre::eyre::Context as _;
20use color_eyre::eyre::eyre;
21
22pub trait CmdExt {
24 fn exec(&mut self) -> color_eyre::Result<Output, CmdError>;
33}
34
35impl CmdExt for Command {
36 fn exec(&mut self) -> Result<Output, CmdError> {
37 let output = self.output().map_err(|source| CmdError::Io {
38 cmd: Cmd::from(&*self),
39 source,
40 })?;
41 if !output.status.success() {
42 return Err(CmdError::CmdFailure {
43 cmd: Cmd::from(&*self),
44 stderr: to_ut8_string(self, output.stderr)?,
45 stdout: to_ut8_string(self, output.stdout)?,
46 status: output.status,
47 });
48 }
49 Ok(output)
50 }
51}
52
53pub fn extract_success_output(output: &Output) -> color_eyre::Result<String> {
58 output
59 .status
60 .exit_ok()
61 .wrap_err_with(|| eyre!("command exited with non-zero status"))?;
62 Ok(std::str::from_utf8(&output.stdout)
63 .wrap_err_with(|| eyre!("error decoding command stdout"))?
64 .trim()
65 .into())
66}
67
68fn to_ut8_string(cmd: &Command, bytes: Vec<u8>) -> color_eyre::Result<String, CmdError> {
69 String::from_utf8(bytes).map_err(|error| CmdError::FromUtf8 {
70 cmd: Cmd::from(cmd),
71 source: error,
72 })
73}
74
75#[derive(Debug, thiserror::Error)]
80pub enum CmdError {
81 #[error("CmdFailure(\n{cmd}\nstatus={status:?}\nstderr=\n{stderr}\nstdout=\n{stdout})")]
83 CmdFailure {
84 cmd: Cmd,
86 stderr: String,
88 stdout: String,
90 status: ExitStatus,
92 },
93 #[error("{source} {cmd}")]
95 Io {
96 cmd: Cmd,
98 #[backtrace]
99 source: std::io::Error,
101 },
102 #[error("{source} {cmd}")]
104 Utf8 {
105 cmd: Cmd,
107 #[backtrace]
108 source: core::str::Utf8Error,
110 },
111 #[error("{source} {cmd}")]
113 FromUtf8 {
114 cmd: Cmd,
116 #[backtrace]
117 source: std::string::FromUtf8Error,
119 },
120}
121
122#[derive(Debug)]
126pub struct Cmd {
127 args: Vec<String>,
129 cur_dir: Option<PathBuf>,
131 name: String,
133}
134
135impl Display for Cmd {
137 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
138 write!(
139 f,
140 "Cmd=(name={:?} args={:?} cur_dir={:?})",
141 self.name, self.args, self.cur_dir,
142 )
143 }
144}
145
146impl From<&Command> for Cmd {
148 fn from(value: &Command) -> Self {
149 Self {
150 name: value.get_program().to_string_lossy().to_string(),
151 args: value.get_args().map(|x| x.to_string_lossy().to_string()).collect(),
152 cur_dir: value.get_current_dir().map(Path::to_path_buf),
153 }
154 }
155}
156
157impl From<&mut Command> for Cmd {
159 fn from(value: &mut Command) -> Self {
160 Self {
161 name: value.get_program().to_string_lossy().to_string(),
162 args: value.get_args().map(|x| x.to_string_lossy().to_string()).collect(),
163 cur_dir: value.get_current_dir().map(Path::to_path_buf),
164 }
165 }
166}
167
168pub fn silent_cmd(program: &str) -> Command {
173 let mut cmd = Command::new(program);
174 if !cfg!(debug_assertions) {
175 cmd.stdout(Stdio::null()).stderr(Stdio::null());
176 }
177 cmd
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn exec_success_returns_output() {
186 let mut cmd = Command::new("bash");
187 cmd.args(["-c", "echo -n ok"]);
188
189 assert2::let_assert!(Ok(out) = cmd.exec());
190 assert!(out.status.success());
191 assert_eq!(String::from_utf8(out.stdout).unwrap(), "ok");
192 assert_eq!(String::from_utf8(out.stderr).unwrap(), "");
193 }
194
195 #[test]
196 fn exec_captures_non_zero_status() {
197 let mut cmd = Command::new("bash");
198 cmd.args(["-c", "echo foo error 1>&2; exit 7"]);
199
200 assert2::let_assert!(
201 Err(CmdError::CmdFailure {
202 status,
203 stderr,
204 stdout,
205 ..
206 }) = cmd.exec()
207 );
208 assert_eq!(status.code(), Some(7));
209 assert!(stderr.contains("foo err"));
210 assert!(stdout.is_empty());
211 }
212}