just_run/
lib.rs

1//! # just-run
2//!
3//! `just-run` is a simple convenience crate for executing system commands with the expectation
4//! of successful termination and UTF-8 encoded output. It aims to handle the most common case without
5//! extensive configuration or edge case coverage.
6//!
7//! ## Usage
8//! ```rust
9//! use just_run::{run, Success};
10//!
11//! let Success { stdout, stderr } = run("echo", ["Hello world!"]).expect("Command failed");
12//! println!("{stdout}");
13//! ```
14//!
15//! For more advanced use cases, consider checking out the [`duct`](https://crates.io/crates/duct) crate or
16//! using the standard library tools directly.
17//!
18//! ## Future
19//! This crate focuses on convenience. It might expand to include async command execution or other
20//! features in the future, but it is not meant to cover all use cases.
21
22#![cfg_attr(docsrs, feature(doc_auto_cfg))]
23
24/// Represents the successful UTF-8 encoded output of a command execution.
25///
26/// This struct is generally created by calling [`run`]. Please see the documentation of [`run`] for more details.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Success {
29    /// The standard output generated by the command.
30    pub stdout: String,
31
32    /// The standard error output generated by the command.
33    pub stderr: String,
34}
35
36/// Executes a system command with the specified arguments.
37///
38/// Executes the command as a child process, waiting for it to finish and
39/// collecting all of its output.
40///
41/// stdout and stderr are captured (and used to provide the resulting output).
42/// Stdin is not inherited from the parent and any attempt by the child process
43/// to read from the stdin stream will result in the stream immediately closing.
44///
45/// # Returns
46/// Returns the stdout and stderr output of the command if it terminates successfully
47/// and the output could be decoded as UTF-8.
48///
49/// # Example
50/// ```rust
51/// use just_run::{run, Success};
52///
53/// let Success { stdout, stderr } = match run("echo", ["Hello, World!"]) {
54///     Ok(success) => success,
55///     Err(err) => panic!("Error: {err}"),
56/// };
57/// ```
58///
59/// This function is intended for straightforward command execution scenarios. For
60/// more complex use cases or advanced features, consider using alternative crates
61/// or standard library functionality.
62pub fn run<Prog, Args, Arg>(program: Prog, args: Args) -> Result<Success, Error>
63where
64    Prog: AsRef<std::ffi::OsStr>,
65    Args: IntoIterator<Item = Arg>,
66    Arg: AsRef<std::ffi::OsStr>,
67{
68    let mut command = std::process::Command::new(program);
69    command.args(args);
70
71    execute(&mut command)
72}
73
74fn execute(cmd: &mut std::process::Command) -> Result<Success, Error> {
75    let std::process::Output { status, stdout, stderr } = cmd.output()?;
76    if !status.success() {
77        return Err(Error::UnsuccessfulTermination { status, stdout, stderr });
78    }
79
80    let stdout = String::from_utf8(stdout).map_err(Error::StdoutNotUtf8)?;
81    let stderr = String::from_utf8(stderr).map_err(Error::StderrNotUtf8)?;
82
83    Ok(Success { stdout, stderr })
84}
85
86/// Represents the errors that can occur during command execution.
87/// # Variants
88/// - `UnsuccessfulTermination { status, stdout, stderr }`: /// - `StdoutNotUtf8(std::string::FromUtf8Error)`: /// - `StderrNotUtf8(std::string::FromUtf8Error)`: Indicates that the `stderr` output could not be
89///   decoded as valid UTF-8.
90#[derive(Debug)]
91pub enum Error {
92    /// Input/output error encountered during command execution.
93    Io(std::io::Error),
94
95    /// Indicates that the command terminated unsuccessfully.
96    ///
97    /// Includes the exit status, and the raw captured `stdout` and `stderr`
98    /// output byte vectors.
99    UnsuccessfulTermination {
100        /// Command exit status.
101        status: std::process::ExitStatus,
102
103        /// Raw captured stdout byte vector.
104        stdout: Vec<u8>,
105
106        /// Raw captured stderr byte vector.
107        stderr: Vec<u8>,
108    },
109
110    /// Indicates that the `stdout` output could not be decoded as valid UTF-8.
111    StdoutNotUtf8(std::string::FromUtf8Error),
112
113    /// Indicates that the `stderr` output could not be decoded as valid UTF-8.
114    StderrNotUtf8(std::string::FromUtf8Error),
115}
116
117impl std::error::Error for Error {
118    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
119        match self {
120            Error::Io(src) => Some(src),
121            Error::UnsuccessfulTermination { .. } => None,
122            Error::StdoutNotUtf8(src) => Some(src),
123            Error::StderrNotUtf8(src) => Some(src),
124        }
125    }
126}
127
128impl From<std::io::Error> for Error {
129    fn from(err: std::io::Error) -> Self {
130        Error::Io(err)
131    }
132}
133
134impl std::fmt::Display for Error {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Error::Io(_) => write!(f, "IO error"),
138            Error::UnsuccessfulTermination { status, stdout: _, stderr } => {
139                let stderr = String::from_utf8_lossy(stderr);
140                let mut stderr_tail = stderr.lines().rev().take(10).collect::<Vec<_>>();
141                stderr_tail.reverse();
142                writeln!(f, "Unsuccessful termination with exit status {status} and stderr (last 10 lines):")?;
143                writeln!(f, "{}", stderr_tail.join("\n"))?;
144                Ok(())
145            }
146            Error::StdoutNotUtf8(_) => write!(f, "stdout is not valid UTF-8"),
147            Error::StderrNotUtf8(_) => write!(f, "stderr is not valid UTF-8"),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::os::unix::process::ExitStatusExt;
156
157    /// Helper function to generate fake command execution output with a specified number of lines.
158    fn generate_lines(prefix: &str, lines: usize) -> Vec<u8> {
159        (1..=lines).map(|i| format!("{prefix} line {}\n", i)).collect::<String>().into_bytes()
160    }
161
162    #[test]
163    fn test_unsuccessful_termination_full_stderr() {
164        // Simulate a command with 12 lines of stderr output
165        let stderr = generate_lines("stderr", 12);
166        let error = Error::UnsuccessfulTermination {
167            status: std::process::ExitStatus::from_raw(1),
168            stdout: Vec::new(),
169            stderr: stderr.clone(),
170        };
171
172        let error_message = error.to_string();
173
174        let expected = indoc::indoc! {r#"
175            Unsuccessful termination with exit status signal: 1 (SIGHUP) and stderr (last 10 lines):
176            stderr line 3
177            stderr line 4
178            stderr line 5
179            stderr line 6
180            stderr line 7
181            stderr line 8
182            stderr line 9
183            stderr line 10
184            stderr line 11
185            stderr line 12
186        "#};
187        assert_eq!(error_message, expected);
188    }
189
190    #[test]
191    fn test_unsuccessful_termination_short_stderr() {
192        // Simulate a command with 4 lines of stderr output
193        let stderr = generate_lines("stderr", 4);
194        let error = Error::UnsuccessfulTermination {
195            status: std::process::ExitStatus::from_raw(1),
196            stdout: Vec::new(),
197            stderr: stderr.clone(),
198        };
199
200        let error_message = error.to_string();
201
202        let expected = indoc::indoc! {r#"
203            Unsuccessful termination with exit status signal: 1 (SIGHUP) and stderr (last 10 lines):
204            stderr line 1
205            stderr line 2
206            stderr line 3
207            stderr line 4
208        "#};
209        assert_eq!(error_message, expected);
210    }
211}