use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Output, Stdio};
use anyhow::{Context as _, Result};
use crate::log::StageLogger;
pub fn run_checked(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
if log.is_verbose() {
run_streamed(cmd, log, label)
} else {
let output = cmd
.output()
.with_context(|| format!("failed to spawn {label}"))?;
log.check_output(output, label)
}
}
pub fn run_checked_with_stdin(
cmd: &mut Command,
stdin: &[u8],
log: &StageLogger,
label: &str,
) -> Result<Output> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_inner(cmd, Some(stdin), log, label)
}
fn run_streamed(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
run_inner(cmd, None, log, label)
}
fn run_inner(
cmd: &mut Command,
stdin: Option<&[u8]>,
log: &StageLogger,
label: &str,
) -> Result<Output> {
let verbose = log.is_verbose();
let mut child = cmd
.spawn()
.with_context(|| format!("failed to spawn {label}"))?;
let child_stdin = match stdin {
Some(_) => Some(
child
.stdin
.take()
.with_context(|| format!("{label}: child has no stdin pipe"))?,
),
None => None,
};
let child_stdout = child
.stdout
.take()
.with_context(|| format!("{label}: child has no stdout pipe"))?;
let child_stderr = child
.stderr
.take()
.with_context(|| format!("{label}: child has no stderr pipe"))?;
let mut out_buf: Vec<u8> = Vec::new();
let mut err_buf: Vec<u8> = Vec::new();
let mut stdin_err: Option<std::io::Error> = None;
std::thread::scope(|s| {
let stdin_handle = child_stdin.map(|mut pipe| {
let bytes = stdin.expect("child_stdin is Some only when stdin is Some");
s.spawn(move || -> std::io::Result<()> {
pipe.write_all(bytes)?;
Ok(())
})
});
let out_handle = s.spawn(|| tee_stream(child_stdout, log, false, verbose));
let err_handle = s.spawn(|| tee_stream(child_stderr, log, true, verbose));
out_buf = join_capture(out_handle, log, "stdout");
err_buf = join_capture(err_handle, log, "stderr");
if let Some(h) = stdin_handle {
match h.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => stdin_err = Some(e),
Err(_) => log.warn(&format!("{label}: stdin writer thread panicked")),
}
}
});
if let Some(e) = stdin_err
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(anyhow::Error::new(e).context(format!("{label}: failed to write stdin")));
}
let status = child
.wait()
.with_context(|| format!("{label}: failed to wait for child"))?;
let output = Output {
status,
stdout: out_buf,
stderr: err_buf,
};
if verbose {
log.check_output_streamed(output, label)
} else {
log.check_output(output, label)
}
}
fn join_capture(
handle: std::thread::ScopedJoinHandle<'_, Vec<u8>>,
log: &StageLogger,
stream: &str,
) -> Vec<u8> {
match handle.join() {
Ok(buf) => buf,
Err(_) => {
log.warn(&format!(
"internal: {stream} capture thread panicked; output for this step is lost"
));
Vec::new()
}
}
}
fn tee_stream<R: std::io::Read>(
reader: R,
log: &StageLogger,
is_stderr: bool,
tee: bool,
) -> Vec<u8> {
let mut buf = BufReader::new(reader);
let mut capture: Vec<u8> = Vec::new();
let mut line: Vec<u8> = Vec::new();
loop {
line.clear();
match buf.read_until(b'\n', &mut line) {
Ok(0) => break,
Ok(_) => {
capture.extend_from_slice(&line);
if tee {
let text = String::from_utf8_lossy(&line);
let stripped = text.trim_end_matches(['\n', '\r']);
if is_stderr {
log.stream_child_stderr(stripped);
} else {
log.stream_child_stdout(stripped);
}
}
}
Err(_) => break,
}
}
capture
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::{LogLevel, StageLogger, Verbosity};
fn sh(script: &str) -> Command {
let mut c = Command::new("sh");
c.arg("-c").arg(script);
c
}
fn count_level(cap: &crate::log::LogCapture, level: LogLevel) -> usize {
cap.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == level)
.count()
}
#[test]
fn run_checked_success_is_silent_at_default_verbosity() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_checked(&mut sh("echo hi"), &log, "echo").expect("echo must succeed");
assert!(
String::from_utf8_lossy(&out.stdout).contains("hi"),
"captured stdout must contain the child's output"
);
assert_eq!(
cap.status_count(),
0,
"default-verbosity success must emit no status lines"
);
assert_eq!(
count_level(&cap, LogLevel::Verbose),
0,
"default-verbosity success must emit no verbose lines"
);
assert_eq!(cap.error_count(), 0, "success must emit no error lines");
}
#[test]
fn run_checked_failure_embeds_child_stderr() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let err = run_checked(&mut sh("echo boom >&2; exit 3"), &log, "boomer")
.expect_err("non-zero exit must surface as Err");
let chain = format!("{err:#}");
assert!(
chain.contains("boom"),
"error must embed the child's stderr; got: {chain}"
);
assert!(
chain.contains("exit code: 3"),
"error must name the exit code; got: {chain}"
);
}
#[test]
fn run_checked_verbose_emits_stdout_line() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
run_checked(&mut sh("echo hi"), &log, "echo").expect("echo must succeed");
let verbose: Vec<_> = cap
.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == LogLevel::Verbose)
.collect();
assert!(
verbose.iter().any(|(_, msg)| msg.contains("hi")),
"verbose run must record a verbose line containing the child's stdout; got: {verbose:?}"
);
}
#[test]
fn run_checked_verbose_failure_no_double_emit() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let _ = run_checked(&mut sh("echo BOOMTOKEN >&2; exit 1"), &log, "boomer")
.expect_err("non-zero exit must surface as Err");
let hits = cap
.all_messages()
.into_iter()
.filter(|(_, msg)| msg.contains("BOOMTOKEN"))
.count();
assert_eq!(
hits, 1,
"verbose failure must surface its stderr exactly once (no double-emit)"
);
}
#[test]
fn run_checked_with_stdin_roundtrips() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_checked_with_stdin(&mut Command::new("cat"), b"piped-in\n", &log, "cat")
.expect("cat must succeed");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
"piped-in",
"cat must echo the piped stdin back on stdout"
);
}
#[test]
fn run_checked_with_stdin_verbose_roundtrips() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let out = run_checked_with_stdin(&mut Command::new("cat"), b"streamed-in\n", &log, "cat")
.expect("cat must succeed");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
"streamed-in",
"verbose stdin path must also round-trip the piped input"
);
let verbose: Vec<_> = cap
.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == LogLevel::Verbose)
.collect();
assert!(
verbose.iter().any(|(_, msg)| msg.contains("streamed-in")),
"verbose stdin run must tee the child's stdout; got: {verbose:?}"
);
}
fn big_stdin() -> Vec<u8> {
let mut v = vec![b'A'; 192 * 1024];
v.push(b'\n');
v
}
#[test]
fn run_checked_with_stdin_large_in_and_out_no_deadlock() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let stdin = big_stdin();
let out = run_checked_with_stdin(
&mut sh("cat; i=0; while [ $i -lt 100000 ]; do echo line$i; i=$((i+1)); done"),
&stdin,
&log,
"bigcat",
)
.expect("large in/out child must complete without hanging");
assert!(
out.stdout.len() > stdin.len() + 100_000,
"captured stdout must include the echoed stdin AND the generated lines; \
got {} bytes",
out.stdout.len()
);
assert!(
out.stdout.windows(3).any(|w| w == b"AAA"),
"echoed stdin must be present in captured stdout"
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("line99999"),
"the last generated stdout line must be captured"
);
}
#[test]
fn run_checked_with_stdin_large_in_and_out_no_deadlock_verbose() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let stdin = big_stdin();
let out = run_checked_with_stdin(
&mut sh("cat; i=0; while [ $i -lt 100000 ]; do echo line$i; i=$((i+1)); done"),
&stdin,
&log,
"bigcat",
)
.expect("verbose large in/out child must complete without hanging");
assert!(
String::from_utf8_lossy(&out.stdout).contains("line99999"),
"verbose path must capture the full stdout"
);
}
}