use std::fmt;
use std::io;
use std::process::ExitStatus;
use std::time::Duration;
use crate::cmd_display::CmdDisplay;
pub const STREAM_SUFFIX_SIZE: usize = 128 * 1024;
#[derive(Debug)]
#[non_exhaustive]
pub enum RunError {
Spawn {
command: CmdDisplay,
source: io::Error,
},
NonZeroExit {
command: CmdDisplay,
status: ExitStatus,
stdout: Vec<u8>,
stderr: String,
},
Timeout {
command: CmdDisplay,
elapsed: Duration,
stdout: Vec<u8>,
stderr: String,
},
}
impl RunError {
pub fn command(&self) -> &CmdDisplay {
match self {
Self::Spawn { command, .. } => command,
Self::NonZeroExit { command, .. } => command,
Self::Timeout { command, .. } => command,
}
}
pub fn program(&self) -> &std::ffi::OsStr {
self.command().program()
}
pub fn stderr(&self) -> Option<&str> {
match self {
Self::NonZeroExit { stderr, .. } => Some(stderr),
Self::Timeout { stderr, .. } => Some(stderr),
Self::Spawn { .. } => None,
}
}
pub fn stdout(&self) -> Option<&[u8]> {
match self {
Self::NonZeroExit { stdout, .. } => Some(stdout),
Self::Timeout { stdout, .. } => Some(stdout),
Self::Spawn { .. } => None,
}
}
pub fn exit_status(&self) -> Option<ExitStatus> {
match self {
Self::NonZeroExit { status, .. } => Some(*status),
Self::Spawn { .. } | Self::Timeout { .. } => None,
}
}
pub fn is_non_zero_exit(&self) -> bool {
matches!(self, Self::NonZeroExit { .. })
}
pub fn is_spawn_failure(&self) -> bool {
matches!(self, Self::Spawn { .. })
}
pub fn is_timeout(&self) -> bool {
matches!(self, Self::Timeout { .. })
}
}
impl fmt::Display for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spawn { command, source } => {
write!(f, "failed to spawn `{command}`: {source}")
}
Self::NonZeroExit {
command,
status,
stderr,
..
} => {
let trimmed = stderr.trim();
if trimmed.is_empty() {
write!(f, "`{command}` exited with {status}")
} else {
write!(f, "`{command}` exited with {status}: {trimmed}")
}
}
Self::Timeout {
command,
elapsed,
stderr,
..
} => {
let trimmed = stderr.trim();
if trimmed.is_empty() {
write!(
f,
"`{command}` killed after timeout ({:.1}s)",
elapsed.as_secs_f64()
)
} else {
write!(
f,
"`{command}` killed after timeout ({:.1}s); last stderr: {trimmed}",
elapsed.as_secs_f64()
)
}
}
}
}
}
impl std::error::Error for RunError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Spawn { source, .. } => Some(source),
Self::NonZeroExit { .. } | Self::Timeout { .. } => None,
}
}
}
pub(crate) fn truncate_suffix(mut buf: Vec<u8>) -> Vec<u8> {
if buf.len() > STREAM_SUFFIX_SIZE {
let drop = buf.len() - STREAM_SUFFIX_SIZE;
buf.drain(..drop);
}
buf
}
pub(crate) fn truncate_suffix_string(s: String) -> String {
if s.len() <= STREAM_SUFFIX_SIZE {
return s;
}
let drop = s.len() - STREAM_SUFFIX_SIZE;
let mut start = drop;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn cd(prog: &str, args: &[&str]) -> CmdDisplay {
CmdDisplay::new(
prog.into(),
args.iter().map(|s| std::ffi::OsString::from(*s)).collect(),
false,
)
}
fn spawn_error() -> RunError {
RunError::Spawn {
command: cd("git", &["status"]),
source: io::Error::new(io::ErrorKind::NotFound, "not found"),
}
}
fn non_zero_exit(stderr: &str) -> RunError {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(256)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
};
RunError::NonZeroExit {
command: cd("git", &["status"]),
status,
stdout: Vec::new(),
stderr: stderr.to_string(),
}
}
fn timeout_error() -> RunError {
RunError::Timeout {
command: cd("git", &["fetch"]),
elapsed: Duration::from_secs(30),
stdout: Vec::new(),
stderr: "Fetching origin".into(),
}
}
#[test]
fn program_returns_name() {
assert_eq!(spawn_error().program(), std::ffi::OsStr::new("git"));
assert_eq!(non_zero_exit("").program(), std::ffi::OsStr::new("git"));
assert_eq!(timeout_error().program(), std::ffi::OsStr::new("git"));
}
#[test]
fn stderr_only_for_completed_or_timed_out() {
assert_eq!(spawn_error().stderr(), None);
assert_eq!(non_zero_exit("boom").stderr(), Some("boom"));
assert_eq!(timeout_error().stderr(), Some("Fetching origin"));
}
#[test]
fn stdout_only_for_completed_or_timed_out() {
assert!(spawn_error().stdout().is_none());
assert!(non_zero_exit("").stdout().is_some());
assert!(timeout_error().stdout().is_some());
}
#[test]
fn exit_status_only_for_non_zero_exit() {
assert!(spawn_error().exit_status().is_none());
assert!(non_zero_exit("").exit_status().is_some());
assert!(timeout_error().exit_status().is_none());
}
#[test]
fn predicates() {
assert!(spawn_error().is_spawn_failure());
assert!(non_zero_exit("").is_non_zero_exit());
assert!(timeout_error().is_timeout());
}
#[test]
fn display_spawn_failure() {
let msg = format!("{}", spawn_error());
assert!(msg.contains("spawn"));
assert!(msg.contains("git status"));
}
#[test]
fn display_non_zero_exit_with_stderr() {
let msg = format!("{}", non_zero_exit("something broke"));
assert!(msg.contains("git status"));
assert!(msg.contains("something broke"));
}
#[test]
fn display_timeout() {
let msg = format!("{}", timeout_error());
assert!(msg.contains("git fetch"));
assert!(msg.contains("timeout"));
}
#[test]
fn error_source_for_spawn() {
use std::error::Error;
assert!(spawn_error().source().is_some());
}
#[test]
fn error_source_none_for_non_spawn() {
use std::error::Error;
assert!(non_zero_exit("").source().is_none());
assert!(timeout_error().source().is_none());
}
#[test]
fn wraps_into_anyhow() {
let err = spawn_error();
let _: anyhow::Error = err.into();
}
#[test]
fn truncate_suffix_keeps_short_buffers() {
let v = vec![1, 2, 3];
assert_eq!(truncate_suffix(v.clone()), v);
}
#[test]
fn truncate_suffix_drops_from_front() {
let big: Vec<u8> = (0..(STREAM_SUFFIX_SIZE + 100) as u32)
.map(|n| (n & 0xff) as u8)
.collect();
let truncated = truncate_suffix(big.clone());
assert_eq!(truncated.len(), STREAM_SUFFIX_SIZE);
assert_eq!(truncated.last(), big.last());
}
#[test]
fn truncate_suffix_string_keeps_short() {
let s = String::from("hello");
assert_eq!(truncate_suffix_string(s), "hello");
}
#[test]
fn truncate_suffix_string_drops_from_front() {
let s = "x".repeat(STREAM_SUFFIX_SIZE + 100);
let truncated = truncate_suffix_string(s);
assert_eq!(truncated.len(), STREAM_SUFFIX_SIZE);
}
#[test]
fn truncate_suffix_string_preserves_utf8() {
let s = format!("{}{}", "x".repeat(STREAM_SUFFIX_SIZE), "é".repeat(50));
let truncated = truncate_suffix_string(s);
assert!(!truncated.is_empty());
}
#[test]
fn secret_command_in_display() {
let cmd_secret =
CmdDisplay::new("docker".into(), vec!["login".into(), "hunter2".into()], true);
let err = RunError::Spawn {
command: cmd_secret,
source: io::Error::new(io::ErrorKind::NotFound, "missing"),
};
let msg = format!("{err}");
assert!(!msg.contains("hunter2"), "secret leaked: {msg}");
assert!(msg.contains("docker"));
assert!(msg.contains("<secret>"));
}
}