#![forbid(unsafe_code)]
use std::io::{self, Write};
use std::sync::Mutex;
use std::sync::mpsc;
static CAPTURE_TX: Mutex<Option<mpsc::Sender<Vec<u8>>>> = Mutex::new(None);
#[derive(Debug)]
pub enum StdioCaptureError {
AlreadyInstalled,
PoisonedLock,
}
impl std::fmt::Display for StdioCaptureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyInstalled => write!(f, "stdio capture is already installed"),
Self::PoisonedLock => write!(f, "stdio capture lock was poisoned"),
}
}
}
impl std::error::Error for StdioCaptureError {}
pub struct StdioCapture {
rx: mpsc::Receiver<Vec<u8>>,
}
impl std::fmt::Debug for StdioCapture {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StdioCapture")
.field("installed", &true)
.finish()
}
}
impl StdioCapture {
pub fn install() -> Result<Self, StdioCaptureError> {
let mut guard = CAPTURE_TX
.lock()
.map_err(|_| StdioCaptureError::PoisonedLock)?;
if guard.is_some() {
return Err(StdioCaptureError::AlreadyInstalled);
}
let (tx, rx) = mpsc::channel();
*guard = Some(tx);
Ok(Self { rx })
}
pub fn is_installed() -> bool {
CAPTURE_TX.lock().map(|g| g.is_some()).unwrap_or(false)
}
pub fn drain<W: Write>(&self, sink: &mut W) -> io::Result<usize> {
let mut total = 0;
while let Ok(bytes) = self.rx.try_recv() {
sink.write_all(&bytes)?;
total += bytes.len();
}
Ok(total)
}
pub fn drain_to_string(&self) -> String {
let mut buf = Vec::new();
let _ = self.drain(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
}
}
impl Drop for StdioCapture {
fn drop(&mut self) {
if let Ok(mut guard) = CAPTURE_TX.lock() {
*guard = None;
}
while self.rx.try_recv().is_ok() {}
}
}
pub fn try_capture(bytes: &[u8]) -> bool {
let Ok(guard) = CAPTURE_TX.lock() else {
return false;
};
if let Some(ref tx) = *guard {
let _ = tx.send(bytes.to_vec());
return true;
}
false
}
pub struct CapturedWriter;
impl Write for CapturedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
try_capture(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[macro_export]
macro_rules! ftui_println {
() => {
$crate::ftui_println!("")
};
($($arg:tt)*) => {{
let msg = ::std::format!("{}\n", ::std::format_args!($($arg)*));
if !$crate::stdio_capture::try_capture(msg.as_bytes()) {
::std::print!("{}", msg);
}
}};
}
#[macro_export]
macro_rules! ftui_eprintln {
() => {
$crate::ftui_eprintln!("")
};
($($arg:tt)*) => {{
let msg = ::std::format!("{}\n", ::std::format_args!($($arg)*));
if !$crate::stdio_capture::try_capture(msg.as_bytes()) {
::std::eprint!("{}", msg);
}
}};
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(mut g) = CAPTURE_TX.lock() {
*g = None;
}
guard
}
#[test]
fn install_and_drop_lifecycle() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
assert!(StdioCapture::is_installed());
drop(capture);
assert!(!StdioCapture::is_installed());
}
#[test]
fn double_install_returns_error() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let result = StdioCapture::install();
assert!(matches!(
result.unwrap_err(),
StdioCaptureError::AlreadyInstalled
));
drop(capture);
}
#[test]
fn reinstall_after_drop() {
let _g = serial();
{
let _c = StdioCapture::install().unwrap();
}
let capture = StdioCapture::install().unwrap();
assert!(StdioCapture::is_installed());
drop(capture);
}
#[test]
fn try_capture_without_install_returns_false() {
let _g = serial();
assert!(!try_capture(b"hello"));
}
#[test]
fn try_capture_with_install_returns_true() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
assert!(try_capture(b"hello"));
drop(capture);
}
#[test]
fn drain_returns_captured_bytes() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
try_capture(b"hello ");
try_capture(b"world\n");
let mut sink = Vec::new();
let bytes = capture.drain(&mut sink).unwrap();
assert_eq!(bytes, 12); assert_eq!(&sink, b"hello world\n");
drop(capture);
}
#[test]
fn drain_to_string_works() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
try_capture(b"test message\n");
let output = capture.drain_to_string();
assert_eq!(output, "test message\n");
drop(capture);
}
#[test]
fn drain_empty_returns_zero() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let mut sink = Vec::new();
let bytes = capture.drain(&mut sink).unwrap();
assert_eq!(bytes, 0);
assert!(sink.is_empty());
drop(capture);
}
#[test]
fn multiple_drains_are_incremental() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
try_capture(b"first\n");
let s1 = capture.drain_to_string();
assert_eq!(s1, "first\n");
try_capture(b"second\n");
let s2 = capture.drain_to_string();
assert_eq!(s2, "second\n");
let s3 = capture.drain_to_string();
assert!(s3.is_empty());
drop(capture);
}
#[test]
fn captured_writer_implements_write() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let mut w = CapturedWriter;
write!(w, "via writer").unwrap();
let output = capture.drain_to_string();
assert_eq!(output, "via writer");
drop(capture);
}
#[test]
fn captured_writer_without_install_is_silent() {
let _g = serial();
let mut w = CapturedWriter;
let result = write!(w, "discarded");
assert!(result.is_ok()); }
#[test]
fn ftui_println_macro_captures() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
ftui_println!("formatted: {}", 42);
let output = capture.drain_to_string();
assert_eq!(output, "formatted: 42\n");
drop(capture);
}
#[test]
fn ftui_eprintln_macro_captures() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
ftui_eprintln!("error: {}", "oops");
let output = capture.drain_to_string();
assert_eq!(output, "error: oops\n");
drop(capture);
}
#[test]
fn ftui_println_empty() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
ftui_println!();
let output = capture.drain_to_string();
assert_eq!(output, "\n");
drop(capture);
}
#[test]
fn concurrent_writers() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let handles: Vec<_> = (0..4)
.map(|i| {
std::thread::spawn(move || {
for j in 0..10 {
let msg = format!("thread-{i}-msg-{j}\n");
try_capture(msg.as_bytes());
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let output = capture.drain_to_string();
let line_count = output.lines().count();
assert_eq!(
line_count, 40,
"Expected 40 lines from 4 threads x 10 messages, got {line_count}"
);
for i in 0..4 {
for j in 0..10 {
assert!(
output.contains(&format!("thread-{i}-msg-{j}")),
"Missing thread-{i}-msg-{j}"
);
}
}
drop(capture);
}
#[test]
fn drop_cleans_up_remaining_messages() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
try_capture(b"orphaned message\n");
drop(capture);
let capture2 = StdioCapture::install().unwrap();
let output = capture2.drain_to_string();
assert!(
output.is_empty(),
"New capture should not see messages from previous"
);
drop(capture2);
}
#[test]
fn error_display() {
let e = StdioCaptureError::AlreadyInstalled;
assert_eq!(e.to_string(), "stdio capture is already installed");
let e = StdioCaptureError::PoisonedLock;
assert_eq!(e.to_string(), "stdio capture lock was poisoned");
}
#[test]
fn binary_data_captured() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let binary = vec![0u8, 1, 2, 255, 254, 253];
try_capture(&binary);
let mut sink = Vec::new();
capture.drain(&mut sink).unwrap();
assert_eq!(sink, binary);
drop(capture);
}
#[test]
fn large_message_captured() {
let _g = serial();
let capture = StdioCapture::install().unwrap();
let large = "x".repeat(1_000_000);
try_capture(large.as_bytes());
let output = capture.drain_to_string();
assert_eq!(output.len(), 1_000_000);
drop(capture);
}
}