From 8f499033c0bcaf0a64dd212eba83589066db1bac Mon Sep 17 00:00:00 2001 From: schneems Date: Tue, 31 Oct 2023 17:35:31 -0500 Subject: [PATCH] First --- .github/workflows/ci.yml | 43 +++ .gitignore | 2 + CHANGELOG.md | 5 + Cargo.toml | 14 + LICENSE | 8 + README.md | 104 ++++++ src/command.rs | 107 ++++++ src/lib.rs | 682 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 965 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/command.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc2aa92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + # Avoid duplicate builds on PRs. + branches: + - main + pull_request: + +jobs: + lint: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Update Rust toolchain + # Most of the time this will be a no-op, since GitHub releases new images every week + # which include the latest stable release of Rust, Rustup, Clippy and rustfmt. + run: rustup update + - name: Rust Cache + uses: Swatinem/rust-cache@v2.7.1 + - name: Clippy + # Using --all-targets so tests are checked and --deny to fail on warnings. + # Not using --locked here and below since Cargo.lock is in .gitignore. + run: cargo clippy --all-targets --all-features -- --deny warnings + - name: rustfmt + run: cargo fmt -- --check + - name: Check docs + # Using RUSTDOCFLAGS until `cargo doc --check` is stabilised: + # https://github.com/rust-lang/cargo/issues/10025 + run: RUSTDOCFLAGS="-D warnings" cargo doc --all-features --document-private-items --no-deps + + unit-test: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Update Rust toolchain + run: rustup update + - name: Rust Cache + uses: Swatinem/rust-cache@v2.7.1 + - name: Run unit tests + run: cargo test --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1e74529 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## Unreleased + +## 0.1.0 + +- First release diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..490b791 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fun_run" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lazy_static = "1" +which_problem = { version = "0.1", optional = true } +regex = "1" + +[features] +which_problem = ["dep:which_problem"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3e2e4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2023 Richard Schneeman + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca16489 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Fun Run + +What does the "Zombie Zoom 5K", the "Wibbly wobbly log jog", and the "Turkey Trot" have in common? They're runs with a fun name! That's exactly what `fun_run` does. It makes running your Rust `Command`s more fun, by naming them. + +## What is Fun Run? + +Fun run is designed for the use case where not only do you want to run a `Command` you want to output what you're running and what happened. Building a CLI tool is a great use case. Another is creating [a buildpack](https://github.com/heroku/buildpacks-ruby/tree/4f514f6046568ada523eefd41b3024f86f1c67ce). + +Here's some things you can do with fun_run: + +- Advertise the command being run before execution +- Customize how commands are displayed +- Return error messages with the command name. +- Turn non-zero status results into an error +- Embed stdout and stderr into errors (when not streamed) +- Store stdout and stderr for debug and diagnosis without displaying them (when streamed) + +Just like you don't need to dress up in a giant turkey costume to run a 5K you also don't **need** `fun_run` to do these things. Though, unlike the turkey costume, using `fun_run` will also make the experience easier. + +## Ready to Roll + +For a quick and easy fun run you can use the `fun_run::CommandWithName` trait extension to stream output: + +```no_run +use fun_run::CommandWithName; +use std::process::Command; + +let mut cmd = Command::new("bundle"); +cmd.args(["install"]); + +// Advertise the command being run before execution +println!("Running `{name}`", name = cmd.name()); + +// Stream output to the end user +// Turn non-zero status results into an error +let result = cmd + .stream_output(std::io::stdout(), std::io::stderr()); + +// Command name is persisted on success or failure +match result { + Ok(output) => { + assert_eq!("bundle install", &output.name()) + }, + Err(cmd_error) => { + assert_eq!("bundle install", &cmd_error.name()) + } +} +``` + +Or capture output without streaming: + +```no_run +use fun_run::CommandWithName; +use std::process::Command; + +let mut cmd = Command::new("bundle"); +cmd.args(["install"]); + +// Advertise the command being run before execution +println!("Quietly Running `{name}`", name = cmd.name()); + +// Don't stream +// Turn non-zero status results into an error +let result = cmd.named_output(); + +// Command name is persisted on success or failure +match result { + Ok(output) => { + assert_eq!("bundle install", &output.name()) + }, + Err(cmd_error) => { + assert_eq!("bundle install", &cmd_error.name()) + } +} +``` + +The `fun_run` library doesn't support executing a `Command` in ways that do not produce an `Output`, for example calling `Command::spawn` returns a `Result` (Which doesn't contain an `Output`). If you want to run for fun in the background, spawn a thread and join it manually: + +```no_run +use fun_run::CommandWithName; +use std::process::Command; +use std::thread; + + +let mut cmd = Command::new("bundle"); +cmd.args(["install"]); + +// Advertise the command being run before execution +println!("Quietly Running `{name}` in the background", name = cmd.name()); + +let result = thread::spawn(move || { + cmd.named_output() +}).join().unwrap(); + +// Command name is persisted on success or failure +match result { + Ok(output) => { + assert_eq!("bundle install", &output.name()) + }, + Err(cmd_error) => { + assert_eq!("bundle install", &cmd_error.name()) + } +} +``` diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..7946949 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,107 @@ +use std::io::Write; +use std::process::Command; +use std::{io, process, thread}; +use std::{mem, panic}; + +pub(crate) fn output_and_write_streams( + command: &mut Command, + stdout_write: OW, + stderr_write: EW, +) -> io::Result { + let mut stdout_buffer = Vec::new(); + let mut stderr_buffer = Vec::new(); + + let mut stdout = tee(&mut stdout_buffer, stdout_write); + let mut stderr = tee(&mut stderr_buffer, stderr_write); + + let mut child = command + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .spawn()?; + + thread::scope(|scope| { + let stdout_thread = mem::take(&mut child.stdout).map(|mut child_stdout| { + scope.spawn(move || std::io::copy(&mut child_stdout, &mut stdout)) + }); + let stderr_thread = mem::take(&mut child.stdout).map(|mut child_stderr| { + scope.spawn(move || std::io::copy(&mut child_stderr, &mut stderr)) + }); + + stdout_thread + .map_or_else( + || Ok(0), + |handle| match handle.join() { + Ok(value) => value, + Err(err) => panic::resume_unwind(err), + }, + ) + .and({ + stderr_thread.map_or_else( + || Ok(0), + |handle| match handle.join() { + Ok(value) => value, + Err(err) => panic::resume_unwind(err), + }, + ) + }) + .and_then(|_| child.wait()) + }) + .map(|status| process::Output { + status, + stdout: stdout_buffer, + stderr: stderr_buffer, + }) +} + +#[cfg(test)] +mod test { + use super::*; + use std::process::Command; + + #[test] + #[cfg(unix)] + fn test_output_and_write_streams() { + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + + let mut cmd = Command::new("echo"); + cmd.args(["-n", "Hello World!"]); + + let output = output_and_write_streams(&mut cmd, &mut stdout_buf, &mut stderr_buf).unwrap(); + + assert_eq!(stdout_buf, "Hello World!".as_bytes()); + assert_eq!(stderr_buf, Vec::::new()); + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, "Hello World!".as_bytes()); + assert_eq!(output.stderr, Vec::::new()); + } +} + +/// Constructs a writer that writes to two other writers. Similar to the UNIX `tee` command. +pub(crate) fn tee(a: A, b: B) -> TeeWrite { + TeeWrite { + inner_a: a, + inner_b: b, + } +} + +/// A tee writer that was created with the [`tee`] function. +#[derive(Debug, Clone)] +pub(crate) struct TeeWrite { + inner_a: A, + inner_b: B, +} + +impl io::Write for TeeWrite { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner_a.write_all(buf)?; + self.inner_b.write_all(buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner_a.flush()?; + self.inner_b.flush() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8a3f04d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,682 @@ +use command::output_and_write_streams; +use lazy_static::lazy_static; +use std::ffi::OsString; +use std::fmt::Display; +use std::io::Write; +use std::os::unix::process::ExitStatusExt; +use std::process::Command; +use std::process::ExitStatus; +use std::process::Output; +#[cfg(feature = "which_problem")] +use which_problem::Which; + +mod command; + +/// For a quick and easy fun run you can use the `fun_run::CommandWithName` trait extension to stream output: +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use std::process::Command; +/// +/// let mut cmd = Command::new("bundle"); +/// cmd.args(["install"]); +/// +/// // Advertise the command being run before execution +/// println!("Running `{name}`", name = cmd.name()); +/// +/// // Stream output to the end user +/// // Turn non-zero status results into an error +/// let result = cmd +/// .stream_output(std::io::stdout(), std::io::stderr()); +/// +/// // Command name is persisted on success or failure +/// match result { +/// Ok(output) => { +/// assert_eq!("bundle install", &output.name()) +/// }, +/// Err(cmd_error) => { +/// assert_eq!("bundle install", &cmd_error.name()) +/// } +/// } +/// ``` +/// +/// Or capture output without streaming: +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use std::process::Command; +/// +/// let mut cmd = Command::new("bundle"); +/// cmd.args(["install"]); +/// +/// // Advertise the command being run before execution +/// println!("Quietly Running `{name}`", name = cmd.name()); +/// +/// // Don't stream +/// // Turn non-zero status results into an error +/// let result = cmd.named_output(); +/// +/// // Command name is persisted on success or failure +/// match result { +/// Ok(output) => { +/// assert_eq!("bundle install", &output.name()) +/// }, +/// Err(cmd_error) => { +/// assert_eq!("bundle install", &cmd_error.name()) +/// } +/// } +/// ``` +/// +/// The `fun_run` library doesn't support executing a `Command` in ways that do not produce an `Output`, for example calling `Command::spawn` returns a `Result` (Which doesn't contain an `Output`). If you want to run for fun in the background, spawn a thread and join it manually: +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use std::process::Command; +/// use std::thread; +/// +/// +/// let mut cmd = Command::new("bundle"); +/// cmd.args(["install"]); +/// +/// // Advertise the command being run before execution +/// println!("Quietly Running `{name}` in the background", name = cmd.name()); +/// +/// let result = thread::spawn(move || { +/// cmd.named_output() +/// }).join().unwrap(); +/// +/// // Command name is persisted on success or failure +/// match result { +/// Ok(output) => { +/// assert_eq!("bundle install", &output.name()) +/// }, +/// Err(cmd_error) => { +/// assert_eq!("bundle install", &cmd_error.name()) +/// } +/// } +/// ``` +/// +/// Rename your commands: +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use std::process::Command; +/// +/// let result = Command::new("gem") +/// .args(["install", "bundler", "-v", "2.4.1.7"]) +/// // Overwrites default command name which would include extra arguments +/// .named("gem install") +/// .stream_output(std::io::stdout(), std::io::stderr()); +/// +/// match result { +/// Ok(output) => { +/// assert_eq!("bundle install", &output.name()) +/// }, +/// Err(varient) => { +/// assert_eq!("bundle install", &varient.name()) +/// } +/// } +/// ``` +/// +/// Or include important env vars in the name: +/// +/// ```no_run +/// use fun_run::{self, CommandWithName}; +/// use std::process::Command; +/// use std::collections::HashMap; +/// +/// let env = std::env::vars_os().collect::>(); +/// +/// let result = Command::new("gem") +/// .args(["install", "bundler", "-v", "2.4.1.7"]) +/// .envs(&env) +/// // Overwrites default command name +/// .named_fn(|cmd| { +/// // Annotate command with GEM_HOME env var +/// fun_run::display_with_env_keys(cmd, &env, ["GEM_HOME"]) +/// }) +/// .stream_output(std::io::stdout(), std::io::stderr()); +/// +/// match result { +/// Ok(output) => { +/// assert_eq!( +/// "GEM_HOME=\"/usr/bin/local/.gems\" gem install bundler -v 2.4.1.7", +/// &output.name() +/// ) +/// } +/// Err(varient) => { +/// assert_eq!( +/// "GEM_HOME=\"/usr/bin/local/.gems\" gem install bundler -v 2.4.1.7", +/// &varient.name() +/// ) +/// } +/// } +/// ``` + +pub trait CommandWithName { + fn name(&mut self) -> String; + fn mut_cmd(&mut self) -> &mut Command; + + fn named(&mut self, s: impl AsRef) -> NamedCommand<'_> { + let name = s.as_ref().to_string(); + let command = self.mut_cmd(); + NamedCommand { name, command } + } + + #[allow(clippy::needless_lifetimes)] + fn named_fn<'a>(&'a mut self, f: impl FnOnce(&mut Command) -> String) -> NamedCommand<'a> { + let cmd = self.mut_cmd(); + let name = f(cmd); + self.named(name) + } + + /// Runs the command without streaming + /// + /// # Errors + /// + /// Returns `CmdError::SystemError` if the system is unable to run the command. + /// Returns `CmdError::NonZeroExitNotStreamed` if the exit code is not zero. + fn named_output(&mut self) -> Result { + let name = self.name(); + self.mut_cmd() + .output() + .map_err(|io_error| CmdError::SystemError(name.clone(), io_error)) + .map(|output| NamedOutput { + name: name.clone(), + output, + }) + .and_then(NamedOutput::nonzero_captured) + } + + /// Runs the command and streams to the given writers + /// + /// # Errors + /// + /// Returns `CmdError::SystemError` if the system is unable to run the command + /// Returns `CmdError::NonZeroExitAlreadyStreamed` if the exit code is not zero. + fn stream_output( + &mut self, + stdout_write: OW, + stderr_write: EW, + ) -> Result + where + OW: Write + Send, + EW: Write + Send, + { + let name = &self.name(); + let cmd = self.mut_cmd(); + + output_and_write_streams(cmd, stdout_write, stderr_write) + .map_err(|io_error| CmdError::SystemError(name.clone(), io_error)) + .map(|output| NamedOutput { + name: name.clone(), + output, + }) + .and_then(NamedOutput::nonzero_streamed) + } +} + +impl CommandWithName for Command { + fn name(&mut self) -> String { + crate::display(self) + } + + fn mut_cmd(&mut self) -> &mut Command { + self + } +} + +/// It's a command, with a name +pub struct NamedCommand<'a> { + name: String, + command: &'a mut Command, +} + +impl CommandWithName for NamedCommand<'_> { + fn name(&mut self) -> String { + self.name.to_string() + } + + fn mut_cmd(&mut self) -> &mut Command { + self.command + } +} + +/// Holds a the `Output` of a command's execution along with it's "name" +/// +/// When paired with `CmdError` a `Result` will retain the +/// "name" of the command regardless of succss or failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamedOutput { + name: String, + output: Output, +} + +impl NamedOutput { + /// # Errors + /// + /// Returns an error if the status is not zero + pub fn nonzero_captured(self) -> Result { + nonzero_captured(self.name, self.output) + } + + /// # Errors + /// + /// Returns an error if the status is not zero + pub fn nonzero_streamed(self) -> Result { + nonzero_streamed(self.name, self.output) + } + + #[must_use] + pub fn status(&self) -> &ExitStatus { + &self.output.status + } + + #[must_use] + pub fn stdout_lossy(&self) -> String { + String::from_utf8_lossy(&self.output.stdout).to_string() + } + + #[must_use] + pub fn stderr_lossy(&self) -> String { + String::from_utf8_lossy(&self.output.stderr).to_string() + } + + #[must_use] + pub fn name(&self) -> String { + self.name.clone() + } +} + +impl AsRef for NamedOutput { + fn as_ref(&self) -> &Output { + &self.output + } +} + +impl From for Output { + fn from(value: NamedOutput) -> Self { + value.output + } +} + +lazy_static! { + // https://github.com/jimmycuadra/rust-shellwords/blob/d23b853a850ceec358a4137d5e520b067ddb7abc/src/lib.rs#L23 + static ref QUOTE_ARG_RE: regex::Regex = regex::Regex::new(r"([^A-Za-z0-9_\-.,:/@\n])").expect("Internal error:"); +} + +/// Converts a command and its arguments into a user readable string +/// +/// Example +/// +/// ```rust +/// use std::process::Command; +/// use fun_run; +/// +/// let name = fun_run::display(Command::new("bundle").arg("install")); +/// assert_eq!(String::from("bundle install"), name); +/// ``` +#[must_use] +pub fn display(command: &mut Command) -> String { + vec![command.get_program().to_string_lossy().to_string()] + .into_iter() + .chain( + command + .get_args() + .map(std::ffi::OsStr::to_string_lossy) + .map(|arg| { + if QUOTE_ARG_RE.is_match(&arg) { + format!("{arg:?}") + } else { + format!("{arg}") + } + }), + ) + .collect::>() + .join(" ") +} + +/// Converts a command, arguments, and specified environment variables to user readable string +/// +/// Example +/// +/// ```rust +/// use std::process::Command; +/// use fun_run; +/// use std::collections::HashMap; +/// +/// let mut env = std::env::vars().collect::>(); +/// env.insert("RAILS_ENV".to_string(), "production".to_string()); +/// +/// let mut command = Command::new("bundle"); +/// command.arg("install").envs(&env); +/// +/// let name = fun_run::display_with_env_keys(&mut command, &env, ["RAILS_ENV"]); +/// assert_eq!(String::from(r#"RAILS_ENV="production" bundle install"#), name); +/// ``` +#[must_use] +pub fn display_with_env_keys(cmd: &mut Command, env: E, keys: I) -> String +where + E: IntoIterator, + K: Into, + V: Into, + I: IntoIterator, + O: Into, +{ + let env = env + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect::>(); + + keys.into_iter() + .map(|key| { + let key = key.into(); + format!( + "{}={:?}", + key.to_string_lossy(), + env.get(&key).cloned().unwrap_or_else(|| OsString::from("")) + ) + }) + .chain([display(cmd)]) + .collect::>() + .join(" ") +} + +/// Who says (`Command`) errors can't be fun? +/// +/// Fun run errors include all the info a user needs to debug, like +/// the name of the command that failed and any outputs (like error messages +/// in stderr). +/// +/// Fun run errors don't overwhelm end users, so by default if stderr is already +/// streamed the output won't be duplicated. +/// +/// Enjoy if you want, skip if you don't. Fun run errors are not mandatory. +/// +/// Error output formatting is unstable +#[derive(Debug)] +#[allow(clippy::module_name_repetitions)] +pub enum CmdError { + SystemError(String, std::io::Error), + + NonZeroExitNotStreamed(NamedOutput), + + NonZeroExitAlreadyStreamed(NamedOutput), +} + +impl Display for CmdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CmdError::SystemError(name, error) => { + write!(f, "Could not run command `{name}`. {error}") + } + CmdError::NonZeroExitNotStreamed(named_output) => { + let stdout = display_out_or_empty(&named_output.output.stdout); + let stderr = display_out_or_empty(&named_output.output.stderr); + + writeln!(f, "Command failed `{name}`", name = named_output.name())?; + writeln!( + f, + "exit status: {status}", + status = named_output.output.status.code().unwrap_or(1) + )?; + writeln!(f, "stdout: {stdout}",)?; + write!(f, "stderr: {stderr}",) + } + CmdError::NonZeroExitAlreadyStreamed(named_output) => { + writeln!(f, "Command failed `{name}`", name = named_output.name())?; + writeln!( + f, + "exit status: {status}", + status = named_output.output.status.code().unwrap_or(1) + )?; + writeln!(f, "stdout: ")?; + write!(f, "stderr: ") + } + } + } +} + +impl CmdError { + /// Returns a display representation of the command that failed + /// + /// Example: + /// + /// ```no_run + /// use fun_run::CommandWithName; + /// use std::process::Command; + /// + /// let result = Command::new("cat") + /// .arg("mouse.txt") + /// .named_output(); + /// + /// match result { + /// Ok(_) => unimplemented!(), + /// Err(error) => assert_eq!(error.name().to_string(), "cat mouse.txt") + /// } + /// ``` + #[must_use] + pub fn name(&self) -> std::borrow::Cow<'_, str> { + match self { + CmdError::SystemError(name, _) => name.into(), + CmdError::NonZeroExitNotStreamed(out) | CmdError::NonZeroExitAlreadyStreamed(out) => { + out.name.as_str().into() + } + } + } +} + +impl From for NamedOutput { + fn from(value: CmdError) -> Self { + match value { + CmdError::SystemError(name, error) => NamedOutput { + name, + output: Output { + status: ExitStatus::from_raw(error.raw_os_error().unwrap_or(-1)), + stdout: Vec::new(), + stderr: error.to_string().into_bytes(), + }, + }, + CmdError::NonZeroExitNotStreamed(named) + | CmdError::NonZeroExitAlreadyStreamed(named) => named, + } + } +} + +fn display_out_or_empty(contents: &[u8]) -> String { + let contents = String::from_utf8_lossy(contents); + if contents.trim().is_empty() { + "".to_string() + } else { + contents.to_string() + } +} + +/// Converts a `std::io::Error` into a `CmdError` which includes the formatted command name +#[must_use] +pub fn on_system_error(name: String, error: std::io::Error) -> CmdError { + CmdError::SystemError(name, error) +} + +/// Converts an `Output` into an error when status is non-zero +/// +/// When calling a `Command` and streaming the output to stdout/stderr +/// it can be jarring to have the contents emitted again in the error. When this +/// error is displayed those outputs will not be repeated. +/// +/// Use when the `Output` comes from a source that was already streamed. +/// +/// To to include the results of stdout/stderr in the display of the error +/// use `nonzero_captured` instead. +/// +/// # Errors +/// +/// Returns Err when the `Output` status is non-zero +pub fn nonzero_streamed(name: String, output: impl Into) -> Result { + let output = output.into(); + if output.status.success() { + Ok(NamedOutput { name, output }) + } else { + Err(CmdError::NonZeroExitAlreadyStreamed(NamedOutput { + name, + output, + })) + } +} + +/// Converts an `Output` into an error when status is non-zero +/// +/// Use when the `Output` comes from a source that was not streamed +/// to stdout/stderr so it will be included in the error display by default. +/// +/// To avoid double printing stdout/stderr when streaming use `nonzero_streamed` +/// +/// # Errors +/// +/// Returns Err when the `Output` status is non-zero +pub fn nonzero_captured(name: String, output: impl Into) -> Result { + let output = output.into(); + if output.status.success() { + Ok(NamedOutput { name, output }) + } else { + Err(CmdError::NonZeroExitNotStreamed(NamedOutput { + name, + output, + })) + } +} + +/// Adds diagnostic information to a `CmdError` using `which_problem` if it is a `CmdError::SystemError` +/// +/// A `CmdError::SystemError` means that the command could not be run (different than, it ran but +/// emitted an error). When that happens it usually means that either there's a typo in the command +/// program name, or there's an error with the system. For example if the PATH is empty, then the +/// OS will be be unable to find and run the executable. +/// +/// To make this type of system debugging easier the `which_problem` crate simulates the logic of +/// `which ` but emits detailed diagnostic information about the system including +/// things like missing or broken symlinks, invalid permissions, directories on the PATH that are +/// empty etc. +/// +/// It's best used as a diagnostic for developers for why a CmdError::SytemError might have occured. +/// For example, if the programmer executed the command with an empty PATH, this debugging tool +/// would help them find and fix the (otherwise) tedius to debug problem. +/// +/// Using this feature may leak sensitive information about the system if the input is untrusted so +/// consider who has access to inputs, and who will view the outputs. +/// +/// See the `which_problem` crate for more details. +/// +/// This feature is experimental and may change in the future. +/// +/// ```no_run +/// use fun_run::{self, CommandWithName}; +/// use std::process::Command; +/// +/// let mut cmd = Command::new("bundle"); +/// cmd.arg("install"); +/// cmd.named_output().map_err(|error| { +/// fun_run::map_which_problem(error, cmd.mut_cmd(), std::env::var_os("PATH")) +/// }).unwrap(); +/// ```` +#[cfg(feature = "which_problem")] +pub fn map_which_problem( + error: CmdError, + cmd: &mut Command, + path_env: Option, +) -> CmdError { + match error { + CmdError::SystemError(name, error) => { + CmdError::SystemError(name, annotate_which_problem(error, cmd, path_env)) + } + CmdError::NonZeroExitNotStreamed(_) | CmdError::NonZeroExitAlreadyStreamed(_) => error, + } +} + +/// Adds diagnostic information to an `std::io::Error` using `which_problem` +/// +/// This feature is experimental +#[must_use] +#[cfg(feature = "which_problem")] +fn annotate_which_problem( + error: std::io::Error, + cmd: &mut Command, + path_env: Option, +) -> std::io::Error { + let program = cmd.get_program().to_os_string(); + let current_working_dir = cmd.get_current_dir().map(std::path::Path::to_path_buf); + let problem = Which { + cwd: current_working_dir, + program, + path_env, + ..Which::default() + } + .diagnose(); + + let annotation = match problem { + Ok(details) => format!("\nSystem diagnostic information:\n\n{details}"), + Err(error) => format!("\nInternal error while gathering dianostic information:\n\n{error}"), + }; + + annotate_io_error(error, annotation) +} + +/// Returns an IO error that displays the given annotation starting on +/// the next line. +/// +/// Internal API used by `annotate_which_problem` +#[must_use] +#[cfg(feature = "which_problem")] +fn annotate_io_error(source: std::io::Error, annotation: String) -> std::io::Error { + IoErrorAnnotation::new(source, annotation).into_io_error() +} + +#[derive(Debug)] +#[cfg(feature = "which_problem")] +pub(crate) struct IoErrorAnnotation { + source: std::io::Error, + annotation: String, +} + +#[cfg(feature = "which_problem")] +impl IoErrorAnnotation { + pub(crate) fn new(source: std::io::Error, annotation: String) -> Self { + Self { source, annotation } + } + + pub(crate) fn into_io_error(self) -> std::io::Error { + std::io::Error::new(self.source.kind(), self) + } +} + +#[cfg(feature = "which_problem")] +impl std::fmt::Display for IoErrorAnnotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}", self.source)?; + f.write_str(&self.annotation)?; + Ok(()) + } +} + +#[cfg(feature = "which_problem")] +impl std::error::Error for IoErrorAnnotation { + fn cause(&self) -> Option<&dyn std::error::Error> { + self.source() + } + + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +#[cfg(doctest)] +mod test_readme { + macro_rules! external_doc_test { + ($x:expr) => { + #[doc = $x] + extern "C" {} + }; + } + + external_doc_test!(include_str!("../README.md")); +}