use std::fmt;
use std::time::Duration;
pub(crate) type PredicateError = Box<dyn std::error::Error + Send + Sync>;
#[derive(thiserror::Error)]
#[non_exhaustive]
pub enum ErrorReason {
#[error("could not start `{program}`: {source}")]
#[non_exhaustive]
Spawn {
program: String,
#[source]
source: std::io::Error,
},
#[error("{}", display_not_found(program, searched))]
#[non_exhaustive]
NotFound {
program: String,
searched: Option<String>,
},
#[error(
"`{program}`: no cassette entry matches this invocation (stale or incomplete cassette)"
)]
CassetteMiss {
program: String,
},
#[error("{}", display_exit(program, *code, stdout, stderr))]
#[non_exhaustive]
Exit {
program: String,
code: i32,
stdout: String,
stderr: String,
stdout_bytes: Option<Vec<u8>>,
},
#[error("{}", display_timeout(program, *timeout, stdout, stderr))]
#[non_exhaustive]
Timeout {
program: String,
timeout: Duration,
stdout: String,
stderr: String,
stdout_bytes: Option<Vec<u8>>,
},
#[error(
"`{program}` output exceeded its capture ceiling ({total_lines} lines, {total_bytes} bytes total)"
)]
#[non_exhaustive]
OutputTooLarge {
program: String,
max_lines: Option<usize>,
max_bytes: Option<usize>,
total_lines: usize,
total_bytes: usize,
},
#[error("`{program}` was not ready after {timeout:?}")]
NotReady {
program: String,
timeout: Duration,
},
#[error("{}", display_parse(program, message))]
#[non_exhaustive]
Parse {
program: String,
message: String,
},
#[cfg(feature = "limits")]
#[error("{}", display_resource_limit(*kind, *reason, detail))]
#[non_exhaustive]
ResourceLimit {
kind: crate::limits::LimitKind,
reason: crate::limits::LimitReason,
detail: String,
},
#[error("operation `{operation}` is not supported on this platform")]
Unsupported {
operation: String,
},
#[error("`{program}` was cancelled")]
Cancelled {
program: String,
},
#[error("{}", display_signalled(program, *signal, stdout, stderr))]
#[non_exhaustive]
Signalled {
program: String,
signal: Option<i32>,
stdout: String,
stderr: String,
stdout_bytes: Option<Vec<u8>>,
},
#[error("failed to write to `{program}` stdin: {source}")]
#[non_exhaustive]
Stdin {
program: String,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Io(std::io::Error),
#[error("the `{predicate}` control predicate returned an error: {source}")]
#[non_exhaustive]
Predicate {
predicate: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
Spawn,
PermissionDenied,
#[cfg(feature = "limits")]
ResourceLimit,
Unsupported,
Timeout,
Cancelled,
Predicate,
Exit,
Signalled,
Other,
}
impl ErrorKind {
pub fn name(&self) -> &'static str {
match self {
ErrorKind::NotFound => "not_found",
ErrorKind::Spawn => "spawn",
ErrorKind::PermissionDenied => "permission_denied",
ErrorKind::Unsupported => "unsupported",
ErrorKind::Timeout => "timeout",
ErrorKind::Cancelled => "cancelled",
ErrorKind::Predicate => "predicate",
ErrorKind::Exit => "exit",
ErrorKind::Signalled => "signalled",
ErrorKind::Other => "other",
#[cfg(feature = "limits")]
ErrorKind::ResourceLimit => "resource_limit",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct OutputOverflow {
total_lines: usize,
total_bytes: usize,
max_lines: Option<usize>,
max_bytes: Option<usize>,
}
impl OutputOverflow {
pub fn total_lines(&self) -> usize {
self.total_lines
}
pub fn total_bytes(&self) -> usize {
self.total_bytes
}
pub fn max_lines(&self) -> Option<usize> {
self.max_lines
}
pub fn max_bytes(&self) -> Option<usize> {
self.max_bytes
}
}
impl ErrorReason {
pub fn kind(&self) -> ErrorKind {
match self {
ErrorReason::NotFound { .. } => ErrorKind::NotFound,
ErrorReason::Spawn { source, .. } => {
if source.kind() == std::io::ErrorKind::PermissionDenied {
ErrorKind::PermissionDenied
} else {
ErrorKind::Spawn
}
}
ErrorReason::Io(source) => {
if source.kind() == std::io::ErrorKind::PermissionDenied {
ErrorKind::PermissionDenied
} else {
ErrorKind::Other
}
}
ErrorReason::Timeout { .. } => ErrorKind::Timeout,
ErrorReason::Cancelled { .. } => ErrorKind::Cancelled,
ErrorReason::Predicate { .. } => ErrorKind::Predicate,
ErrorReason::Exit { .. } => ErrorKind::Exit,
ErrorReason::Signalled { .. } => ErrorKind::Signalled,
ErrorReason::Unsupported { .. } => ErrorKind::Unsupported,
ErrorReason::CassetteMiss { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Stdin { .. } => ErrorKind::Other,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => ErrorKind::ResourceLimit,
}
}
pub fn diagnostic(&self) -> Option<&str> {
match self {
ErrorReason::Exit { stdout, stderr, .. }
| ErrorReason::Timeout { stdout, stderr, .. }
| ErrorReason::Signalled { stdout, stderr, .. } => exit_diagnostic(stdout, stderr),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn stdout(&self) -> Option<&str> {
self.streams().map(|(stdout, _)| stdout)
}
pub fn stderr(&self) -> Option<&str> {
self.streams().map(|(_, stderr)| stderr)
}
pub fn stdout_bytes(&self) -> Option<&[u8]> {
match self {
ErrorReason::Exit { stdout_bytes, .. }
| ErrorReason::Timeout { stdout_bytes, .. }
| ErrorReason::Signalled { stdout_bytes, .. } => stdout_bytes.as_deref(),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn combined(&self) -> Option<String> {
self.streams()
.map(|(stdout, stderr)| crate::result::combine_streams(stdout, stderr))
}
fn streams(&self) -> Option<(&str, &str)> {
match self {
ErrorReason::Exit { stdout, stderr, .. }
| ErrorReason::Timeout { stdout, stderr, .. }
| ErrorReason::Signalled { stdout, stderr, .. } => Some((stdout, stderr)),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn program(&self) -> Option<&str> {
match self {
ErrorReason::Spawn { program, .. }
| ErrorReason::NotFound { program, .. }
| ErrorReason::CassetteMiss { program }
| ErrorReason::Exit { program, .. }
| ErrorReason::Timeout { program, .. }
| ErrorReason::OutputTooLarge { program, .. }
| ErrorReason::NotReady { program, .. }
| ErrorReason::Parse { program, .. }
| ErrorReason::Cancelled { program }
| ErrorReason::Signalled { program, .. }
| ErrorReason::Stdin { program, .. } => Some(program),
ErrorReason::Unsupported { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, ErrorReason::NotFound { .. })
}
pub fn is_permission_denied(&self) -> bool {
self.io_source()
.is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
}
pub fn is_transient(&self) -> bool {
self.io_source().is_some_and(is_transient_io)
}
pub fn code(&self) -> Option<i32> {
match self {
ErrorReason::Exit { code, .. } => Some(*code),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn signal(&self) -> Option<i32> {
match self {
ErrorReason::Signalled { signal, .. } => *signal,
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn timeout_duration(&self) -> Option<Duration> {
match self {
ErrorReason::Timeout { timeout, .. } => Some(*timeout),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn output_overflow(&self) -> Option<OutputOverflow> {
match self {
ErrorReason::OutputTooLarge {
max_lines,
max_bytes,
total_lines,
total_bytes,
..
} => Some(OutputOverflow {
total_lines: *total_lines,
total_bytes: *total_bytes,
max_lines: *max_lines,
max_bytes: *max_bytes,
}),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
pub fn unsupported_operation(&self) -> Option<&str> {
match self {
ErrorReason::Unsupported { operation } => Some(operation),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
#[cfg(feature = "limits")]
pub fn limit_kind(&self) -> Option<crate::limits::LimitKind> {
match self {
ErrorReason::ResourceLimit { kind, .. } => Some(*kind),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
}
}
#[cfg(feature = "limits")]
pub fn limit_reason(&self) -> Option<crate::limits::LimitReason> {
match self {
ErrorReason::ResourceLimit { reason, .. } => Some(*reason),
ErrorReason::Spawn { .. }
| ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. }
| ErrorReason::Io(_) => None,
}
}
pub fn is_timeout(&self) -> bool {
matches!(self, ErrorReason::Timeout { .. })
}
pub fn is_cancelled(&self) -> bool {
matches!(self, ErrorReason::Cancelled { .. })
}
pub fn is_signalled(&self) -> bool {
matches!(self, ErrorReason::Signalled { .. })
}
fn io_source(&self) -> Option<&std::io::Error> {
match self {
ErrorReason::Spawn { source, .. } => Some(source),
ErrorReason::Io(source) => Some(source),
ErrorReason::NotFound { .. }
| ErrorReason::CassetteMiss { .. }
| ErrorReason::Exit { .. }
| ErrorReason::Timeout { .. }
| ErrorReason::OutputTooLarge { .. }
| ErrorReason::NotReady { .. }
| ErrorReason::Parse { .. }
| ErrorReason::Unsupported { .. }
| ErrorReason::Cancelled { .. }
| ErrorReason::Signalled { .. }
| ErrorReason::Stdin { .. }
| ErrorReason::Predicate { .. } => None,
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit { .. } => None,
}
}
}
pub struct Error {
reason: Box<ErrorReason>,
}
impl Error {
pub fn reason(&self) -> &ErrorReason {
&self.reason
}
#[must_use]
pub fn into_reason(self) -> ErrorReason {
*self.reason
}
pub fn kind(&self) -> ErrorKind {
self.reason.kind()
}
pub fn diagnostic(&self) -> Option<&str> {
self.reason.diagnostic()
}
pub fn stdout(&self) -> Option<&str> {
self.reason.stdout()
}
pub fn stderr(&self) -> Option<&str> {
self.reason.stderr()
}
pub fn stdout_bytes(&self) -> Option<&[u8]> {
self.reason.stdout_bytes()
}
pub fn combined(&self) -> Option<String> {
self.reason.combined()
}
pub fn program(&self) -> Option<&str> {
self.reason.program()
}
pub fn is_not_found(&self) -> bool {
self.reason.is_not_found()
}
pub fn is_permission_denied(&self) -> bool {
self.reason.is_permission_denied()
}
pub fn is_transient(&self) -> bool {
self.reason.is_transient()
}
pub fn code(&self) -> Option<i32> {
self.reason.code()
}
pub fn signal(&self) -> Option<i32> {
self.reason.signal()
}
pub fn timeout_duration(&self) -> Option<Duration> {
self.reason.timeout_duration()
}
pub fn output_overflow(&self) -> Option<OutputOverflow> {
self.reason.output_overflow()
}
pub fn unsupported_operation(&self) -> Option<&str> {
self.reason.unsupported_operation()
}
#[cfg(feature = "limits")]
pub fn limit_kind(&self) -> Option<crate::limits::LimitKind> {
self.reason.limit_kind()
}
#[cfg(feature = "limits")]
pub fn limit_reason(&self) -> Option<crate::limits::LimitReason> {
self.reason.limit_reason()
}
pub fn is_timeout(&self) -> bool {
self.reason.is_timeout()
}
pub fn is_cancelled(&self) -> bool {
self.reason.is_cancelled()
}
pub fn is_signalled(&self) -> bool {
self.reason.is_signalled()
}
}
impl From<ErrorReason> for Error {
fn from(reason: ErrorReason) -> Self {
Error {
reason: Box::new(reason),
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&*self.reason, f)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&*self.reason, f)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.reason.source()
}
}
const _: () = assert!(
std::mem::size_of::<Error>() == std::mem::size_of::<*const ()>(),
"Error must remain pointer-sized (a Box<ErrorReason> handle)"
);
impl Error {
#[doc(hidden)]
pub fn exit(
program: impl Into<String>,
code: i32,
stdout: impl Into<String>,
stderr: impl Into<String>,
) -> Self {
ErrorReason::Exit {
program: program.into(),
code,
stdout: stdout.into(),
stderr: stderr.into(),
stdout_bytes: None,
}
.into()
}
#[doc(hidden)]
pub fn timeout(
program: impl Into<String>,
timeout: Duration,
stdout: impl Into<String>,
stderr: impl Into<String>,
) -> Self {
ErrorReason::Timeout {
program: program.into(),
timeout,
stdout: stdout.into(),
stderr: stderr.into(),
stdout_bytes: None,
}
.into()
}
#[doc(hidden)]
pub fn signalled(
program: impl Into<String>,
signal: Option<i32>,
stdout: impl Into<String>,
stderr: impl Into<String>,
) -> Self {
ErrorReason::Signalled {
program: program.into(),
signal,
stdout: stdout.into(),
stderr: stderr.into(),
stdout_bytes: None,
}
.into()
}
#[doc(hidden)]
pub fn spawn(program: impl Into<String>, source: std::io::Error) -> Self {
ErrorReason::Spawn {
program: program.into(),
source,
}
.into()
}
#[doc(hidden)]
pub fn not_found(program: impl Into<String>, searched: Option<String>) -> Self {
ErrorReason::NotFound {
program: program.into(),
searched,
}
.into()
}
#[doc(hidden)]
pub fn stdin(program: impl Into<String>, source: std::io::Error) -> Self {
ErrorReason::Stdin {
program: program.into(),
source,
}
.into()
}
pub(crate) fn io(source: std::io::Error) -> Self {
ErrorReason::Io(source).into()
}
pub(crate) fn predicate(predicate: &'static str, source: PredicateError) -> Self {
ErrorReason::Predicate { predicate, source }.into()
}
pub fn parse(program: impl Into<String>, message: impl Into<String>) -> Self {
ErrorReason::Parse {
program: program.into(),
message: message.into(),
}
.into()
}
}
impl fmt::Debug for ErrorReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorReason::Spawn { program, source } => f
.debug_struct("Spawn")
.field("program", program)
.field("source", source)
.finish(),
ErrorReason::NotFound { program, searched } => f
.debug_struct("NotFound")
.field("program", program)
.field("searched", &searched.as_deref().map(SearchedRedaction))
.finish(),
ErrorReason::CassetteMiss { program } => f
.debug_struct("CassetteMiss")
.field("program", program)
.finish(),
ErrorReason::Exit {
program,
code,
stdout,
stderr,
stdout_bytes,
} => f
.debug_struct("Exit")
.field("program", program)
.field("code", code)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref()))
.finish(),
ErrorReason::Timeout {
program,
timeout,
stdout,
stderr,
stdout_bytes,
} => f
.debug_struct("Timeout")
.field("program", program)
.field("timeout", timeout)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref()))
.finish(),
ErrorReason::OutputTooLarge {
program,
max_lines,
max_bytes,
total_lines,
total_bytes,
} => f
.debug_struct("OutputTooLarge")
.field("program", program)
.field("max_lines", max_lines)
.field("max_bytes", max_bytes)
.field("total_lines", total_lines)
.field("total_bytes", total_bytes)
.finish(),
ErrorReason::NotReady { program, timeout } => f
.debug_struct("NotReady")
.field("program", program)
.field("timeout", timeout)
.finish(),
ErrorReason::Parse { program, message } => f
.debug_struct("Parse")
.field("program", program)
.field("message", &StreamPreview(message))
.finish(),
#[cfg(feature = "limits")]
ErrorReason::ResourceLimit {
kind,
reason,
detail,
} => f
.debug_struct("ResourceLimit")
.field("kind", kind)
.field("reason", reason)
.field("detail", &StreamPreview(detail))
.finish(),
ErrorReason::Unsupported { operation } => f
.debug_struct("Unsupported")
.field("operation", operation)
.finish(),
ErrorReason::Cancelled { program } => f
.debug_struct("Cancelled")
.field("program", program)
.finish(),
ErrorReason::Signalled {
program,
signal,
stdout,
stderr,
stdout_bytes,
} => f
.debug_struct("Signalled")
.field("program", program)
.field("signal", signal)
.field("stdout", &StreamPreview(stdout))
.field("stderr", &StreamPreview(stderr))
.field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref()))
.finish(),
ErrorReason::Stdin { program, source } => f
.debug_struct("Stdin")
.field("program", program)
.field("source", source)
.finish(),
ErrorReason::Io(source) => f.debug_tuple("Io").field(source).finish(),
ErrorReason::Predicate { predicate, source } => f
.debug_struct("Predicate")
.field("predicate", predicate)
.field("source", source)
.finish(),
}
}
}
pub(crate) struct StreamPreview<'a>(pub(crate) &'a str);
impl fmt::Debug for StreamPreview<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const CAP: usize = DIAG_CAP;
let s = self.0;
if s.len() <= CAP {
return fmt::Debug::fmt(s, f);
}
let mut cut = CAP;
while !s.is_char_boundary(cut) {
cut -= 1;
}
write!(f, "{:?}… (+{} bytes)", &s[..cut], s.len() - cut)
}
}
struct BytesPreview<'a>(Option<&'a [u8]>);
impl fmt::Debug for BytesPreview<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(bytes) => write!(f, "Some(<{} bytes>)", bytes.len()),
None => write!(f, "None"),
}
}
}
struct SearchedRedaction<'a>(&'a str);
impl fmt::Debug for SearchedRedaction<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const SEP: char = if cfg!(windows) { ';' } else { ':' };
let n = self.0.split(SEP).filter(|s| !s.is_empty()).count();
write!(f, "<{n} directories>")
}
}
fn display_not_found(program: &str, searched: &Option<String>) -> String {
match searched {
Some(_) => format!("`{program}` not found on PATH"),
None => format!("`{program}` not found"),
}
}
pub(crate) fn stdout_not_piped_error(program: &str) -> Error {
ErrorReason::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"`{program}`: stdout is not piped (Command::stdout was set to \
Inherit/Null or stdout_file), so the capture verbs have nothing to read — \
use StdioMode::Piped to capture it"
),
))
.into()
}
fn display_parse(program: &str, message: &str) -> String {
let mut out = format!("failed to parse `{program}` output: ");
push_sanitized_capped(&mut out, message, DIAG_CAP);
out
}
#[cfg(feature = "limits")]
fn display_resource_limit(
kind: crate::limits::LimitKind,
reason: crate::limits::LimitReason,
detail: &str,
) -> String {
use crate::limits::{LimitKind, LimitReason};
let kind_str = match kind {
LimitKind::Memory => "memory limit",
LimitKind::Processes => "process-count limit",
LimitKind::Cpu => "CPU limit",
};
let reason_str = match reason {
LimitReason::Invalid => "is invalid",
LimitReason::Unsupported => "is not supported on this platform",
LimitReason::Unenforceable => "could not be enforced",
};
let mut out = format!("{kind_str} {reason_str}");
if !detail.is_empty() {
out.push_str(": ");
push_sanitized_capped(&mut out, detail, DIAG_CAP);
}
out
}
fn display_signalled(program: &str, signal: Option<i32>, stdout: &str, stderr: &str) -> String {
let mut message = match signal {
Some(n) => format!("`{program}` was terminated by signal {n}"),
None => format!("`{program}` was terminated by a signal"),
};
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
fn display_timeout(program: &str, timeout: Duration, stdout: &str, stderr: &str) -> String {
let mut message = if timeout.is_zero() {
format!("`{program}` timed out")
} else {
format!("`{program}` timed out after {timeout:?}")
};
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
fn is_transient_io(e: &std::io::Error) -> bool {
use std::io::ErrorKind;
if matches!(
e.kind(),
ErrorKind::Interrupted
| ErrorKind::WouldBlock
| ErrorKind::ResourceBusy
| ErrorKind::ExecutableFileBusy
) {
return true;
}
#[cfg(windows)]
{
matches!(e.raw_os_error(), Some(32 | 33))
}
#[cfg(not(windows))]
{
false
}
}
fn exit_diagnostic<'a>(stdout: &'a str, stderr: &'a str) -> Option<&'a str> {
[stderr, stdout]
.into_iter()
.map(str::trim)
.find(|text| !text.is_empty())
}
fn display_exit(program: &str, code: i32, stdout: &str, stderr: &str) -> String {
let mut message = format!("`{program}` exited with code {code}");
append_diagnostic_tail(&mut message, stdout, stderr);
message
}
const DIAG_CAP: usize = 200;
fn push_sanitized_capped(out: &mut String, text: &str, cap: usize) {
let mut written = 0usize;
for ch in text.chars() {
let ch = if is_display_unsafe(ch) {
'\u{FFFD}'
} else {
ch
};
if written + ch.len_utf8() > cap {
out.push('…');
return;
}
out.push(ch);
written += ch.len_utf8();
}
}
fn is_display_unsafe(ch: char) -> bool {
(ch.is_control() && ch != '\t')
|| matches!(ch,
'\u{2028}' | '\u{2029}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' | '\u{061C}' )
}
fn append_diagnostic_tail(message: &mut String, stdout: &str, stderr: &str) {
let tail = exit_diagnostic(stdout, stderr)
.and_then(|text| text.lines().rev().map(str::trim).find(|l| !l.is_empty()));
if let Some(tail) = tail {
message.push_str(": ");
push_sanitized_capped(message, tail, DIAG_CAP);
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_is_pointer_sized() {
assert_eq!(
std::mem::size_of::<Error>(),
std::mem::size_of::<*const ()>(),
"Error must remain pointer-sized (a Box<ErrorReason> handle)"
);
assert_eq!(
std::mem::size_of::<crate::Result<()>>(),
std::mem::size_of::<*const ()>(),
"Result<(), Error> must be pointer-sized too"
);
}
#[test]
fn stream_accessors_cover_the_stream_bearing_variants() {
for err in [
Error::exit("git", 1, "out", "err"),
Error::timeout("git", Duration::from_secs(1), "out", "err"),
Error::signalled("git", Some(9), "out", "err"),
] {
assert_eq!(err.stdout(), Some("out"));
assert_eq!(err.stderr(), Some("err"));
assert_eq!(err.combined().as_deref(), Some("out\nerr"));
}
let trailing = Error::exit("git", 1, "out\n", "err");
assert_eq!(trailing.combined().as_deref(), Some("out\nerr"));
let only_err = Error::exit("git", 1, "", "err");
assert_eq!(only_err.stdout(), Some(""));
assert_eq!(only_err.stderr(), Some("err"));
assert_eq!(only_err.combined().as_deref(), Some("err"));
let not_ready = ErrorReason::NotReady {
program: "server".into(),
timeout: Duration::from_secs(1),
};
assert_eq!(not_ready.stdout(), None);
assert_eq!(not_ready.stderr(), None);
assert_eq!(not_ready.combined(), None);
}
#[test]
fn disposition_accessors_code_signal_timeout_cancelled() {
let exit = Error::exit("git", 7, "", "");
assert_eq!(exit.code(), Some(7));
assert_eq!(exit.signal(), None);
assert!(!exit.is_timeout());
assert!(!exit.is_cancelled());
let timeout = Error::timeout("git", Duration::from_secs(1), "", "");
assert_eq!(timeout.code(), None);
assert_eq!(timeout.signal(), None);
assert!(timeout.is_timeout());
let signalled = Error::signalled("git", Some(9), "", "");
assert_eq!(signalled.code(), None);
assert_eq!(signalled.signal(), Some(9));
assert!(!signalled.is_timeout());
assert!(signalled.is_signalled());
let unknown_sig = Error::signalled("git", None, "", "");
assert_eq!(unknown_sig.signal(), None);
assert!(unknown_sig.is_signalled());
assert!(!exit.is_signalled() && !timeout.is_signalled());
let cancelled = ErrorReason::Cancelled {
program: "job".into(),
};
assert!(cancelled.is_cancelled());
assert!(!cancelled.is_signalled());
assert!(!exit.is_cancelled() && !timeout.is_cancelled());
}
#[test]
fn program_accessor_covers_named_variants_and_skips_the_rest() {
assert_eq!(Error::exit("git", 1, "", "").program(), Some("git"));
assert_eq!(
ErrorReason::NotReady {
program: "server".into(),
timeout: Duration::from_secs(1),
}
.program(),
Some("server")
);
assert_eq!(
ErrorReason::Cancelled {
program: "job".into()
}
.program(),
Some("job")
);
assert_eq!(
ErrorReason::Unsupported {
operation: "suspend".into()
}
.program(),
None
);
assert_eq!(
ErrorReason::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)).program(),
None
);
#[cfg(feature = "limits")]
assert_eq!(
ErrorReason::ResourceLimit {
kind: crate::limits::LimitKind::Memory,
reason: crate::limits::LimitReason::Unsupported,
detail: "no container".into()
}
.program(),
None
);
}
#[test]
fn doc_hidden_constructors_build_the_expected_variants() {
assert!(matches!(
Error::exit("git", 2, "o", "e").reason(),
ErrorReason::Exit { code: 2, .. }
));
assert!(matches!(
Error::timeout("git", Duration::from_secs(3), "o", "e").reason(),
ErrorReason::Timeout { .. }
));
match Error::signalled("git", None, "o", "e").into_reason() {
ErrorReason::Signalled { signal, .. } => assert_eq!(signal, None),
other => panic!("expected Signalled, got {other:?}"),
}
}
#[test]
fn display_tail_sanitizes_control_chars_against_terminal_injection() {
let err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "boom\x1b[31m\x07\x00danger".into(),
stdout_bytes: None,
};
let msg = err.to_string();
assert!(!msg.contains('\x1b'), "ESC must be sanitized: {msg:?}");
assert!(!msg.contains('\x07'), "BEL must be sanitized: {msg:?}");
assert!(!msg.contains('\x00'), "NUL must be sanitized: {msg:?}");
assert!(msg.contains("boom"), "printable text is kept: {msg:?}");
assert!(msg.contains("danger"), "printable text is kept: {msg:?}");
}
#[test]
fn display_tail_strips_bidi_controls_against_trojan_source() {
let err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "safe\u{202E}reversed\u{202C}\u{2066}iso".into(),
stdout_bytes: None,
};
let msg = err.to_string();
assert!(!msg.contains('\u{202E}'), "RLO must be sanitized: {msg:?}");
assert!(!msg.contains('\u{202C}'), "PDF must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2066}'), "LRI must be sanitized: {msg:?}");
assert!(msg.contains("safe"), "printable text is kept: {msg:?}");
}
#[test]
fn display_tail_strips_unicode_line_separators() {
let err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: "before\u{2028}after\u{2029}end".into(),
stdout_bytes: None,
};
let msg = err.to_string();
assert!(!msg.contains('\u{2028}'), "LS must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2029}'), "PS must be sanitized: {msg:?}");
assert!(msg.contains("before"), "printable text is kept: {msg:?}");
assert!(msg.contains("after"), "printable text is kept: {msg:?}");
}
#[test]
fn parse_display_sanitizes_control_and_bidi_injection() {
let err = ErrorReason::Parse {
program: "jq".into(),
message: "bad\x1b[31m\x07token\u{202E}flip\u{2069}sep\u{2028}end".into(),
};
let msg = err.to_string();
assert!(!msg.contains('\x1b'), "ESC must be sanitized: {msg:?}");
assert!(!msg.contains('\x07'), "BEL must be sanitized: {msg:?}");
assert!(!msg.contains('\u{202E}'), "RLO must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2069}'), "PDI must be sanitized: {msg:?}");
assert!(!msg.contains('\u{2028}'), "LS must be sanitized: {msg:?}");
assert!(msg.contains("bad"), "printable text is kept: {msg:?}");
assert!(msg.contains("token"), "printable text is kept: {msg:?}");
}
#[test]
fn debug_bounds_exit_streams_so_unwrap_cannot_dump_them() {
let huge = "x".repeat(10_000);
let err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: huge.clone(),
stderr: huge,
stdout_bytes: None,
};
let dbg = format!("{err:?}");
assert!(
dbg.len() < 700,
"Debug must be bounded (two 200-byte previews + struct), got {} bytes",
dbg.len()
);
assert!(
!dbg.contains(&"x".repeat(300)),
"must not dump the full multi-KiB stream"
);
assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}");
let small = ErrorReason::Exit {
program: "tool".into(),
code: 2,
stdout: "hello".into(),
stderr: String::new(),
stdout_bytes: None,
};
let dbg = format!("{small:?}");
assert!(dbg.contains("\"hello\""), "got: {dbg}");
assert!(
!dbg.contains("bytes)"),
"no truncation note for a short stream: {dbg}"
);
}
#[test]
fn stream_preview_truncates_on_char_boundary() {
let stream = format!("{}éz", "a".repeat(DIAG_CAP - 1));
let cut = DIAG_CAP - 1;
assert_eq!(
format!("{:?}", StreamPreview(&stream)),
format!("{:?}… (+{} bytes)", &stream[..cut], stream.len() - cut)
);
}
#[test]
fn searched_redaction_filters_empty_path_segments() {
let sep = if cfg!(windows) { ';' } else { ':' };
assert_eq!(
format!("{:?}", SearchedRedaction(&format!("first{sep}second{sep}"))),
"<2 directories>"
);
assert_eq!(
format!("{:?}", SearchedRedaction(&format!("first{sep}{sep}second"))),
"<2 directories>"
);
}
#[test]
fn push_sanitized_capped_respects_byte_boundaries() {
let mut out = String::new();
push_sanitized_capped(&mut out, "abcd", 4);
assert_eq!(out, "abcd");
let mut out = String::new();
push_sanitized_capped(&mut out, "abc", 4);
assert_eq!(out, "abc");
let mut out = String::new();
push_sanitized_capped(&mut out, "abcde", 4);
assert_eq!(out, "abcd…");
let mut out = String::new();
push_sanitized_capped(&mut out, "abé", 3);
assert_eq!(out, "ab…");
let mut out = String::new();
push_sanitized_capped(&mut out, "\x1b", 3);
assert_eq!(out, "\u{FFFD}");
let mut out = String::new();
push_sanitized_capped(&mut out, "\x1ba", 3);
assert_eq!(out, "\u{FFFD}…");
}
#[test]
fn debug_bounds_stdout_bytes_to_a_length_summary() {
let huge_bytes = vec![b'y'; 10_000];
let err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: String::from_utf8_lossy(&huge_bytes).into_owned(),
stderr: String::new(),
stdout_bytes: Some(huge_bytes),
};
let dbg = format!("{err:?}");
assert!(
dbg.contains("stdout_bytes: Some(<10000 bytes>)"),
"got: {dbg}"
);
assert!(
!dbg.contains(&"y".repeat(300)),
"must not dump the raw bytes: {dbg}"
);
let none_err = ErrorReason::Exit {
program: "tool".into(),
code: 1,
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
};
let dbg = format!("{none_err:?}");
assert!(dbg.contains("stdout_bytes: None"), "got: {dbg}");
}
#[test]
fn debug_redacts_the_path_value_in_not_found() {
let err = ErrorReason::NotFound {
program: "tool".into(),
searched: Some("/secret/bin:/another/private/dir".into()),
};
let dbg = format!("{err:?}");
assert!(
!dbg.contains("/secret/bin") && !dbg.contains("/another/private/dir"),
"PATH value must not appear in Debug: {dbg}"
);
assert!(
dbg.contains("directories"),
"should summarize as a count: {dbg}"
);
}
#[test]
fn exit_display_appends_a_bounded_diagnostic_tail() {
let err = ErrorReason::Exit {
program: "git".into(),
code: 2,
stdout: "CONFLICT (content): merge conflict in a.rs".into(),
stderr: "warning: something\nfatal: boom\n".into(),
stdout_bytes: None,
};
assert_eq!(err.to_string(), "`git` exited with code 2: fatal: boom");
let err = ErrorReason::Exit {
program: "git".into(),
code: 2,
stdout: "CONFLICT (content): merge conflict in a.rs".into(),
stderr: " ".into(),
stdout_bytes: None,
};
assert_eq!(
err.to_string(),
"`git` exited with code 2: CONFLICT (content): merge conflict in a.rs"
);
}
#[test]
fn exit_display_with_blank_streams_has_no_trailing_colon() {
let err = ErrorReason::Exit {
program: "git".into(),
code: 2,
stdout: String::new(),
stderr: " \n ".into(),
stdout_bytes: None,
};
assert_eq!(err.to_string(), "`git` exited with code 2");
}
#[test]
fn exit_display_tail_is_capped_and_never_leaks_the_stream() {
let huge = "é".repeat(3000); let err = ErrorReason::Exit {
program: "x".into(),
code: 1,
stdout: String::new(),
stderr: huge,
stdout_bytes: None,
};
let message = err.to_string();
assert!(message.len() < 250, "capped, got {} bytes", message.len());
assert!(message.ends_with('…'), "got: {message}");
assert!(message.starts_with("`x` exited with code 1: éé"));
}
#[test]
fn diagnostic_is_none_for_non_exit_variants() {
let timeout = ErrorReason::Timeout {
program: "git".into(),
timeout: Duration::from_secs(1),
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
};
assert_eq!(timeout.diagnostic(), None);
let unsupported = ErrorReason::Unsupported {
operation: "suspend".into(),
};
assert_eq!(unsupported.diagnostic(), None);
let not_ready = ErrorReason::NotReady {
program: "server".into(),
timeout: Duration::from_secs(10),
};
assert_eq!(not_ready.diagnostic(), None);
{
let cancelled = ErrorReason::Cancelled {
program: "job".into(),
};
assert_eq!(cancelled.diagnostic(), None);
}
#[cfg(feature = "limits")]
{
let limit = ErrorReason::ResourceLimit {
kind: crate::limits::LimitKind::Memory,
reason: crate::limits::LimitReason::Unenforceable,
detail: "cgroup controller delegation unavailable".into(),
};
assert_eq!(limit.diagnostic(), None);
}
}
#[test]
fn cancelled_display_names_the_program() {
let err = ErrorReason::Cancelled {
program: "long-job".into(),
};
assert_eq!(err.to_string(), "`long-job` was cancelled");
assert_eq!(err.diagnostic(), None);
}
#[test]
fn timeout_and_signalled_carry_diagnostic_streams() {
let timeout = ErrorReason::Timeout {
program: "db-migrate".into(),
timeout: Duration::from_secs(30),
stdout: String::new(),
stderr: "connecting…\nwaiting for lock held by pid 4123\n".into(),
stdout_bytes: None,
};
assert_eq!(
timeout.diagnostic(),
Some("connecting…\nwaiting for lock held by pid 4123")
);
assert_eq!(
timeout.to_string(),
"`db-migrate` timed out after 30s: waiting for lock held by pid 4123"
);
let signalled = ErrorReason::Signalled {
program: "worker".into(),
signal: Some(11),
stdout: "processing batch 7\n".into(),
stderr: String::new(),
stdout_bytes: None,
};
assert_eq!(signalled.diagnostic(), Some("processing batch 7"));
assert_eq!(
signalled.to_string(),
"`worker` was terminated by signal 11: processing batch 7"
);
}
#[test]
fn timeout_and_signalled_debug_bounds_their_streams() {
let huge = "x".repeat(10_000);
let timeout = ErrorReason::Timeout {
program: "t".into(),
timeout: Duration::from_secs(1),
stdout: huge.clone(),
stderr: huge.clone(),
stdout_bytes: None,
};
let dbg = format!("{timeout:?}");
assert!(dbg.len() < 800, "Debug must be bounded, got {}", dbg.len());
assert!(!dbg.contains(&"x".repeat(300)), "must not dump the stream");
assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}");
let signalled = ErrorReason::Signalled {
program: "s".into(),
signal: None,
stdout: huge.clone(),
stderr: huge,
stdout_bytes: None,
};
let dbg = format!("{signalled:?}");
assert!(dbg.len() < 800, "Debug must be bounded, got {}", dbg.len());
assert!(!dbg.contains(&"x".repeat(300)), "must not dump the stream");
}
#[test]
fn parse_message_is_bounded_in_display_and_debug() {
let huge = "x".repeat(10_000);
let err = ErrorReason::Parse {
program: "jq".into(),
message: huge,
};
let display = err.to_string();
assert!(
display.len() < 300,
"Display must be bounded, got {} bytes",
display.len()
);
assert!(display.starts_with("failed to parse `jq` output: "));
assert!(
display.ends_with('…'),
"truncated Display ends with ellipsis"
);
let dbg = format!("{err:?}");
assert!(
dbg.len() < 400,
"Debug must be bounded, got {} bytes",
dbg.len()
);
assert!(
!dbg.contains(&"x".repeat(300)),
"must not dump the full message: {dbg}"
);
assert!(dbg.contains("bytes)"), "truncation note present: {dbg}");
let small = ErrorReason::Parse {
program: "jq".into(),
message: "unexpected token at line 3".into(),
};
assert_eq!(
small.to_string(),
"failed to parse `jq` output: unexpected token at line 3"
);
assert!(!format!("{small:?}").contains("bytes)"));
}
#[test]
fn not_ready_display_names_program_and_timeout() {
let err = ErrorReason::NotReady {
program: "my-server".into(),
timeout: Duration::from_secs(10),
};
assert_eq!(err.to_string(), "`my-server` was not ready after 10s");
}
#[test]
fn unsupported_display_names_the_operation() {
let err = ErrorReason::Unsupported {
operation: "signal(Hup)".into(),
};
assert_eq!(
err.to_string(),
"operation `signal(Hup)` is not supported on this platform"
);
}
#[cfg(feature = "limits")]
#[test]
fn resource_limit_display_carries_kind_and_reason() {
use crate::limits::{LimitKind, LimitReason};
let unsupported = ErrorReason::ResourceLimit {
kind: LimitKind::Memory,
reason: LimitReason::Unsupported,
detail: "no cgroup or Job Object available".into(),
};
assert_eq!(
unsupported.to_string(),
"memory limit is not supported on this platform: no cgroup or Job Object available"
);
let unenforceable = ErrorReason::ResourceLimit {
kind: LimitKind::Cpu,
reason: LimitReason::Unenforceable,
detail: "delegation unavailable".into(),
};
assert_eq!(
unenforceable.to_string(),
"CPU limit could not be enforced: delegation unavailable"
);
let invalid = ErrorReason::ResourceLimit {
kind: LimitKind::Processes,
reason: LimitReason::Invalid,
detail: "max_processes must be greater than 0".into(),
};
assert_eq!(
invalid.to_string(),
"process-count limit is invalid: max_processes must be greater than 0"
);
let no_detail = ErrorReason::ResourceLimit {
kind: LimitKind::Memory,
reason: LimitReason::Unsupported,
detail: String::new(),
};
assert_eq!(
no_detail.to_string(),
"memory limit is not supported on this platform"
);
}
#[cfg(feature = "limits")]
#[test]
fn resource_limit_accessors_read_kind_and_reason_without_destructuring() {
use crate::limits::{LimitKind, LimitReason};
let err = ErrorReason::ResourceLimit {
kind: LimitKind::Cpu,
reason: LimitReason::Invalid,
detail: "boom".into(),
};
assert_eq!(err.limit_kind(), Some(LimitKind::Cpu));
assert_eq!(err.limit_reason(), Some(LimitReason::Invalid));
let other = Error::exit("git", 1, "", "");
assert_eq!(other.limit_kind(), None);
assert_eq!(other.limit_reason(), None);
}
#[test]
fn signalled_display_and_diagnostic() {
let with_signal = ErrorReason::Signalled {
program: "git".into(),
signal: Some(9),
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
};
assert_eq!(with_signal.to_string(), "`git` was terminated by signal 9");
assert_eq!(with_signal.diagnostic(), None);
assert!(!with_signal.is_not_found());
assert!(!with_signal.is_permission_denied());
assert!(!with_signal.is_transient());
let no_signal = ErrorReason::Signalled {
program: "git".into(),
signal: None,
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
};
assert_eq!(no_signal.to_string(), "`git` was terminated by a signal");
}
#[test]
fn not_found_display_and_classifier() {
let err = ErrorReason::NotFound {
program: "my-tool".into(),
searched: Some("/usr/bin:/usr/local/bin".into()),
};
let display = err.to_string();
assert_eq!(display, "`my-tool` not found on PATH");
assert!(
!display.contains("/usr/bin"),
"Display must not expose PATH value: {display}"
);
assert!(err.is_not_found(), "NotFound must satisfy is_not_found()");
assert!(!err.is_permission_denied());
assert!(!err.is_transient());
assert_eq!(err.diagnostic(), None);
}
#[test]
fn not_found_without_path_search_omits_on_path() {
let err = ErrorReason::NotFound {
program: "/no/such/tool".into(),
searched: None,
};
assert_eq!(err.to_string(), "`/no/such/tool` not found");
assert!(err.is_not_found());
let bare = ErrorReason::NotFound {
program: "tool".into(),
searched: Some("/usr/bin".into()),
};
assert_eq!(bare.to_string(), "`tool` not found on PATH");
}
fn spawn(kind: std::io::ErrorKind) -> Error {
ErrorReason::Spawn {
program: "x".into(),
source: std::io::Error::from(kind),
}
.into()
}
#[test]
fn not_found_and_permission_denied_are_classified_on_spawn_and_io() {
use std::io::ErrorKind::{NotFound, PermissionDenied};
assert!(
ErrorReason::NotFound {
program: "x".into(),
searched: None,
}
.is_not_found()
);
assert!(!spawn(NotFound).is_not_found());
assert!(!ErrorReason::Io(std::io::Error::from(NotFound)).is_not_found());
assert!(!spawn(NotFound).is_permission_denied());
assert!(spawn(PermissionDenied).is_permission_denied());
assert!(!spawn(PermissionDenied).is_not_found());
assert!(!spawn(NotFound).is_transient());
assert!(!spawn(PermissionDenied).is_transient());
}
#[test]
fn transient_kinds_are_classified() {
for kind in [
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::WouldBlock,
std::io::ErrorKind::ResourceBusy,
std::io::ErrorKind::ExecutableFileBusy,
] {
assert!(spawn(kind).is_transient(), "{kind:?} should be transient");
assert!(
ErrorReason::Io(std::io::Error::from(kind)).is_transient(),
"{kind:?} (Io) should be transient"
);
}
}
#[cfg(unix)]
#[test]
fn etxtbsy_is_transient_on_unix() {
let err = ErrorReason::Spawn {
program: "busy".into(),
source: std::io::Error::from_raw_os_error(libc::ETXTBSY),
};
assert!(err.is_transient());
assert!(!err.is_not_found() && !err.is_permission_denied());
}
#[cfg(windows)]
#[test]
fn sharing_and_lock_violations_are_transient_on_windows() {
for code in [32, 33] {
let err = ErrorReason::Spawn {
program: "locked".into(),
source: std::io::Error::from_raw_os_error(code),
};
assert!(
err.is_transient(),
"raw os error {code} should be transient"
);
}
}
#[test]
fn classifiers_are_false_for_non_io_variants() {
let exit = ErrorReason::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "could not resolve host".into(),
stdout_bytes: None,
};
assert!(!exit.is_not_found() && !exit.is_permission_denied() && !exit.is_transient());
let timeout = ErrorReason::Timeout {
program: "x".into(),
timeout: Duration::from_secs(1),
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
};
assert!(
!timeout.is_transient(),
"Timeout is excluded from is_transient by design"
);
}
#[test]
fn spawn_not_found_stdin_and_parse_constructors_build_the_expected_variant() {
let spawn = Error::spawn("git", std::io::Error::from_raw_os_error(2));
match spawn.into_reason() {
ErrorReason::Spawn { program, source } => {
assert_eq!(program, "git");
assert_eq!(source.raw_os_error(), Some(2));
}
other => panic!("expected ErrorReason::Spawn, got {other:?}"),
}
let not_found = Error::not_found("my-tool", Some("/usr/bin".into()));
assert!(matches!(
not_found.reason(),
ErrorReason::NotFound { program, searched }
if program == "my-tool" && searched.as_deref() == Some("/usr/bin")
));
let stdin = Error::stdin("git", std::io::Error::from_raw_os_error(32));
match stdin.into_reason() {
ErrorReason::Stdin { program, source } => {
assert_eq!(program, "git");
assert_eq!(source.raw_os_error(), Some(32));
}
other => panic!("expected ErrorReason::Stdin, got {other:?}"),
}
let parse = Error::parse("git", "unexpected token");
assert!(matches!(
parse.reason(),
ErrorReason::Parse { program, message }
if program == "git" && message == "unexpected token"
));
}
fn output_too_large() -> ErrorReason {
ErrorReason::OutputTooLarge {
program: "noisy".into(),
max_lines: Some(100),
max_bytes: None,
total_lines: 250,
total_bytes: 9001,
}
}
#[test]
fn kind_pins_the_classification_for_every_error_reason_variant() {
use std::io::ErrorKind as IoKind;
assert_eq!(
ErrorReason::NotFound {
program: "x".into(),
searched: None,
}
.kind(),
ErrorKind::NotFound
);
assert_eq!(spawn(IoKind::NotFound).kind(), ErrorKind::Spawn);
assert_eq!(
spawn(IoKind::PermissionDenied).kind(),
ErrorKind::PermissionDenied
);
assert_eq!(
ErrorReason::Io(std::io::Error::from(IoKind::PermissionDenied)).kind(),
ErrorKind::PermissionDenied
);
assert_eq!(
ErrorReason::Io(std::io::Error::from(IoKind::BrokenPipe)).kind(),
ErrorKind::Other
);
assert_eq!(
ErrorReason::CassetteMiss {
program: "x".into()
}
.kind(),
ErrorKind::Other
);
assert_eq!(Error::exit("git", 1, "", "").kind(), ErrorKind::Exit);
assert_eq!(
Error::timeout("git", Duration::from_secs(1), "", "").kind(),
ErrorKind::Timeout
);
assert_eq!(output_too_large().kind(), ErrorKind::Other);
assert_eq!(
ErrorReason::NotReady {
program: "server".into(),
timeout: Duration::from_secs(1),
}
.kind(),
ErrorKind::Other
);
assert_eq!(
ErrorReason::Parse {
program: "jq".into(),
message: "boom".into(),
}
.kind(),
ErrorKind::Other
);
assert_eq!(
ErrorReason::Unsupported {
operation: "suspend".into(),
}
.kind(),
ErrorKind::Unsupported
);
assert_eq!(
ErrorReason::Cancelled {
program: "job".into(),
}
.kind(),
ErrorKind::Cancelled
);
assert_eq!(
Error::signalled("git", Some(9), "", "").kind(),
ErrorKind::Signalled
);
assert_eq!(
Error::stdin("git", std::io::Error::from(IoKind::Other)).kind(),
ErrorKind::Other
);
#[cfg(feature = "limits")]
assert_eq!(
ErrorReason::ResourceLimit {
kind: crate::limits::LimitKind::Memory,
reason: crate::limits::LimitReason::Unsupported,
detail: "no container".into(),
}
.kind(),
ErrorKind::ResourceLimit
);
}
#[test]
fn kind_matches_what_every_crate_error_constructor_produces() {
use std::io::ErrorKind as IoKind;
assert_eq!(Error::exit("git", 2, "o", "e").kind(), ErrorKind::Exit);
assert_eq!(
Error::timeout("git", Duration::from_secs(3), "o", "e").kind(),
ErrorKind::Timeout
);
assert_eq!(
Error::signalled("git", None, "o", "e").kind(),
ErrorKind::Signalled
);
assert_eq!(
Error::spawn("git", std::io::Error::from(IoKind::NotFound)).kind(),
ErrorKind::Spawn
);
assert_eq!(
Error::spawn("git", std::io::Error::from(IoKind::PermissionDenied)).kind(),
ErrorKind::PermissionDenied
);
assert_eq!(
Error::not_found("git", Some("/usr/bin".into())).kind(),
ErrorKind::NotFound
);
assert_eq!(
Error::stdin("git", std::io::Error::from(IoKind::BrokenPipe)).kind(),
ErrorKind::Other
);
assert_eq!(Error::parse("git", "boom").kind(), ErrorKind::Other);
assert_eq!(
Error::io(std::io::Error::from(IoKind::InvalidInput)).kind(),
ErrorKind::Other
);
assert_eq!(
Error::io(std::io::Error::from(IoKind::PermissionDenied)).kind(),
ErrorKind::PermissionDenied
);
assert_eq!(stdout_not_piped_error("git").kind(), ErrorKind::Other);
}
#[test]
fn kind_stays_consistent_with_the_is_classifiers() {
use std::io::ErrorKind as IoKind;
let cases: [Error; 6] = [
Error::not_found("x", None),
spawn(IoKind::PermissionDenied),
Error::timeout("x", Duration::from_secs(1), "", ""),
ErrorReason::Cancelled {
program: "x".into(),
}
.into(),
Error::signalled("x", None, "", ""),
Error::exit("x", 1, "", ""),
];
for err in &cases {
assert_eq!(err.is_not_found(), err.kind() == ErrorKind::NotFound);
assert_eq!(
err.is_permission_denied(),
err.kind() == ErrorKind::PermissionDenied
);
assert_eq!(err.is_timeout(), err.kind() == ErrorKind::Timeout);
assert_eq!(err.is_cancelled(), err.kind() == ErrorKind::Cancelled);
assert_eq!(err.is_signalled(), err.kind() == ErrorKind::Signalled);
}
}
#[test]
fn error_kind_name_is_a_stable_identifier_per_kind() {
assert_eq!(ErrorKind::NotFound.name(), "not_found");
assert_eq!(ErrorKind::Spawn.name(), "spawn");
assert_eq!(ErrorKind::PermissionDenied.name(), "permission_denied");
assert_eq!(ErrorKind::Unsupported.name(), "unsupported");
assert_eq!(ErrorKind::Timeout.name(), "timeout");
assert_eq!(ErrorKind::Cancelled.name(), "cancelled");
assert_eq!(ErrorKind::Exit.name(), "exit");
assert_eq!(ErrorKind::Signalled.name(), "signalled");
assert_eq!(ErrorKind::Other.name(), "other");
#[cfg(feature = "limits")]
assert_eq!(ErrorKind::ResourceLimit.name(), "resource_limit");
}
#[test]
fn timeout_duration_reads_only_the_run_timeout() {
let dur = Duration::from_millis(1500);
assert_eq!(
Error::timeout("git", dur, "", "").timeout_duration(),
Some(dur)
);
let not_ready = ErrorReason::NotReady {
program: "server".into(),
timeout: Duration::from_secs(9),
};
assert_eq!(not_ready.timeout_duration(), None);
assert!(!not_ready.is_timeout());
assert_eq!(Error::exit("git", 1, "", "").timeout_duration(), None);
assert_eq!(Error::not_found("git", None).timeout_duration(), None);
}
#[test]
fn output_overflow_snapshots_the_ceiling_counters() {
let overflow = output_too_large().output_overflow().expect("some");
assert_eq!(overflow.total_lines(), 250);
assert_eq!(overflow.total_bytes(), 9001);
assert_eq!(overflow.max_lines(), Some(100));
assert_eq!(overflow.max_bytes(), None);
assert_eq!(Error::exit("git", 1, "", "").output_overflow(), None);
assert_eq!(
Error::timeout("git", Duration::from_secs(1), "", "").output_overflow(),
None
);
}
#[test]
fn unsupported_operation_reads_only_the_unsupported_variant() {
let err = ErrorReason::Unsupported {
operation: "signal(Hup)".into(),
};
assert_eq!(err.unsupported_operation(), Some("signal(Hup)"));
assert_eq!(Error::exit("git", 1, "", "").unsupported_operation(), None);
assert_eq!(
ErrorReason::Cancelled {
program: "job".into()
}
.unsupported_operation(),
None
);
}
#[test]
fn error_wrapper_delegates_kind_and_payload_accessors_to_the_reason() {
let timeout: Error = ErrorReason::Timeout {
program: "git".into(),
timeout: Duration::from_secs(2),
stdout: String::new(),
stderr: String::new(),
stdout_bytes: None,
}
.into();
assert_eq!(timeout.kind(), timeout.reason().kind());
assert_eq!(timeout.kind(), ErrorKind::Timeout);
assert_eq!(
timeout.timeout_duration(),
timeout.reason().timeout_duration()
);
let overflow: Error = output_too_large().into();
assert_eq!(
overflow.output_overflow(),
overflow.reason().output_overflow()
);
assert_eq!(overflow.kind(), ErrorKind::Other);
let unsupported: Error = ErrorReason::Unsupported {
operation: "suspend".into(),
}
.into();
assert_eq!(
unsupported.unsupported_operation(),
unsupported.reason().unsupported_operation()
);
assert_eq!(unsupported.kind(), ErrorKind::Unsupported);
}
}