use std::time::Duration;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Outcome {
Exited(i32),
Signalled,
TimedOut,
}
#[derive(Debug, Clone)]
pub struct ProcessResult<T> {
program: String,
stdout: T,
stderr: String,
outcome: Outcome,
timeout: Option<Duration>,
duration: Duration,
truncated: bool,
ok_codes: Vec<i32>,
}
impl<T: PartialEq> PartialEq for ProcessResult<T> {
fn eq(&self, other: &Self) -> bool {
self.program == other.program
&& self.stdout == other.stdout
&& self.stderr == other.stderr
&& self.outcome == other.outcome
&& self.timeout == other.timeout
&& self.ok_codes == other.ok_codes
}
}
impl<T: Eq> Eq for ProcessResult<T> {}
impl<T> ProcessResult<T> {
pub(crate) fn new(
program: String,
stdout: T,
stderr: String,
code: Option<i32>,
timed_out: bool,
timeout: Option<Duration>,
) -> Self {
let outcome = match (code, timed_out) {
(_, true) => Outcome::TimedOut,
(Some(code), false) => Outcome::Exited(code),
(None, false) => Outcome::Signalled,
};
Self {
program,
stdout,
stderr,
outcome,
timeout,
duration: Duration::ZERO,
truncated: false,
ok_codes: vec![0],
}
}
pub fn program(&self) -> &str {
&self.program
}
pub fn stdout(&self) -> &T {
&self.stdout
}
pub fn into_stdout(self) -> T {
self.stdout
}
pub fn stderr(&self) -> &str {
&self.stderr
}
pub fn outcome(&self) -> Outcome {
self.outcome
}
pub fn code(&self) -> Option<i32> {
match self.outcome {
Outcome::Exited(code) => Some(code),
_ => None,
}
}
pub fn timed_out(&self) -> bool {
matches!(self.outcome, Outcome::TimedOut)
}
pub fn is_success(&self) -> bool {
matches!(self.outcome, Outcome::Exited(code) if self.ok_codes.contains(&code))
}
pub fn ensure_success(self) -> Result<Self, Error>
where
T: StdoutText,
{
match self.outcome {
Outcome::Exited(code) if self.ok_codes.contains(&code) => Ok(self),
Outcome::TimedOut => Err(Error::Timeout {
program: self.program.clone(),
timeout: self.timeout.unwrap_or_default(),
}),
Outcome::Signalled => Err(self.signal_error()),
Outcome::Exited(code) => Err(Error::Exit {
program: self.program.clone(),
code,
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
}
}
fn signal_error(&self) -> Error {
Error::Io(std::io::Error::other(format!(
"`{}` was terminated by a signal without an exit code",
self.program
)))
}
pub(crate) fn require_code(&self) -> Result<i32, Error> {
match self.outcome {
Outcome::Exited(code) => Ok(code),
Outcome::TimedOut => Err(Error::Timeout {
program: self.program.clone(),
timeout: self.timeout.unwrap_or_default(),
}),
Outcome::Signalled => Err(self.signal_error()),
}
}
pub fn duration(&self) -> Duration {
self.duration
}
pub fn truncated(&self) -> bool {
self.truncated
}
pub(crate) fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
pub(crate) fn with_truncated(mut self, truncated: bool) -> Self {
self.truncated = truncated;
self
}
pub(crate) fn with_ok_codes(mut self, ok_codes: Vec<i32>) -> Self {
if !ok_codes.is_empty() {
self.ok_codes = ok_codes;
}
self
}
}
impl ProcessResult<String> {
pub fn combined(&self) -> String {
format!("{}{}", self.stdout(), self.stderr())
}
pub fn diagnostic(&self) -> &str {
if self.stderr.trim().is_empty() {
self.stdout.trim()
} else {
self.stderr.trim()
}
}
}
#[doc(hidden)]
pub trait StdoutText {
fn as_text(&self) -> String;
}
impl StdoutText for String {
fn as_text(&self) -> String {
self.clone()
}
}
impl StdoutText for Vec<u8> {
fn as_text(&self) -> String {
String::from_utf8_lossy(self).into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outcome_reflects_the_three_terminal_states() {
let exited = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Some(3),
false,
None,
);
assert_eq!(exited.outcome(), Outcome::Exited(3));
let signalled =
ProcessResult::new("x".into(), String::new(), String::new(), None, false, None);
assert_eq!(signalled.outcome(), Outcome::Signalled);
let timed_out =
ProcessResult::new("x".into(), String::new(), String::new(), None, true, None);
assert_eq!(timed_out.outcome(), Outcome::TimedOut);
let both = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Some(0),
true,
None,
);
assert_eq!(both.outcome(), Outcome::TimedOut);
}
#[test]
fn derived_accessors_agree_with_outcome() {
for (code, timed_out) in [
(Some(0), false),
(Some(7), false),
(None, false),
(None, true),
] {
let r = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
code,
timed_out,
None,
);
match r.outcome() {
Outcome::Exited(c) => {
assert_eq!(r.code(), Some(c));
assert!(!r.timed_out());
assert_eq!(r.is_success(), c == 0);
}
Outcome::Signalled => {
assert_eq!(r.code(), None);
assert!(!r.timed_out());
assert!(!r.is_success());
}
Outcome::TimedOut => {
assert_eq!(r.code(), None);
assert!(r.timed_out());
assert!(!r.is_success());
}
}
}
}
#[test]
fn success_is_code_zero() {
let ok = ProcessResult::new(
"git".into(),
"out".to_owned(),
String::new(),
Some(0),
false,
None,
);
assert!(ok.is_success());
assert_eq!(ok.code(), Some(0));
assert!(ok.ensure_success().is_ok());
}
#[test]
fn nonzero_exit_carries_both_streams() {
let bad = ProcessResult::new(
"git".into(),
"CONFLICT (content): merge conflict in a.rs".to_owned(),
"boom".to_owned(),
Some(2),
false,
None,
);
assert!(!bad.is_success());
assert_eq!(bad.code(), Some(2));
let err = bad.ensure_success().unwrap_err();
match err {
Error::Exit {
program,
code,
stdout,
stderr,
} => {
assert_eq!(program, "git");
assert_eq!(code, 2);
assert_eq!(stdout, "CONFLICT (content): merge conflict in a.rs");
assert_eq!(stderr, "boom");
}
other => panic!("expected Exit, got {other:?}"),
}
}
#[test]
fn diagnostic_prefers_stderr_then_falls_back_to_stdout() {
let with_stderr = ProcessResult::new(
"git".into(),
"on stdout".into(),
" on stderr \n".into(),
Some(1),
false,
None,
);
assert_eq!(with_stderr.diagnostic(), "on stderr");
assert_eq!(
with_stderr.ensure_success().unwrap_err().diagnostic(),
Some("on stderr")
);
let conflict = ProcessResult::new(
"git".into(),
"CONFLICT (content): merge conflict in a.rs".into(),
" \n".into(),
Some(1),
false,
None,
);
assert_eq!(
conflict.diagnostic(),
"CONFLICT (content): merge conflict in a.rs"
);
let Error::Exit { .. } = conflict.clone().ensure_success().unwrap_err() else {
panic!("expected Exit");
};
assert_eq!(
conflict.ensure_success().unwrap_err().diagnostic(),
Some("CONFLICT (content): merge conflict in a.rs")
);
let silent = ProcessResult::new(
"git".into(),
String::new(),
" \n".into(),
Some(1),
false,
None,
);
assert_eq!(silent.ensure_success().unwrap_err().diagnostic(), None);
}
#[test]
fn timed_out_takes_precedence_over_exit_code() {
let timed = ProcessResult::new(
"git".into(),
"out".to_owned(),
String::new(),
None,
true,
Some(Duration::from_millis(500)),
);
assert!(timed.timed_out());
assert_eq!(timed.code(), None);
match timed.ensure_success().unwrap_err() {
Error::Timeout { program, timeout } => {
assert_eq!(program, "git");
assert_eq!(timeout, Duration::from_millis(500));
}
other => panic!("expected Timeout, got {other:?}"),
}
}
#[test]
fn signal_kill_has_no_code_and_never_yields_minus_one() {
let killed = ProcessResult::new(
"git".into(),
"out".to_owned(),
String::new(),
None,
false,
None,
);
assert_eq!(killed.code(), None);
assert!(!killed.is_success());
assert!(matches!(killed.require_code().unwrap_err(), Error::Io(_)));
match killed.ensure_success().unwrap_err() {
Error::Io(_) => {}
other => panic!("expected Io for a signal-kill, got {other:?}"),
}
}
#[test]
fn combined_concatenates_stdout_then_stderr() {
let r = ProcessResult::new(
"p".into(),
"out".to_owned(),
"err".to_owned(),
Some(0),
false,
None,
);
assert_eq!(r.combined(), "outerr");
}
#[test]
fn error_exit_carries_full_streams() {
let big = "x".repeat(10_000);
let bad = ProcessResult::new(
"p".into(),
big.clone().into_bytes(),
big.clone(),
Some(1),
false,
None,
);
assert_eq!(bad.stderr().len(), 10_000);
let Error::Exit { stdout, stderr, .. } = bad.ensure_success().unwrap_err() else {
panic!("expected Exit");
};
assert_eq!(stdout.len(), 10_000, "full stdout carried, untruncated");
assert_eq!(stderr.len(), 10_000, "full stderr carried, untruncated");
assert!(stdout.chars().all(|c| c == 'x'));
assert!(stderr.chars().all(|c| c == 'x'));
}
#[test]
fn ok_codes_widen_success_without_masking_other_failures() {
let one = ProcessResult::new(
"grep".into(),
"out".to_owned(),
String::new(),
Some(1),
false,
None,
)
.with_ok_codes(vec![0, 1]);
assert!(one.is_success(), "exit 1 is success when accepted");
assert!(one.ensure_success().is_ok());
let two = ProcessResult::new(
"grep".into(),
"out".to_owned(),
String::new(),
Some(2),
false,
None,
)
.with_ok_codes(vec![0, 1]);
assert!(!two.is_success(), "an unaccepted code is still a failure");
assert!(matches!(
two.ensure_success().unwrap_err(),
Error::Exit { code: 2, .. }
));
}
#[test]
fn new_defaults_zero_duration_untruncated_and_zero_only_success() {
let r = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Some(1),
false,
None,
);
assert_eq!(r.duration(), Duration::ZERO);
assert!(!r.truncated());
assert!(!r.is_success(), "the default accepts only code 0");
let stamped = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Some(0),
false,
None,
)
.with_duration(Duration::from_millis(5))
.with_truncated(true);
assert_eq!(stamped.duration(), Duration::from_millis(5));
assert!(stamped.truncated());
assert!(stamped.is_success());
}
#[test]
fn empty_ok_codes_is_ignored() {
let r = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Some(0),
false,
None,
)
.with_ok_codes(vec![]);
assert!(
r.is_success(),
"an empty accepted set falls back to the default [0]"
);
}
#[test]
fn equality_ignores_duration_and_truncation() {
let base = ProcessResult::new(
"p".into(),
"out".to_owned(),
String::new(),
Some(0),
false,
None,
);
let measured = base
.clone()
.with_duration(Duration::from_secs(5))
.with_truncated(true);
assert_eq!(
base, measured,
"duration and truncation are not part of identity"
);
let different = ProcessResult::new(
"p".into(),
"DIFF".to_owned(),
String::new(),
Some(0),
false,
None,
);
assert_ne!(base, different);
let widened = base.clone().with_ok_codes(vec![0, 1]);
assert_ne!(base, widened, "ok_codes is part of identity");
}
}