just-run 0.1.0

Convenience crate for executing system commands with the expectation of successful termination and UTF-8 encoded output, for basic straightforward command execution scenarios.
Documentation
//! # just-run
//!
//! `just-run` is a simple convenience crate for executing system commands with the expectation
//! of successful termination and UTF-8 encoded output. It aims to handle the most common case without
//! extensive configuration or edge case coverage.
//!
//! ## Usage
//! ```rust
//! use just_run::{run, Success};
//!
//! let Success { stdout, stderr } = run("echo", ["Hello world!"]).expect("Command failed");
//! println!("{stdout}");
//! ```
//!
//! For more advanced use cases, consider checking out the [`duct`](https://crates.io/crates/duct) crate or
//! using the standard library tools directly.
//!
//! ## Future
//! This crate focuses on convenience. It might expand to include async command execution or other
//! features in the future, but it is not meant to cover all use cases.

#![cfg_attr(docsrs, feature(doc_auto_cfg))]

/// Represents the successful UTF-8 encoded output of a command execution.
///
/// This struct is generally created by calling [`run`]. Please see the documentation of [`run`] for more details.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Success {
    /// The standard output generated by the command.
    pub stdout: String,

    /// The standard error output generated by the command.
    pub stderr: String,
}

/// Executes a system command with the specified arguments.
///
/// Executes the command as a child process, waiting for it to finish and
/// collecting all of its output.
///
/// stdout and stderr are captured (and used to provide the resulting output).
/// Stdin is not inherited from the parent and any attempt by the child process
/// to read from the stdin stream will result in the stream immediately closing.
///
/// # Returns
/// Returns the stdout and stderr output of the command if it terminates successfully
/// and the output could be decoded as UTF-8.
///
/// # Example
/// ```rust
/// use just_run::{run, Success};
///
/// let Success { stdout, stderr } = match run("echo", ["Hello, World!"]) {
///     Ok(success) => success,
///     Err(err) => panic!("Error: {err}"),
/// };
/// ```
///
/// This function is intended for straightforward command execution scenarios. For
/// more complex use cases or advanced features, consider using alternative crates
/// or standard library functionality.
pub fn run<Prog, Args, Arg>(program: Prog, args: Args) -> Result<Success, Error>
where
    Prog: AsRef<std::ffi::OsStr>,
    Args: IntoIterator<Item = Arg>,
    Arg: AsRef<std::ffi::OsStr>,
{
    let mut command = std::process::Command::new(program);
    command.args(args);

    execute(&mut command)
}

fn execute(cmd: &mut std::process::Command) -> Result<Success, Error> {
    let std::process::Output { status, stdout, stderr } = cmd.output()?;
    if !status.success() {
        return Err(Error::UnsuccessfulTermination { status, stdout, stderr });
    }

    let stdout = String::from_utf8(stdout).map_err(Error::StdoutNotUtf8)?;
    let stderr = String::from_utf8(stderr).map_err(Error::StderrNotUtf8)?;

    Ok(Success { stdout, stderr })
}

/// Represents the errors that can occur during command execution.
/// # Variants
/// - `UnsuccessfulTermination { status, stdout, stderr }`: /// - `StdoutNotUtf8(std::string::FromUtf8Error)`: /// - `StderrNotUtf8(std::string::FromUtf8Error)`: Indicates that the `stderr` output could not be
///   decoded as valid UTF-8.
#[derive(Debug)]
pub enum Error {
    /// Input/output error encountered during command execution.
    Io(std::io::Error),

    /// Indicates that the command terminated unsuccessfully.
    ///
    /// Includes the exit status, and the raw captured `stdout` and `stderr`
    /// output byte vectors.
    UnsuccessfulTermination {
        /// Command exit status.
        status: std::process::ExitStatus,

        /// Raw captured stdout byte vector.
        stdout: Vec<u8>,

        /// Raw captured stderr byte vector.
        stderr: Vec<u8>,
    },

    /// Indicates that the `stdout` output could not be decoded as valid UTF-8.
    StdoutNotUtf8(std::string::FromUtf8Error),

    /// Indicates that the `stderr` output could not be decoded as valid UTF-8.
    StderrNotUtf8(std::string::FromUtf8Error),
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Error::Io(src) => Some(src),
            Error::UnsuccessfulTermination { .. } => None,
            Error::StdoutNotUtf8(src) => Some(src),
            Error::StderrNotUtf8(src) => Some(src),
        }
    }
}

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::Io(err)
    }
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::Io(_) => write!(f, "IO error"),
            Error::UnsuccessfulTermination { status, stdout: _, stderr } => {
                let stderr = String::from_utf8_lossy(stderr);
                let mut stderr_tail = stderr.lines().rev().take(10).collect::<Vec<_>>();
                stderr_tail.reverse();
                writeln!(f, "Unsuccessful termination with exit status {status} and stderr (last 10 lines):")?;
                writeln!(f, "{}", stderr_tail.join("\n"))?;
                Ok(())
            }
            Error::StdoutNotUtf8(_) => write!(f, "stdout is not valid UTF-8"),
            Error::StderrNotUtf8(_) => write!(f, "stderr is not valid UTF-8"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::process::ExitStatusExt;

    /// Helper function to generate fake command execution output with a specified number of lines.
    fn generate_lines(prefix: &str, lines: usize) -> Vec<u8> {
        (1..=lines).map(|i| format!("{prefix} line {}\n", i)).collect::<String>().into_bytes()
    }

    #[test]
    fn test_unsuccessful_termination_full_stderr() {
        // Simulate a command with 12 lines of stderr output
        let stderr = generate_lines("stderr", 12);
        let error = Error::UnsuccessfulTermination {
            status: std::process::ExitStatus::from_raw(1),
            stdout: Vec::new(),
            stderr: stderr.clone(),
        };

        let error_message = error.to_string();

        let expected = indoc::indoc! {r#"
            Unsuccessful termination with exit status signal: 1 (SIGHUP) and stderr (last 10 lines):
            stderr line 3
            stderr line 4
            stderr line 5
            stderr line 6
            stderr line 7
            stderr line 8
            stderr line 9
            stderr line 10
            stderr line 11
            stderr line 12
        "#};
        assert_eq!(error_message, expected);
    }

    #[test]
    fn test_unsuccessful_termination_short_stderr() {
        // Simulate a command with 4 lines of stderr output
        let stderr = generate_lines("stderr", 4);
        let error = Error::UnsuccessfulTermination {
            status: std::process::ExitStatus::from_raw(1),
            stdout: Vec::new(),
            stderr: stderr.clone(),
        };

        let error_message = error.to_string();

        let expected = indoc::indoc! {r#"
            Unsuccessful termination with exit status signal: 1 (SIGHUP) and stderr (last 10 lines):
            stderr line 1
            stderr line 2
            stderr line 3
            stderr line 4
        "#};
        assert_eq!(error_message, expected);
    }
}