//! Utilities for running commands and providing user-friendly error messages. use std::fmt::Display; use std::process::Child; use std::process::{Command, ExitStatus, Output}; use color_eyre::eyre; use color_eyre::eyre::eyre; use color_eyre::eyre::Context; use crate::error::TopgradeError; use tracing::debug; /// Like [`Output`], but UTF-8 decoded. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Utf8Output { pub status: ExitStatus, pub stdout: String, pub stderr: String, } impl TryFrom for Utf8Output { type Error = eyre::Error; fn try_from(Output { status, stdout, stderr }: Output) -> Result { let stdout = String::from_utf8(stdout).map_err(|err| { eyre!( "Stdout contained invalid UTF-8: {}", String::from_utf8_lossy(err.as_bytes()) ) })?; let stderr = String::from_utf8(stderr).map_err(|err| { eyre!( "Stderr contained invalid UTF-8: {}", String::from_utf8_lossy(err.as_bytes()) ) })?; Ok(Utf8Output { status, stdout, stderr }) } } impl TryFrom<&Output> for Utf8Output { type Error = eyre::Error; fn try_from(Output { status, stdout, stderr }: &Output) -> Result { let stdout = String::from_utf8(stdout.clone()).map_err(|err| { eyre!( "Stdout contained invalid UTF-8: {}", String::from_utf8_lossy(err.as_bytes()) ) })?; let stderr = String::from_utf8(stderr.clone()).map_err(|err| { eyre!( "Stderr contained invalid UTF-8: {}", String::from_utf8_lossy(err.as_bytes()) ) })?; let status = *status; Ok(Utf8Output { status, stdout, stderr }) } } impl Display for Utf8Output { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.stdout) } } /// Extension trait for [`Command`], adding helpers to gather output while checking the exit /// status. /// /// These also give us significantly better error messages, which include: /// /// 1. The command and arguments that were executed, escaped with familiar `sh` syntax. /// 2. The exit status of the command or the signal that killed it. /// 3. If we were capturing the output of the command, rather than forwarding it to the user's /// stdout/stderr, the error message includes the command's stdout and stderr output. /// /// Additionally, executing commands with these methods will log the command at debug-level, /// useful when gathering error reports. pub trait CommandExt { type Child; /// Like [`Command::output`], but checks the exit status and provides nice error messages. /// /// Returns an `Err` if the command failed to execute or returned a non-zero exit code. #[track_caller] fn output_checked(&mut self) -> eyre::Result { self.output_checked_with(|output: &Output| if output.status.success() { Ok(()) } else { Err(()) }) } /// Like [`output_checked`], but also decodes Stdout and Stderr as UTF-8. /// /// Returns an `Err` if the command failed to execute, returned a non-zero exit code, or if the /// output contains invalid UTF-8. #[track_caller] fn output_checked_utf8(&mut self) -> eyre::Result { let output = self.output_checked()?; output.try_into() } /// Like [`output_checked`] but a closure determines if the command failed instead of /// [`ExitStatus::success`]. /// /// Returns an `Err` if the command failed to execute or if `succeeded` returns an `Err`. /// (This lets the caller substitute their own notion of "success" instead of assuming /// non-zero exit codes indicate success.) #[track_caller] fn output_checked_with(&mut self, succeeded: impl Fn(&Output) -> Result<(), ()>) -> eyre::Result; /// Like [`output_checked_with`], but also decodes Stdout and Stderr as UTF-8. /// /// Returns an `Err` if the command failed to execute, if `succeeded` returns an `Err`, or if /// the output contains invalid UTF-8. // This function is currently unused, but is useful and makes sense with `output_checked_with` // and `output_checked_utf8` existing. #[allow(dead_code)] #[track_caller] fn output_checked_with_utf8( &mut self, succeeded: impl Fn(&Utf8Output) -> Result<(), ()>, ) -> eyre::Result { // This decodes the Stdout and Stderr as UTF-8 twice... let output = self.output_checked_with(|output| output.try_into().map_err(|_| ()).and_then(|o| succeeded(&o)))?; output.try_into() } /// Like [`Command::status`], but gives a nice error message if the status is unsuccessful /// rather than returning the [`ExitStatus`]. /// /// Returns `Ok` if the command executes successfully, returns `Err` if the command fails to /// execute or returns a non-zero exit code. #[track_caller] fn status_checked(&mut self) -> eyre::Result<()> { self.status_checked_with(|status| if status.success() { Ok(()) } else { Err(()) }) } /// Like [`status_checked`], but gives a nice error message if the status is unsuccessful /// rather than returning the [`ExitStatus`]. /// /// Returns `Ok` if the command executes successfully, returns `Err` if the command fails to /// execute or if `succeeded` returns an `Err`. /// (This lets the caller substitute their own notion of "success" instead of assuming /// non-zero exit codes indicate success.) #[track_caller] fn status_checked_with(&mut self, succeeded: impl Fn(ExitStatus) -> Result<(), ()>) -> eyre::Result<()>; /// Like [`Command::spawn`], but gives a nice error message if the command fails to /// execute. #[track_caller] #[allow(dead_code)] fn spawn_checked(&mut self) -> eyre::Result; } impl CommandExt for Command { type Child = Child; fn output_checked_with(&mut self, succeeded: impl Fn(&Output) -> Result<(), ()>) -> eyre::Result { let command = log(self); // This is where we implement `output_checked`, which is what we prefer to use instead of // `output`, so we allow `Command::output` here. #[allow(clippy::disallowed_methods)] let output = self .output() .with_context(|| format!("Failed to execute `{command}`"))?; if succeeded(&output).is_ok() { Ok(output) } else { let mut message = format!("Command failed: `{command}`"); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let stdout_trimmed = stdout.trim(); if !stdout_trimmed.is_empty() { message.push_str(&format!("\n\nStdout:\n{stdout_trimmed}")); } let stderr_trimmed = stderr.trim(); if !stderr_trimmed.is_empty() { message.push_str(&format!("\n\nStderr:\n{stderr_trimmed}")); } let (program, _) = get_program_and_args(self); let err = TopgradeError::ProcessFailedWithOutput(program, output.status, stderr.into_owned()); let ret = Err(err).with_context(|| message); debug!("Command failed: {ret:?}"); ret } } fn status_checked_with(&mut self, succeeded: impl Fn(ExitStatus) -> Result<(), ()>) -> eyre::Result<()> { let command = log(self); let message = format!("Failed to execute `{command}`"); // This is where we implement `status_checked`, which is what we prefer to use instead of // `status`, so we allow `Command::status` here. #[allow(clippy::disallowed_methods)] let status = self.status().with_context(|| message.clone())?; if succeeded(status).is_ok() { Ok(()) } else { let (program, _) = get_program_and_args(self); let err = TopgradeError::ProcessFailed(program, status); let ret = Err(err).with_context(|| format!("Command failed: `{command}`")); debug!("Command failed: {ret:?}"); ret } } fn spawn_checked(&mut self) -> eyre::Result { let command = log(self); let message = format!("Failed to execute `{command}`"); // This is where we implement `spawn_checked`, which is what we prefer to use instead of // `spawn`, so we allow `Command::spawn` here. #[allow(clippy::disallowed_methods)] { self.spawn().with_context(|| message.clone()) } } } fn get_program_and_args(cmd: &Command) -> (String, String) { // We're not doing anything weird with commands that are invalid UTF-8 so this is fine. let program = cmd.get_program().to_string_lossy().into_owned(); let args = shell_words::join(cmd.get_args().map(|arg| arg.to_string_lossy())); (program, args) } fn format_program_and_args(cmd: &Command) -> String { let (program, args) = get_program_and_args(cmd); if args.is_empty() { program } else { format!("{program} {args}") } } fn log(cmd: &Command) -> String { let command = format_program_and_args(cmd); debug!("Executing command `{command}`"); command }