use std::fmt;
use std::io::{self, BufWriter, Write};
use std::sync::Mutex;
struct SinkInner {
file: Option<BufWriter<std::fs::File>>,
error: Option<io::Error>,
wrote: bool,
stdout: StdoutState,
}
enum StdoutState {
Open,
Closed,
Failed(io::Error),
}
static SINK: Mutex<SinkInner> = Mutex::new(SinkInner {
file: None,
error: None,
wrote: false,
stdout: StdoutState::Open,
});
fn lock() -> std::sync::MutexGuard<'static, SinkInner> {
SINK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn set_file_sink(file: std::fs::File) {
let mut inner = lock();
inner.file = Some(BufWriter::new(file));
inner.error = None;
inner.wrote = false;
}
pub fn is_redirected() -> bool {
lock().file.is_some()
}
pub fn wrote() -> bool {
lock().wrote
}
pub fn flush() -> io::Result<()> {
let mut inner = lock();
if let Some(error) = inner.error.take() {
return Err(error);
}
match inner.file.as_mut() {
Some(writer) => writer.flush(),
None => Ok(()),
}
}
pub fn write_fmt_line(args: fmt::Arguments<'_>) {
let mut inner = lock();
if inner.error.is_some() {
return;
}
let Some(writer) = inner.file.as_mut() else {
write_stdout_line(&mut inner, args);
return;
};
let result = writeln!(writer, "{args}");
inner.wrote = true;
if let Err(error) = result {
inner.error = Some(error);
}
}
pub fn write_stdout_fmt_line(args: fmt::Arguments<'_>) {
let mut inner = lock();
write_stdout_line(&mut inner, args);
}
fn write_stdout_line(inner: &mut SinkInner, args: fmt::Arguments<'_>) {
if !matches!(inner.stdout, StdoutState::Open) {
return;
}
let mut line = fmt::format(args);
line.push('\n');
let stdout = io::stdout();
let mut handle = stdout.lock();
match write_all_retrying(&mut handle, line.as_bytes(), wait_until_stdout_writable) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => {
inner.stdout = StdoutState::Closed;
}
Err(error) => inner.stdout = StdoutState::Failed(error),
}
}
fn write_all_retrying<W: Write>(
writer: &mut W,
mut buf: &[u8],
wait: impl Fn() -> io::Result<()>,
) -> io::Result<()> {
while !buf.is_empty() {
match writer.write(buf) {
Ok(0) => return Err(io::ErrorKind::WriteZero.into()),
Ok(written) => buf = &buf[written..],
Err(error) => retry_or_fail(error, &wait)?,
}
}
loop {
match writer.flush() {
Ok(()) => return Ok(()),
Err(error) => retry_or_fail(error, &wait)?,
}
}
}
fn retry_or_fail(error: io::Error, wait: &impl Fn() -> io::Result<()>) -> io::Result<()> {
match error.kind() {
io::ErrorKind::Interrupted => Ok(()),
io::ErrorKind::WouldBlock => wait(),
_ => Err(error),
}
}
#[cfg(unix)]
fn wait_until_stdout_writable() -> io::Result<()> {
use rustix::event::{PollFd, PollFlags, poll};
use std::os::fd::AsFd;
let stdout = io::stdout();
let fd = stdout.as_fd();
let mut fds = [PollFd::new(&fd, PollFlags::OUT)];
match poll(&mut fds, None) {
Ok(_) | Err(rustix::io::Errno::INTR) => Ok(()),
Err(errno) => Err(errno.into()),
}
}
#[cfg(not(unix))]
#[expect(
clippy::unnecessary_wraps,
reason = "the signature matches the unix version, which can return a poll error"
)]
fn wait_until_stdout_writable() -> io::Result<()> {
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(1);
std::thread::sleep(RETRY_DELAY);
Ok(())
}
pub fn finish_stdout(code: std::process::ExitCode) -> std::process::ExitCode {
let mut inner = lock();
if !matches!(inner.stdout, StdoutState::Failed(_)) {
return code;
}
let StdoutState::Failed(error) = std::mem::replace(&mut inner.stdout, StdoutState::Closed)
else {
return code;
};
drop(inner);
eprintln!("Error: failed to write the report to stdout: {error}");
std::process::ExitCode::from(2)
}
macro_rules! outln {
() => {
$crate::report::sink::write_fmt_line(::std::format_args!(""))
};
($($arg:tt)*) => {
$crate::report::sink::write_fmt_line(::std::format_args!($($arg)*))
};
}
pub(crate) use outln;
macro_rules! stdoutln {
($($arg:tt)*) => {
$crate::report::sink::write_stdout_fmt_line(::std::format_args!($($arg)*))
};
}
pub(crate) use stdoutln;
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
static TEST_GUARD: Mutex<()> = Mutex::new(());
fn reset() {
let mut inner = lock();
inner.file = None;
inner.error = None;
}
struct ChokedWriter {
data: Vec<u8>,
chunk: usize,
calls: usize,
error_kind: io::ErrorKind,
flushed: bool,
}
impl Write for ChokedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.calls += 1;
if self.calls % 2 == 1 {
return Err(self.error_kind.into());
}
let len = buf.len().min(self.chunk);
self.data.extend_from_slice(&buf[..len]);
Ok(len)
}
fn flush(&mut self) -> io::Result<()> {
self.flushed = true;
Ok(())
}
}
fn choked(error_kind: io::ErrorKind) -> ChokedWriter {
ChokedWriter {
data: Vec::new(),
chunk: 7,
calls: 0,
error_kind,
flushed: false,
}
}
#[test]
fn retries_would_block_until_all_bytes_are_written() {
let payload: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
let mut writer = choked(io::ErrorKind::WouldBlock);
let waits = std::cell::Cell::new(0);
write_all_retrying(&mut writer, &payload, || {
waits.set(waits.get() + 1);
Ok(())
})
.expect("write succeeds after retries");
assert_eq!(writer.data, payload);
assert!(writer.flushed);
assert!(waits.get() > 0, "WouldBlock must call the wait function");
}
#[test]
fn retries_interrupted_without_waiting() {
let mut writer = choked(io::ErrorKind::Interrupted);
write_all_retrying(&mut writer, b"hello world", || {
panic!("Interrupted must not call the wait function")
})
.expect("write succeeds after retries");
assert_eq!(writer.data, b"hello world");
}
#[test]
fn returns_other_write_errors() {
let mut writer = choked(io::ErrorKind::BrokenPipe);
let error = write_all_retrying(&mut writer, b"x", || Ok(())).expect_err("error");
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn returns_the_wait_error() {
let mut writer = choked(io::ErrorKind::WouldBlock);
let error = write_all_retrying(&mut writer, b"x", || Err(io::Error::other("poll failed")))
.expect_err("error");
assert_eq!(error.to_string(), "poll failed");
}
#[test]
fn redirects_content_to_file_and_reports_flush_state() {
let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
reset();
assert!(!is_redirected());
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("out.txt");
let file = std::fs::File::create(&path).expect("create");
set_file_sink(file);
assert!(is_redirected());
outln!("line one");
outln!("end");
flush().expect("flush ok");
let mut contents = String::new();
std::fs::File::open(&path)
.expect("open")
.read_to_string(&mut contents)
.expect("read");
assert_eq!(contents, "line one\nend\n");
assert!(!contents.contains('\u{1b}'), "no ANSI escapes in file");
reset();
assert!(!is_redirected());
}
#[test]
fn flush_is_ok_when_writing_to_stdout() {
let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
reset();
assert!(flush().is_ok());
}
}