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