use std::time::Duration;
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessResult<T> {
program: String,
stdout: T,
stderr: String,
code: Option<i32>,
timed_out: bool,
timeout: Option<Duration>,
}
impl<T> ProcessResult<T> {
pub(crate) fn new(
program: String,
stdout: T,
stderr: String,
code: Option<i32>,
timed_out: bool,
timeout: Option<Duration>,
) -> Self {
Self {
program,
stdout,
stderr,
code,
timed_out,
timeout,
}
}
pub fn stdout(&self) -> &T {
&self.stdout
}
pub fn into_stdout(self) -> T {
self.stdout
}
pub fn stderr(&self) -> &str {
&self.stderr
}
pub fn code(&self) -> Option<i32> {
self.code
}
pub fn timed_out(&self) -> bool {
self.timed_out
}
pub fn is_success(&self) -> bool {
self.code == Some(0)
}
pub fn ensure_success(self) -> Result<Self, Error>
where
T: StdoutText,
{
if let Some(err) = self.timeout_error() {
return Err(err);
}
match self.code {
Some(0) => Ok(self),
None => Err(self.signal_error()),
Some(code) => Err(Error::Exit {
program: self.program.clone(),
code,
stdout: truncate_output(&self.stdout.as_text()),
stderr: truncate_output(&self.stderr),
}),
}
}
pub(crate) fn timeout_error(&self) -> Option<Error> {
self.timed_out.then(|| Error::Timeout {
program: self.program.clone(),
timeout: self.timeout.unwrap_or_default(),
})
}
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> {
if let Some(err) = self.timeout_error() {
return Err(err);
}
self.code.ok_or_else(|| self.signal_error())
}
}
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()
}
}
fn truncate_output(text: &str) -> String {
const MAX: usize = 4 * 1024;
if text.len() <= MAX {
return text.to_owned();
}
let mut end = MAX;
while !text.is_char_boundary(end) {
end -= 1;
}
format!("{}… (truncated)", &text[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[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 output_is_truncated_in_error_only() {
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!(stderr.len() < 10_000 && stderr.ends_with("… (truncated)"));
assert!(stdout.len() < 10_000 && stdout.ends_with("… (truncated)"));
}
}