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 color_eyre::eyre::Context as _;
20use color_eyre::eyre::eyre;
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) -> 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
53/// Extracts and validates successful command output, converting it to a trimmed string.
54///
55/// # Errors
56/// - UTF-8 conversion fails.
57pub 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/// Command execution errors with contextual details.
76///
77/// Each variant embeds [`Cmd`] (program, args, cwd) for terse diagnostics. `Utf8`
78/// is currently not produced by [`CmdExt::exec`] but kept for potential future APIs.
79#[derive(Debug, thiserror::Error)]
80pub enum CmdError {
81    /// Non-zero exit status; stderr captured & UTF-8 decoded.
82    #[error("CmdFailure(\n{cmd}\nstatus={status:?}\nstderr=\n{stderr}\nstdout=\n{stdout})")]
83    CmdFailure {
84        /// Command metadata snapshot.
85        cmd: Cmd,
86        /// Full (untruncated) stderr.
87        stderr: String,
88        /// Full (untruncated) stdout.
89        stdout: String,
90        /// Failing status.
91        status: ExitStatus,
92    },
93    /// I/O failure spawning or waiting.
94    #[error("{source} {cmd}")]
95    Io {
96        /// Command metadata snapshot.
97        cmd: Cmd,
98        #[backtrace]
99        /// Underlying OS error.
100        source: std::io::Error,
101    },
102    /// Borrowed data UTF-8 validation failed.
103    #[error("{source} {cmd}")]
104    Utf8 {
105        /// Command metadata snapshot.
106        cmd: Cmd,
107        #[backtrace]
108        /// UTF-8 error.
109        source: core::str::Utf8Error,
110    },
111    /// Owned stderr bytes not valid UTF-8.
112    #[error("{source} {cmd}")]
113    FromUtf8 {
114        /// Command metadata snapshot.
115        cmd: Cmd,
116        #[backtrace]
117        /// Conversion error.
118        source: std::string::FromUtf8Error,
119    },
120}
121
122/// Snapshot of command name, args and cwd.
123///
124/// Arguments/program are converted lossily from [`std::ffi::OsStr`] to [`String`] for ease of logging.
125#[derive(Debug)]
126pub struct Cmd {
127    /// Ordered arguments (lossy UTF-8).
128    args: Vec<String>,
129    /// Working directory (if set).
130    cur_dir: Option<PathBuf>,
131    /// Program / executable name.
132    name: String,
133}
134
135/// Formats [`Cmd`] for display, showing command name, arguments, and working directory.
136impl 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
146/// Converts a [`Command`] reference to [`Cmd`] for error reporting.
147impl 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
157/// Converts a mutable [`Command`] reference to [`Cmd`] for error reporting.
158impl 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
168/// Creates a [`Command`] for `program`; silences stdout/stderr in release builds.
169///
170/// In debug (`debug_assertions`), output is inherited for easier troubleshooting.
171/// In release, both streams are redirected to [`Stdio::null()`] to keep logs quiet.
172pub 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}