use std::sync::Mutex;
use std::time::Instant;
use crate::result::{ConsoleEntry, ConsoleLevel};
pub struct ConsoleCapture {
max_entries: usize,
max_total_bytes: usize,
max_entry_bytes: usize,
started: Instant,
inner: Mutex<ConsoleInner>,
}
struct ConsoleInner {
entries: Vec<ConsoleEntry>,
total_bytes: usize,
truncated: bool,
}
impl ConsoleCapture {
#[must_use]
pub fn new(max_entries: usize, max_total_bytes: usize, max_entry_bytes: usize) -> Self {
Self {
max_entries,
max_total_bytes,
max_entry_bytes,
started: Instant::now(),
inner: Mutex::new(ConsoleInner {
entries: Vec::new(),
total_bytes: 0,
truncated: false,
}),
}
}
pub fn push(&self, level: ConsoleLevel, message: impl Into<String>) {
let mut message = message.into();
if message.len() > self.max_entry_bytes {
message.truncate(self.max_entry_bytes);
message.push('…');
}
let Ok(mut inner) = self.inner.lock() else {
return;
};
if inner.truncated {
return;
}
let would_exceed_count = inner.entries.len() >= self.max_entries;
let would_exceed_bytes = inner.total_bytes.saturating_add(message.len()) > self.max_total_bytes;
if would_exceed_count || would_exceed_bytes {
inner.entries.push(ConsoleEntry {
level: ConsoleLevel::System,
message: "console capture truncated: limits exceeded".to_string(),
ts_ms: self.started.elapsed().as_millis() as u64,
});
inner.truncated = true;
return;
}
inner.total_bytes = inner.total_bytes.saturating_add(message.len());
inner.entries.push(ConsoleEntry {
level,
message,
ts_ms: self.started.elapsed().as_millis() as u64,
});
}
#[must_use]
pub fn drain(&self) -> Vec<ConsoleEntry> {
self
.inner
.lock()
.map(|mut inner| std::mem::take(&mut inner.entries))
.unwrap_or_default()
}
#[must_use]
pub fn elapsed_ms(&self) -> u64 {
self.started.elapsed().as_millis() as u64
}
}
#[must_use]
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for nc in chars.by_ref() {
if ('@'..='~').contains(&nc) {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_removes_color_codes() {
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
assert_eq!(strip_ansi("\x1b[1;34mbold blue\x1b[0m"), "bold blue");
assert_eq!(strip_ansi("plain"), "plain");
}
#[test]
fn capture_respects_entry_limit() {
let cap = ConsoleCapture::new(3, 10_000, 1000);
for i in 0..5 {
cap.push(ConsoleLevel::Log, format!("line {i}"));
}
let entries = cap.drain();
assert_eq!(entries.len(), 4);
assert_eq!(entries[3].level, ConsoleLevel::System);
}
#[test]
fn capture_respects_byte_limit() {
let cap = ConsoleCapture::new(100, 20, 100);
cap.push(ConsoleLevel::Log, "a".repeat(15));
cap.push(ConsoleLevel::Log, "b".repeat(15));
let entries = cap.drain();
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].level, ConsoleLevel::System);
}
#[test]
fn capture_truncates_long_entry() {
let cap = ConsoleCapture::new(10, 10_000, 5);
cap.push(ConsoleLevel::Log, "abcdefgh");
let entries = cap.drain();
assert_eq!(entries.len(), 1);
assert!(entries[0].message.starts_with("abcde"));
assert!(entries[0].message.ends_with('…'));
}
}