#![allow(dead_code)]
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnsiSequence {
Sgr(Vec<u8>),
Cursor(String),
Osc(String),
Unknown(String),
}
impl AnsiSequence {
#[must_use]
pub fn is_reset(&self) -> bool {
matches!(self, Self::Sgr(codes) if codes.is_empty() || codes == &[0])
}
#[must_use]
pub fn has_bold(&self) -> bool {
matches!(self, Self::Sgr(codes) if codes.contains(&1))
}
#[must_use]
pub fn has_italic(&self) -> bool {
matches!(self, Self::Sgr(codes) if codes.contains(&3))
}
#[must_use]
pub fn has_underline(&self) -> bool {
matches!(self, Self::Sgr(codes) if codes.contains(&4))
}
#[must_use]
pub fn has_foreground_color(&self) -> bool {
match self {
Self::Sgr(codes) => codes
.iter()
.any(|&c| (30..=37).contains(&c) || (90..=97).contains(&c) || c == 38),
_ => false,
}
}
#[must_use]
pub fn has_background_color(&self) -> bool {
match self {
Self::Sgr(codes) => codes
.iter()
.any(|&c| (40..=47).contains(&c) || (100..=107).contains(&c) || c == 48),
_ => false,
}
}
#[must_use]
pub fn sgr_codes(&self) -> Option<&[u8]> {
match self {
Self::Sgr(codes) => Some(codes),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedSegment {
pub text: String,
pub sequences: Vec<AnsiSequence>,
pub raw_ansi: String,
}
#[derive(Debug, Default)]
pub struct AnsiParser {}
impl AnsiParser {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn parse_sgr_codes(params: &str) -> Vec<u8> {
if params.is_empty() {
return vec![0]; }
params
.split(';')
.filter_map(|s| s.parse::<u8>().ok())
.collect()
}
fn parse_sequence(s: &str) -> Option<(AnsiSequence, usize)> {
if !s.starts_with("\x1b[") && !s.starts_with("\x1b]") {
return None;
}
if s.starts_with("\x1b]") {
if let Some(end) = s.find("\x1b\\") {
let content = &s[2..end];
return Some((AnsiSequence::Osc(content.to_string()), end + 2));
}
if let Some(end) = s.find('\x07') {
let content = &s[2..end];
return Some((AnsiSequence::Osc(content.to_string()), end + 1));
}
return None;
}
let rest = &s[2..];
let mut end_idx = 0;
for (i, c) in rest.char_indices() {
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
end_idx = i;
break;
}
}
if end_idx == 0
&& !rest.is_empty()
&& rest
.chars()
.next()
.is_some_and(|c| (0x40..=0x7E).contains(&(c as u8)))
{
end_idx = 0;
} else if end_idx == 0 {
return None;
}
let params = &rest[..end_idx];
let final_byte = rest.chars().nth(end_idx)?;
let total_len = 2 + end_idx + 1;
let sequence = match final_byte {
'm' => AnsiSequence::Sgr(Self::parse_sgr_codes(params)),
'H' | 'f' | 'A' | 'B' | 'C' | 'D' | 'J' | 'K' | 's' | 'u' => {
AnsiSequence::Cursor(format!("{params}{final_byte}"))
}
_ => AnsiSequence::Unknown(s[..total_len].to_string()),
};
Some((sequence, total_len))
}
#[must_use]
pub fn parse(&mut self, output: &str) -> Vec<ParsedSegment> {
let mut segments = Vec::new();
let mut current_text = String::new();
let mut current_sequences = Vec::new();
let mut current_raw_ansi = String::new();
let mut pos = 0;
while pos < output.len() {
let remaining = &output[pos..];
if remaining.starts_with("\x1b") {
if !current_text.is_empty() {
segments.push(ParsedSegment {
text: std::mem::take(&mut current_text),
sequences: std::mem::take(&mut current_sequences),
raw_ansi: std::mem::take(&mut current_raw_ansi),
});
}
if let Some((seq, len)) = Self::parse_sequence(remaining) {
current_raw_ansi.push_str(&remaining[..len]);
current_sequences.push(seq);
pos += len;
continue;
}
}
if let Some(c) = remaining.chars().next() {
current_text.push(c);
pos += c.len_utf8();
} else {
break;
}
}
if !current_text.is_empty() || !current_raw_ansi.is_empty() {
segments.push(ParsedSegment {
text: current_text,
sequences: current_sequences,
raw_ansi: current_raw_ansi,
});
}
segments
}
#[must_use]
pub fn strip_ansi(output: &str) -> String {
let mut parser = Self::new();
let segments = parser.parse(output);
segments.into_iter().map(|s| s.text).collect()
}
#[must_use]
pub fn validate(output: &str) -> Vec<String> {
let mut errors = Vec::new();
let mut parser = Self::new();
let segments = parser.parse(output);
let mut style_active = false;
for segment in &segments {
for seq in &segment.sequences {
match seq {
AnsiSequence::Sgr(codes) => {
style_active = !(codes.is_empty() || codes == &[0]);
}
AnsiSequence::Unknown(s) => {
errors.push(format!("Unknown ANSI sequence: {s:?}"));
}
_ => {}
}
}
}
if style_active {
errors.push("Styles not fully reset at end of output".to_string());
}
errors
}
#[must_use]
pub fn count_sgr_code(output: &str, code: u8) -> usize {
let mut parser = Self::new();
let segments = parser.parse(output);
let mut count = 0;
for segment in segments {
for seq in segment.sequences {
if let AnsiSequence::Sgr(codes) = seq
&& codes.contains(&code)
{
count += 1;
}
}
}
count
}
}
#[derive(Debug, Clone)]
pub struct CapturedOutput {
pub content: String,
pub elapsed: Duration,
pub started_at: Instant,
pub errors: Vec<String>,
}
impl CapturedOutput {
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.content.contains(needle)
}
#[must_use]
pub fn plain_text(&self) -> String {
AnsiParser::strip_ansi(&self.content)
}
#[must_use]
pub fn validate_ansi(&self) -> Vec<String> {
AnsiParser::validate(&self.content)
}
#[must_use]
pub fn parse_ansi(&self) -> Vec<ParsedSegment> {
let mut parser = AnsiParser::new();
parser.parse(&self.content)
}
#[must_use]
pub fn len(&self) -> usize {
self.content.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
#[must_use]
pub fn line_count(&self) -> usize {
self.content.lines().count()
}
}
pub fn capture<F, R>(f: F) -> (R, CapturedOutput)
where
F: FnOnce() -> R,
{
let start = Instant::now();
let result = f();
let elapsed = start.elapsed();
(
result,
CapturedOutput {
content: String::new(),
elapsed,
started_at: start,
errors: Vec::new(),
},
)
}
pub fn capture_string<F>(f: F) -> CapturedOutput
where
F: FnOnce() -> String,
{
let start = Instant::now();
let content = f();
let elapsed = start.elapsed();
CapturedOutput {
content,
elapsed,
started_at: start,
errors: Vec::new(),
}
}
#[derive(Debug)]
pub struct FileValidation {
pub path: PathBuf,
pub exists: bool,
pub size: Option<u64>,
pub content: Option<String>,
pub errors: Vec<String>,
}
impl FileValidation {
pub fn validate(path: impl AsRef<Path>) -> Self {
let path = path.as_ref().to_path_buf();
let mut errors = Vec::new();
let exists = path.exists();
if !exists {
errors.push(format!("File does not exist: {}", path.display()));
return Self {
path,
exists,
size: None,
content: None,
errors,
};
}
let metadata = fs::metadata(&path);
let size = metadata.as_ref().ok().map(|m| m.len());
let content = fs::read_to_string(&path).ok();
Self {
path,
exists,
size,
content,
errors,
}
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.content.as_ref().is_some_and(|c| c.contains(needle))
}
#[track_caller]
pub fn assert_exists(&self) {
if !self.exists {
panic!(
"Expected file to exist: {}\nErrors: {:?}",
self.path.display(),
self.errors
);
}
}
#[track_caller]
pub fn assert_contains(&self, needle: &str) {
if !self.contains(needle) {
panic!(
"Expected file {} to contain {:?}\nContent: {:?}",
self.path.display(),
needle,
self.content
);
}
}
#[track_caller]
pub fn assert_size_between(&self, min: u64, max: u64) {
match self.size {
Some(size) if size >= min && size <= max => {}
Some(size) => {
panic!(
"Expected file {} size between {}-{} bytes, got {} bytes",
self.path.display(),
min,
max,
size
);
}
None => {
panic!("Could not determine size of {}", self.path.display());
}
}
}
}
pub fn validate_files<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> Vec<FileValidation> {
paths.into_iter().map(FileValidation::validate).collect()
}
#[derive(Debug)]
pub struct E2eContext {
pub name: String,
pub started_at: Instant,
pub phases: Vec<(String, Duration)>,
pub captures: Vec<CapturedOutput>,
pub metadata: HashMap<String, String>,
}
impl E2eContext {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
tracing::info!(test = %name, "Starting E2E test");
Self {
name,
started_at: Instant::now(),
phases: Vec::new(),
captures: Vec::new(),
metadata: HashMap::new(),
}
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn phase<F, R>(&mut self, name: &str, f: F) -> R
where
F: FnOnce() -> R,
{
tracing::info!(test = %self.name, phase = name, "Entering phase");
let start = Instant::now();
let result = f();
let elapsed = start.elapsed();
tracing::info!(test = %self.name, phase = name, elapsed_ms = elapsed.as_millis(), "Phase complete");
self.phases.push((name.to_string(), elapsed));
result
}
pub fn capture<F>(&mut self, description: &str, f: F) -> CapturedOutput
where
F: FnOnce() -> String,
{
tracing::debug!(test = %self.name, capture = description, "Starting capture");
let output = capture_string(f);
tracing::debug!(
test = %self.name,
capture = description,
bytes = output.len(),
elapsed_ms = output.elapsed.as_millis(),
"Capture complete"
);
self.captures.push(output.clone());
output
}
#[track_caller]
pub fn assert_contains(&self, output: &CapturedOutput, needle: &str) {
tracing::debug!(
test = %self.name,
needle = needle,
output_len = output.len(),
"Asserting contains"
);
if !output.contains(needle) {
tracing::error!(
test = %self.name,
needle = needle,
output = %output.content,
"Assertion failed: content not found"
);
panic!(
"[{}] Expected output to contain {:?}\nOutput:\n{}",
self.name, needle, output.content
);
}
}
#[track_caller]
pub fn assert_ansi_valid(&self, output: &CapturedOutput) {
let errors = output.validate_ansi();
if !errors.is_empty() {
tracing::error!(
test = %self.name,
errors = ?errors,
"ANSI validation failed"
);
panic!(
"[{}] ANSI validation errors:\n{}\nOutput:\n{}",
self.name,
errors.join("\n"),
output.content
);
}
tracing::debug!(test = %self.name, "ANSI validation passed");
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
#[must_use]
pub fn report(&self) -> String {
let mut report = String::new();
writeln!(report, "=== E2E Test Report: {} ===", self.name).unwrap();
writeln!(report, "Total elapsed: {:?}", self.elapsed()).unwrap();
writeln!(report).unwrap();
if !self.metadata.is_empty() {
writeln!(report, "Metadata:").unwrap();
for (k, v) in &self.metadata {
writeln!(report, " {k}: {v}").unwrap();
}
writeln!(report).unwrap();
}
writeln!(report, "Phases:").unwrap();
for (name, duration) in &self.phases {
writeln!(report, " {name}: {duration:?}").unwrap();
}
writeln!(report).unwrap();
writeln!(report, "Captures: {} total", self.captures.len()).unwrap();
for (i, capture) in self.captures.iter().enumerate() {
writeln!(
report,
" [{i}] {} bytes, {:?}",
capture.len(),
capture.elapsed
)
.unwrap();
}
report
}
}
impl Drop for E2eContext {
fn drop(&mut self) {
tracing::info!(
test = %self.name,
elapsed_ms = self.elapsed().as_millis(),
phases = self.phases.len(),
captures = self.captures.len(),
"E2E test complete"
);
}
}
pub fn timed<F, R>(f: F) -> (R, Duration)
where
F: FnOnce() -> R,
{
let start = Instant::now();
let result = f();
(result, start.elapsed())
}
#[track_caller]
pub fn assert_completes_within<F, R>(max_duration: Duration, f: F) -> R
where
F: FnOnce() -> R,
{
let (result, elapsed) = timed(f);
if elapsed > max_duration {
panic!(
"Operation exceeded time limit: {:?} > {:?}",
elapsed, max_duration
);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ansi_parser_basic() {
let mut parser = AnsiParser::new();
let segments = parser.parse("\x1b[1mBold\x1b[0m Normal");
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].text, "Bold");
assert_eq!(segments[1].text, " Normal");
}
#[test]
fn test_ansi_parser_sgr_codes() {
let mut parser = AnsiParser::new();
let segments = parser.parse("\x1b[1;31mRed Bold\x1b[0m");
assert!(!segments.is_empty());
assert_eq!(segments[0].text, "Red Bold");
assert!(segments[0].sequences.iter().any(|s| s.has_bold()));
}
#[test]
fn test_ansi_strip() {
let plain = AnsiParser::strip_ansi("\x1b[1mBold\x1b[0m and \x1b[32mGreen\x1b[0m");
assert_eq!(plain, "Bold and Green");
}
#[test]
fn test_ansi_validate() {
let errors = AnsiParser::validate("\x1b[1mBold\x1b[0m");
assert!(errors.is_empty());
let errors = AnsiParser::validate("\x1b[1mBold");
assert!(!errors.is_empty());
}
#[test]
fn test_capture_string() {
let output = capture_string(|| "Hello, World!".to_string());
assert_eq!(output.content, "Hello, World!");
assert!(!output.elapsed.is_zero() || output.elapsed == Duration::ZERO);
}
#[test]
fn test_file_validation_nonexistent() {
let validation = FileValidation::validate("/nonexistent/path/file.txt");
assert!(!validation.exists);
assert!(!validation.errors.is_empty());
}
#[test]
fn test_e2e_context() {
let mut ctx = E2eContext::new("test_example").with_metadata("version", "1.0");
let result = ctx.phase("setup", || 42);
assert_eq!(result, 42);
assert_eq!(ctx.phases.len(), 1);
let output = ctx.capture("render", || "Hello".to_string());
assert_eq!(output.content, "Hello");
assert_eq!(ctx.captures.len(), 1);
let report = ctx.report();
assert!(report.contains("test_example"));
assert!(report.contains("setup"));
}
#[test]
fn test_timed() {
let (result, duration) = timed(|| {
std::thread::sleep(Duration::from_millis(10));
42
});
assert_eq!(result, 42);
assert!(duration >= Duration::from_millis(10));
}
#[test]
fn test_assert_completes_within() {
let result = assert_completes_within(Duration::from_secs(1), || 42);
assert_eq!(result, 42);
}
#[test]
#[should_panic(expected = "exceeded time limit")]
fn test_assert_completes_within_fails() {
assert_completes_within(Duration::from_millis(1), || {
std::thread::sleep(Duration::from_millis(50));
42
});
}
#[test]
fn test_ansi_sequence_properties() {
let bold = AnsiSequence::Sgr(vec![1]);
assert!(bold.has_bold());
assert!(!bold.has_italic());
assert!(!bold.is_reset());
let reset = AnsiSequence::Sgr(vec![0]);
assert!(reset.is_reset());
let color = AnsiSequence::Sgr(vec![31]);
assert!(color.has_foreground_color());
assert!(!color.has_background_color());
}
}