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 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
21/// Extension trait for [`Command`] to execute and handle errors.
22pub trait CmdExt {
23    /// Run the command; capture stdout & stderr; return [`Output`] on success.
24    ///
25    ///
26    /// # Errors
27    /// - Spawning or waiting fails ([`CmdError::Io`]).
28    /// - Non-zero exit with valid UTF-8 stderr ([`CmdError::CmdFailure`]).
29    /// - Non-zero exit with invalid UTF-8 stderr ([`CmdError::FromUtf8`]).
30    /// - Borrowed UTF-8 validation failure ([`CmdError::Utf8`]).
31    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
52/// Extracts and validates successful command output, converting it to a trimmed string.
53///
54/// # Errors
55/// - UTF-8 conversion fails.
56pub 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/// Command execution errors with contextual details.
72///
73/// Each variant embeds [`Cmd`] (program, args, cwd) for terse diagnostics. `Utf8`
74/// is currently not produced by [`CmdExt::exec`] but kept for potential future APIs.
75#[derive(Debug, thiserror::Error)]
76pub enum CmdError {
77    /// Non-zero exit status; stderr captured & UTF-8 decoded.
78    #[error("CmdFailure(\n{cmd}\nstatus={status:?}\nstderr=\n{stderr}\nstdout=\n{stdout})")]
79    CmdFailure {
80        /// Command metadata snapshot.
81        cmd: Cmd,
82        /// Full (untruncated) stderr.
83        stderr: String,
84        /// Full (untruncated) stdout.
85        stdout: String,
86        /// Failing status.
87        status: ExitStatus,
88    },
89    /// I/O failure spawning or waiting.
90    #[error("{source} {cmd}")]
91    Io {
92        /// Command metadata snapshot.
93        cmd: Cmd,
94        #[backtrace]
95        /// Underlying OS error.
96        source: std::io::Error,
97    },
98    /// Borrowed data UTF-8 validation failed.
99    #[error("{source} {cmd}")]
100    Utf8 {
101        /// Command metadata snapshot.
102        cmd: Cmd,
103        #[backtrace]
104        /// UTF-8 error.
105        source: core::str::Utf8Error,
106    },
107    /// Owned stderr bytes not valid UTF-8.
108    #[error("{source} {cmd}")]
109    FromUtf8 {
110        /// Command metadata snapshot.
111        cmd: Cmd,
112        #[backtrace]
113        /// Conversion error.
114        source: std::string::FromUtf8Error,
115    },
116}
117
118/// Snapshot of command name, args and cwd.
119///
120/// Arguments/program are converted lossily from [`std::ffi::OsStr`] to [`String`] for ease of logging.
121#[derive(Debug)]
122pub struct Cmd {
123    /// Ordered arguments (lossy UTF-8).
124    args: Vec<String>,
125    /// Working directory (if set).
126    cur_dir: Option<PathBuf>,
127    /// Program / executable name.
128    name: String,
129}
130
131/// Formats [`Cmd`] for display, showing command name, arguments, and working directory.
132impl 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
142/// Converts a [`Command`] reference to [`Cmd`] for error reporting.
143impl From<&Command> for Cmd {
144    fn from(value: &Command) -> Self {
145        Self {
146            // Use into_owned() instead of to_string() to avoid extra allocation when Cow is already owned
147            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
154/// Converts a mutable [`Command`] reference to [`Cmd`] for error reporting.
155impl From<&mut Command> for Cmd {
156    fn from(value: &mut Command) -> Self {
157        Self {
158            // Use into_owned() instead of to_string() to avoid extra allocation when Cow is already owned
159            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
166/// Creates a [`Command`] for `program`; silences stdout/stderr in release builds.
167///
168/// In debug (`debug_assertions`), output is inherited for easier troubleshooting.
169/// In release, both streams are redirected to [`Stdio::null()`] to keep logs quiet.
170pub 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}