Skip to main content

ytil_cmd/
lib.rs

1//! Execute system commands with structured errors and optional silenced output in release builds.
2//!
3//! Exposes an extension trait [`CmdExt`] with an `exec` method plus a helper [`silent_cmd`] that
4//! null-routes stdout/stderr outside debug mode. Errors capture the command name, args and working
5//! directory for concise diagnostics.
6//!
7//! See [`CmdError`] for failure variants with rich context.
8
9#![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
22/// Extension trait for [`Command`] to execute and handle errors.
23pub trait CmdExt {
24    /// Run the command; capture stdout & stderr; return [`Output`] on success.
25    ///
26    ///
27    /// # Errors
28    /// - Spawning or waiting fails ([`CmdError::Io`]).
29    /// - Non-zero exit with valid UTF-8 stderr ([`CmdError::CmdFailure`]).
30    /// - Non-zero exit with invalid UTF-8 stderr ([`CmdError::FromUtf8`]).
31    /// - Borrowed UTF-8 validation failure ([`CmdError::Utf8`]).
32    fn exec(&mut self) -> Result<Output, CmdError>;
33}
34
35/// Extracts and validates successful command output, converting it to a trimmed string.
36///
37/// # Errors
38/// - UTF-8 conversion fails.
39pub 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/// Command execution errors with contextual details.
48///
49/// Each variant embeds [`Cmd`] (program, args, cwd) for terse diagnostics. `Utf8`
50/// is currently not produced by [`CmdExt::exec`] but kept for potential future APIs.
51#[derive(Debug, thiserror::Error)]
52pub enum CmdError {
53    /// Non-zero exit status; stderr captured & UTF-8 decoded.
54    #[error("CmdFailure(\n{cmd}\nstatus={status:?}\nstderr=\n{stderr}\nstdout=\n{stdout})")]
55    CmdFailure {
56        /// Command metadata snapshot.
57        cmd: Cmd,
58        /// Full (untruncated) stderr.
59        stderr: String,
60        /// Full (untruncated) stdout.
61        stdout: String,
62        /// Failing status.
63        status: ExitStatus,
64    },
65    /// I/O failure spawning or waiting.
66    #[error("{source} {cmd}")]
67    Io {
68        /// Command metadata snapshot.
69        cmd: Cmd,
70        #[backtrace]
71        /// Underlying OS error.
72        source: std::io::Error,
73    },
74    /// Borrowed data UTF-8 validation failed.
75    #[error("{source} {cmd}")]
76    Utf8 {
77        /// Command metadata snapshot.
78        cmd: Cmd,
79        #[backtrace]
80        /// UTF-8 error.
81        source: core::str::Utf8Error,
82    },
83    /// Owned stderr bytes not valid UTF-8.
84    #[error("{source} {cmd}")]
85    FromUtf8 {
86        /// Command metadata snapshot.
87        cmd: Cmd,
88        #[backtrace]
89        /// Conversion error.
90        source: std::string::FromUtf8Error,
91    },
92}
93
94/// Snapshot of command name, args and cwd.
95///
96/// Arguments/program are converted lossily from [`std::ffi::OsStr`] to [`String`] for ease of logging.
97#[derive(Debug)]
98pub struct Cmd {
99    /// Ordered arguments (lossy UTF-8).
100    args: Vec<String>,
101    /// Working directory (if set).
102    cur_dir: Option<PathBuf>,
103    /// Program / executable name.
104    name: String,
105}
106
107/// Creates a [`Command`] for `program`; silences stdout/stderr in release builds.
108///
109/// In debug (`debug_assertions`), output is inherited for easier troubleshooting.
110/// In release, both streams are redirected to [`Stdio::null()`] to keep logs quiet.
111pub 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
144/// Formats [`Cmd`] for display, showing command name, arguments, and working directory.
145impl 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
155/// Converts a [`Command`] reference to [`Cmd`] for error reporting.
156impl From<&Command> for Cmd {
157    fn from(value: &Command) -> Self {
158        Self {
159            // Use into_owned() instead of to_string() to avoid extra allocation when Cow is already owned
160            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
167/// Converts a mutable [`Command`] reference to [`Cmd`] for error reporting.
168impl From<&mut Command> for Cmd {
169    fn from(value: &mut Command) -> Self {
170        Self {
171            // Use into_owned() instead of to_string() to avoid extra allocation when Cow is already owned
172            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}