use std::time::Duration;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Outcome {
Exited(i32),
Signalled(Option<i32>),
TimedOut,
}
impl Outcome {
pub fn code(&self) -> Option<i32> {
match self {
Outcome::Exited(code) => Some(*code),
_ => None,
}
}
pub fn signal(&self) -> Option<i32> {
match self {
Outcome::Signalled(signal) => *signal,
_ => None,
}
}
pub fn timed_out(&self) -> bool {
matches!(self, Outcome::TimedOut)
}
}
#[must_use = "a ProcessResult carries the exit status; inspect is_success()/code()/ensure_success() or it is silently discarded"]
#[derive(Debug, Clone)]
pub struct ProcessResult<T> {
program: String,
stdout: T,
stderr: String,
outcome: Outcome,
timeout: Option<Duration>,
duration: Duration,
truncated: bool,
total_lines: usize,
total_bytes: usize,
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,
outcome: Outcome,
timeout: Option<Duration>,
) -> Self {
Self {
program,
stdout,
stderr,
outcome,
timeout,
duration: Duration::ZERO,
truncated: false,
total_lines: 0,
total_bytes: 0,
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> {
self.outcome.code()
}
pub fn timed_out(&self) -> bool {
self.outcome.timed_out()
}
pub fn signal(&self) -> Option<i32> {
self.outcome.signal()
}
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(),
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
Outcome::Signalled(signal) => Err(Error::Signalled {
program: self.program.clone(),
signal,
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
Outcome::Exited(code) => Err(Error::Exit {
program: self.program.clone(),
code,
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
}
}
pub(crate) fn require_code(&self) -> Result<i32, Error>
where
T: StdoutText,
{
match self.outcome {
Outcome::Exited(code) => Ok(code),
Outcome::TimedOut => Err(Error::Timeout {
program: self.program.clone(),
timeout: self.timeout.unwrap_or_default(),
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
Outcome::Signalled(signal) => Err(Error::Signalled {
program: self.program.clone(),
signal,
stdout: self.stdout.as_text(),
stderr: self.stderr.clone(),
}),
}
}
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_overflow_totals(mut self, total_lines: usize, total_bytes: usize) -> Self {
self.total_lines = total_lines;
self.total_bytes = total_bytes;
self
}
pub(crate) fn total_lines(&self) -> usize {
self.total_lines
}
pub(crate) fn total_bytes(&self) -> usize {
self.total_bytes
}
pub(crate) fn reject_if_truncated(
&self,
line_limit: Option<usize>,
byte_limit: Option<usize>,
) -> Result<(), Error> {
if self.truncated {
return Err(Error::OutputTooLarge {
program: self.program.clone(),
line_limit,
byte_limit,
total_lines: self.total_lines,
total_bytes: self.total_bytes,
});
}
Ok(())
}
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 {
let out = self.stdout();
let err = self.stderr();
if !out.is_empty() && !err.is_empty() && !out.ends_with('\n') {
format!("{out}\n{err}")
} else {
format!("{out}{err}")
}
}
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_accessors_match_each_variant() {
assert_eq!(Outcome::Exited(7).code(), Some(7));
assert_eq!(Outcome::Exited(7).signal(), None);
assert!(!Outcome::Exited(7).timed_out());
assert_eq!(Outcome::Signalled(Some(9)).signal(), Some(9));
assert_eq!(Outcome::Signalled(Some(9)).code(), None);
assert_eq!(Outcome::Signalled(None).signal(), None);
assert!(!Outcome::Signalled(None).timed_out());
assert!(Outcome::TimedOut.timed_out());
assert_eq!(Outcome::TimedOut.code(), None);
assert_eq!(Outcome::TimedOut.signal(), None);
let exited = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::Exited(2),
None,
);
assert_eq!(exited.outcome().code(), exited.code());
assert_eq!(exited.outcome().signal(), exited.signal());
assert_eq!(exited.outcome().timed_out(), exited.timed_out());
let killed = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::Signalled(Some(9)),
None,
);
assert_eq!(killed.signal(), Some(9));
assert_eq!(killed.code(), None);
}
#[test]
fn outcome_reflects_the_three_terminal_states() {
let exited = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::Exited(3),
None,
);
assert_eq!(exited.outcome(), Outcome::Exited(3));
let signalled = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::Signalled(None),
None,
);
assert_eq!(signalled.outcome(), Outcome::Signalled(None));
let timed_out = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::TimedOut,
None,
);
assert_eq!(timed_out.outcome(), Outcome::TimedOut);
}
#[test]
fn signalled_carries_signal_number() {
let killed = ProcessResult::new(
"git".into(),
"out".to_owned(),
String::new(),
Outcome::Signalled(Some(9)),
None,
);
assert_eq!(killed.code(), None);
assert!(!killed.timed_out());
assert!(!killed.is_success());
assert_eq!(killed.outcome(), Outcome::Signalled(Some(9)));
match killed.ensure_success().unwrap_err() {
Error::Signalled {
program,
signal,
stdout,
..
} => {
assert_eq!(program, "git");
assert_eq!(signal, Some(9));
assert_eq!(stdout, "out");
}
other => panic!("expected Signalled, got {other:?}"),
}
}
#[test]
fn derived_accessors_agree_with_outcome() {
for outcome in [
Outcome::Exited(0),
Outcome::Exited(7),
Outcome::Signalled(None),
Outcome::TimedOut,
] {
let r = ProcessResult::new("x".into(), String::new(), String::new(), outcome, 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(),
Outcome::Exited(0),
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(),
Outcome::Exited(2),
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(),
Outcome::Exited(1),
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(),
Outcome::Exited(1),
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(),
Outcome::Exited(1),
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(),
Outcome::TimedOut,
Some(Duration::from_millis(500)),
);
assert!(timed.timed_out());
assert_eq!(timed.code(), None);
match timed.ensure_success().unwrap_err() {
Error::Timeout {
program,
timeout,
stdout,
..
} => {
assert_eq!(program, "git");
assert_eq!(timeout, Duration::from_millis(500));
assert_eq!(stdout, "out");
}
other => panic!("expected Timeout, got {other:?}"),
}
}
#[test]
fn signal_kill_surfaces_as_signalled_error() {
let killed = ProcessResult::new(
"git".into(),
"out".to_owned(),
String::new(),
Outcome::Signalled(None),
None,
);
assert_eq!(killed.code(), None);
assert!(!killed.is_success());
assert!(matches!(
killed.require_code().unwrap_err(),
Error::Signalled { .. }
));
match killed.ensure_success().unwrap_err() {
Error::Signalled {
program, signal, ..
} => {
assert_eq!(program, "git");
assert_eq!(signal, None);
}
other => panic!("expected Signalled 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(),
Outcome::Exited(0),
None,
);
assert_eq!(r.combined(), "out\nerr");
}
#[test]
fn combined_no_extra_newline_when_stdout_already_ends_with_newline() {
let r = ProcessResult::new(
"p".into(),
"out\n".to_owned(),
"err".to_owned(),
Outcome::Exited(0),
None,
);
assert_eq!(r.combined(), "out\nerr");
}
#[test]
fn combined_no_separator_when_one_stream_is_empty() {
let stdout_only = ProcessResult::new(
"p".into(),
"out".to_owned(),
String::new(),
Outcome::Exited(0),
None,
);
assert_eq!(stdout_only.combined(), "out");
let stderr_only = ProcessResult::new(
"p".into(),
String::new(),
"err".to_owned(),
Outcome::Exited(0),
None,
);
assert_eq!(stderr_only.combined(), "err");
}
#[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(),
Outcome::Exited(1),
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(),
Outcome::Exited(1),
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(),
Outcome::Exited(2),
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(),
Outcome::Exited(1),
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(),
Outcome::Exited(0),
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 checking_verbs_reject_truncated_output() {
let truncated = ProcessResult::new(
"p".into(),
"tail".to_owned(),
String::new(),
Outcome::Exited(0),
None,
)
.with_truncated(true)
.with_overflow_totals(5000, 1_000_000);
match truncated.reject_if_truncated(Some(100), None) {
Err(Error::OutputTooLarge {
program,
line_limit,
byte_limit,
total_lines,
total_bytes,
}) => {
assert_eq!(program, "p");
assert_eq!(line_limit, Some(100));
assert_eq!(byte_limit, None);
assert_eq!(total_lines, 5000);
assert_eq!(total_bytes, 1_000_000);
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
let complete = ProcessResult::new(
"p".into(),
"full".to_owned(),
String::new(),
Outcome::Exited(0),
None,
);
assert!(complete.reject_if_truncated(Some(100), None).is_ok());
}
#[test]
fn empty_ok_codes_is_ignored() {
let r = ProcessResult::new(
"x".into(),
String::new(),
String::new(),
Outcome::Exited(0),
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(),
Outcome::Exited(0),
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(),
Outcome::Exited(0),
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");
}
}