use std::time::Duration;
#[derive(Debug, Clone)]
pub struct CapturedOutput {
pub stdout: String,
pub stderr: String,
pub duration: Duration,
}
impl CapturedOutput {
#[must_use]
pub fn from_strings(stdout: String, stderr: String) -> Self {
Self {
stdout,
stderr,
duration: Duration::ZERO,
}
}
#[must_use]
pub fn with_duration(stdout: String, stderr: String, duration: Duration) -> Self {
Self {
stdout,
stderr,
duration,
}
}
#[must_use]
pub fn stdout_has_ansi(&self) -> bool {
has_ansi_codes(&self.stdout)
}
#[must_use]
pub fn stderr_has_ansi(&self) -> bool {
has_ansi_codes(&self.stderr)
}
#[must_use]
pub fn has_any_ansi(&self) -> bool {
self.stdout_has_ansi() || self.stderr_has_ansi()
}
#[must_use]
pub fn stdout_lines(&self) -> Vec<&str> {
self.stdout.lines().collect()
}
#[must_use]
pub fn stderr_lines(&self) -> Vec<&str> {
self.stderr.lines().collect()
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn duration_ms(&self) -> u64 {
self.duration.as_millis() as u64
}
pub fn assert_stdout_eq(&self, expected: &str) {
if self.stdout != expected {
eprintln!("=== STDOUT MISMATCH ===");
eprintln!("Expected ({} bytes):", expected.len());
for (i, line) in expected.lines().enumerate() {
eprintln!(" {:3}: {}", i + 1, line);
}
eprintln!("Actual ({} bytes):", self.stdout.len());
for (i, line) in self.stdout.lines().enumerate() {
eprintln!(" {:3}: {}", i + 1, line);
}
eprintln!("=== END MISMATCH ===");
panic!("stdout mismatch");
}
}
pub fn assert_stdout_contains(&self, substring: &str) {
if !self.stdout.contains(substring) {
eprintln!("=== STDOUT MISSING SUBSTRING ===");
eprintln!("Looking for: {substring}");
eprintln!("Full stdout ({} bytes):", self.stdout.len());
for line in self.stdout.lines() {
eprintln!(" {line}");
}
eprintln!("=== END ===");
panic!("stdout missing expected substring: {substring}");
}
}
pub fn assert_stderr_contains(&self, substring: &str) {
if !self.stderr.contains(substring) {
eprintln!("=== STDERR MISSING SUBSTRING ===");
eprintln!("Looking for: {substring}");
eprintln!("Full stderr ({} bytes):", self.stderr.len());
for line in self.stderr.lines() {
eprintln!(" {line}");
}
eprintln!("=== END ===");
panic!("stderr missing expected substring: {substring}");
}
}
pub fn assert_stdout_not_contains(&self, substring: &str) {
if self.stdout.contains(substring) {
eprintln!("=== STDOUT CONTAINS UNWANTED SUBSTRING ===");
eprintln!("Unwanted: {substring}");
eprintln!("Full stdout:");
for line in self.stdout.lines() {
eprintln!(" {line}");
}
eprintln!("=== END ===");
panic!("stdout contains unwanted substring: {substring}");
}
}
pub fn assert_plain_mode_clean(&self) {
if self.stdout_has_ansi() {
eprintln!("=== ANSI CODES FOUND IN PLAIN MODE ===");
eprintln!("stdout bytes: {:?}", self.stdout.as_bytes());
let ansi_locations = find_ansi_locations(&self.stdout);
eprintln!("ANSI code locations: {ansi_locations:?}");
eprintln!("=== END ===");
panic!("plain mode should have no ANSI codes in stdout");
}
}
pub fn assert_stderr_plain(&self) {
if self.stderr_has_ansi() {
eprintln!("=== ANSI CODES FOUND IN STDERR ===");
eprintln!("stderr bytes: {:?}", self.stderr.as_bytes());
eprintln!("=== END ===");
panic!("stderr should have no ANSI codes");
}
}
pub fn assert_all_plain(&self) {
self.assert_plain_mode_clean();
self.assert_stderr_plain();
}
pub fn assert_duration_under(&self, max_ms: u64) {
let actual_ms = self.duration_ms();
assert!(
actual_ms <= max_ms,
"Output took {actual_ms}ms, expected under {max_ms}ms"
);
}
}
#[must_use]
pub fn has_ansi_codes(s: &str) -> bool {
s.contains("\x1b[")
|| s.contains("\x1b]")
|| s.contains("\x1bP")
|| s.contains("\u{009b}")
|| s.contains("\x1b\\")
}
#[must_use]
pub fn find_ansi_locations(s: &str) -> Vec<(usize, String)> {
let mut locations = Vec::new();
let bytes = s.as_bytes();
for (i, window) in bytes.windows(2).enumerate() {
if window[0] == 0x1b {
let seq_start: String = bytes[i..std::cmp::min(i + 6, bytes.len())]
.iter()
.map(|&b| if b.is_ascii_graphic() { b as char } else { '.' })
.collect();
locations.push((i, seq_start));
}
}
locations
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_captured_output_creation() {
let output = CapturedOutput::from_strings(
"stdout content".to_string(),
"stderr content".to_string(),
);
assert_eq!(output.stdout, "stdout content");
assert_eq!(output.stderr, "stderr content");
assert_eq!(output.duration_ms(), 0);
}
#[test]
fn test_captured_output_with_duration() {
let output = CapturedOutput::with_duration(
"test".to_string(),
String::new(),
Duration::from_millis(100),
);
assert_eq!(output.duration_ms(), 100);
}
#[test]
fn test_stdout_lines() {
let output = CapturedOutput::from_strings("line1\nline2\nline3".to_string(), String::new());
assert_eq!(output.stdout_lines(), vec!["line1", "line2", "line3"]);
}
#[test]
fn test_has_ansi_codes_positive() {
assert!(has_ansi_codes("\x1b[31mred\x1b[0m"));
assert!(has_ansi_codes("text\x1b[1mbold\x1b[0m"));
}
#[test]
fn test_has_ansi_codes_negative() {
assert!(!has_ansi_codes("plain text"));
assert!(!has_ansi_codes("no escape codes here"));
assert!(!has_ansi_codes(""));
}
#[test]
fn test_ansi_detection_in_output() {
let plain =
CapturedOutput::from_strings("plain text".to_string(), "plain stderr".to_string());
assert!(!plain.stdout_has_ansi());
assert!(!plain.stderr_has_ansi());
assert!(!plain.has_any_ansi());
let with_ansi =
CapturedOutput::from_strings("\x1b[31mred\x1b[0m".to_string(), String::new());
assert!(with_ansi.stdout_has_ansi());
assert!(with_ansi.has_any_ansi());
}
#[test]
fn test_assert_stdout_contains() {
let output = CapturedOutput::from_strings("Hello, world!".to_string(), String::new());
output.assert_stdout_contains("Hello");
output.assert_stdout_contains("world");
output.assert_stdout_contains(", ");
}
#[test]
#[should_panic(expected = "stdout missing expected substring")]
fn test_assert_stdout_contains_fails() {
let output = CapturedOutput::from_strings("Hello, world!".to_string(), String::new());
output.assert_stdout_contains("goodbye");
}
#[test]
fn test_assert_stdout_not_contains() {
let output = CapturedOutput::from_strings("Hello, world!".to_string(), String::new());
output.assert_stdout_not_contains("goodbye");
output.assert_stdout_not_contains("ANSI");
}
#[test]
#[should_panic(expected = "stdout contains unwanted substring")]
fn test_assert_stdout_not_contains_fails() {
let output = CapturedOutput::from_strings("Hello, world!".to_string(), String::new());
output.assert_stdout_not_contains("Hello");
}
#[test]
fn test_assert_plain_mode_clean() {
let output = CapturedOutput::from_strings(
"Plain text without escape codes".to_string(),
String::new(),
);
output.assert_plain_mode_clean();
}
#[test]
#[should_panic(expected = "plain mode should have no ANSI codes")]
fn test_assert_plain_mode_clean_fails() {
let output =
CapturedOutput::from_strings("\x1b[31mred text\x1b[0m".to_string(), String::new());
output.assert_plain_mode_clean();
}
#[test]
fn test_find_ansi_locations() {
let s = "text\x1b[31mred\x1b[0mmore";
let locations = find_ansi_locations(s);
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, 4); assert_eq!(locations[1].0, 12);
}
#[test]
fn test_assert_duration_under() {
let output =
CapturedOutput::with_duration(String::new(), String::new(), Duration::from_millis(50));
output.assert_duration_under(100);
output.assert_duration_under(51);
}
#[test]
#[should_panic(expected = "expected under")]
fn test_assert_duration_under_fails() {
let output =
CapturedOutput::with_duration(String::new(), String::new(), Duration::from_millis(100));
output.assert_duration_under(50);
}
}