use std::cell::RefCell;
use std::io::Write;
use std::sync::Once;
thread_local! {
static CAPTURE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
}
struct ThreadCapture;
impl Write for ThreadCapture {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let _ = CAPTURE.try_with(|slot| {
if let Some(capture) = slot.borrow_mut().as_mut() {
capture.extend_from_slice(buf);
}
});
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn install() {
static INSTALLED: Once = Once::new();
INSTALLED.call_once(|| {
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_ansi(false)
.with_writer(|| ThreadCapture)
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("the capturing subscriber must be this test process's only global default");
tracing::callsite::rebuild_interest_cache();
});
}
#[derive(Debug)]
pub(crate) struct Captured(String);
impl Captured {
#[track_caller]
pub(crate) fn as_str(&self) -> &str {
assert_captured(self);
&self.0
}
#[track_caller]
pub(crate) fn assert_not_contains(&self, needle: &str) {
let logs = self.as_str();
assert!(
!logs.contains(needle),
"captured logs must not contain {needle:?}: {logs}"
);
}
}
struct ClearCaptureOnDrop;
impl Drop for ClearCaptureOnDrop {
fn drop(&mut self) {
let _ = CAPTURE.try_with(|slot| {
slot.borrow_mut().take();
});
}
}
pub(crate) fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Captured) {
install();
CAPTURE.with(|slot| {
let mut slot = slot.borrow_mut();
debug_assert!(
slot.is_none(),
"capture_logs does not nest: the inner capture would swallow the outer one"
);
*slot = Some(Vec::new());
});
let guard = ClearCaptureOnDrop;
let value = f();
let captured = CAPTURE
.with(|slot| slot.borrow_mut().take())
.unwrap_or_default();
drop(guard);
let logs = String::from_utf8(captured).expect("captured logs are UTF-8");
(value, Captured(logs))
}
#[track_caller]
pub(crate) fn assert_captured(logs: &Captured) {
assert!(
!logs.0.is_empty(),
"captured nothing: the subscriber saw no events"
);
}
#[track_caller]
pub(crate) fn assert_logged(logs: &Captured, needle: &str) {
let logs = logs.as_str();
assert!(
logs.contains(needle),
"captured logs lack {needle:?}: {logs}"
);
}
#[cfg(test)]
mod tests {
use super::*;
const PROBE: &str = "testlog-interest-probe";
fn probe() {
tracing::info!("{PROBE}");
}
#[test]
fn a_subscriberless_thread_cannot_silence_a_later_capture() {
let (_, logs) = capture_logs(|| {
std::thread::spawn(probe).join().expect("probe thread");
probe();
});
assert_logged(&logs, PROBE);
assert_eq!(
logs.as_str().matches(PROBE).count(),
1,
"only this thread's events belong in the capture: {}",
logs.as_str()
);
}
#[test]
#[should_panic(expected = "captured nothing")]
fn an_empty_capture_fails_as_an_empty_capture() {
assert_logged(&Captured(String::new()), "anything");
}
#[test]
#[should_panic(expected = "captured logs lack")]
fn a_non_empty_capture_without_the_needle_fails_as_a_missing_needle() {
assert_logged(&Captured("something else entirely".into()), "anything");
}
#[test]
#[should_panic(expected = "captured nothing")]
fn as_str_on_an_empty_capture_fails_as_an_empty_capture() {
let _ = Captured(String::new()).as_str();
}
#[test]
#[should_panic(expected = "captured nothing")]
fn assert_not_contains_on_an_empty_capture_fails_as_an_empty_capture() {
Captured(String::new()).assert_not_contains("secret");
}
#[test]
#[should_panic(expected = "captured logs must not contain")]
fn assert_not_contains_fails_when_the_needle_is_present() {
Captured("leaked-token-material".into()).assert_not_contains("leaked-token-material");
}
#[test]
fn assert_not_contains_accepts_a_non_empty_capture_without_the_needle() {
Captured("harmless startup line".into()).assert_not_contains("secret");
}
#[test]
fn a_panicked_capture_clears_the_slot() {
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = capture_logs(|| {
tracing::info!("residue-that-must-not-leak");
panic!("intentional capture panic");
});
}));
assert!(caught.is_err(), "the inner f must panic");
CAPTURE.with(|slot| {
assert!(
slot.borrow().is_none(),
"a panicked f must leave the capture slot empty"
);
});
let (_, logs) = capture_logs(probe);
assert_logged(&logs, PROBE);
logs.assert_not_contains("residue-that-must-not-leak");
}
}