use std::fmt::Write;
use std::path::Path;
use super::backend::{ProofBackend, RenderContext};
use crate::assertion::AssertionFailure;
use crate::diff::FrameDiff;
use crate::frame::TerminalFrame;
#[derive(Debug, Clone)]
pub struct ProofCapture {
pub label: String,
pub description: String,
pub frame_text: String,
pub frame_bytes: Vec<u8>,
pub cols: u16,
pub rows: u16,
pub assertions: Vec<AssertionResult>,
}
#[derive(Debug, Clone)]
pub struct AssertionResult {
pub passed: bool,
pub description: String,
pub failure: Option<Box<AssertionFailure>>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ProofError {
#[error("proof I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("proof format error: {0}")]
Format(String),
}
#[derive(Debug, Clone)]
pub struct ProofReport {
pub scenario_name: String,
pub captures: Vec<ProofCapture>,
pub diffs: Vec<FrameDiff>,
}
impl ProofReport {
pub fn new(scenario_name: impl Into<String>) -> Self {
Self {
scenario_name: scenario_name.into(),
captures: Vec::new(),
diffs: Vec::new(),
}
}
pub fn add_capture(
&mut self,
label: impl Into<String>,
description: impl Into<String>,
frame: &TerminalFrame,
) {
if let Some(previous) = self.captures.last() {
let previous_frame =
TerminalFrame::new(previous.cols, previous.rows, &previous.frame_bytes);
self.diffs.push(FrameDiff::compute(&previous_frame, frame));
}
self.captures.push(ProofCapture {
label: label.into(),
description: description.into(),
frame_text: frame.all_text(),
frame_bytes: frame.contents_formatted(),
cols: frame.cols(),
rows: frame.rows(),
assertions: Vec::new(),
});
}
pub fn add_assertion(
&mut self,
label: &str,
passed: bool,
description: impl Into<String>,
) -> bool {
if let Some(capture) = self
.captures
.iter_mut()
.find(|capture| capture.label == label)
{
capture.assertions.push(AssertionResult {
passed,
description: description.into(),
failure: None,
});
return true;
}
false
}
pub fn record_soft_failure(&mut self, failure: &AssertionFailure) -> bool {
if let Some(capture) = self.captures.last_mut() {
let summary = failure
.message
.lines()
.next()
.unwrap_or(&failure.message)
.to_string();
capture.assertions.push(AssertionResult {
passed: false,
description: summary,
failure: Some(Box::new(failure.clone())),
});
return true;
}
false
}
pub fn save(&self, backend: &dyn ProofBackend, path: &Path) -> Result<(), ProofError> {
let context = RenderContext::new(self, path);
backend.render(&context)
}
pub fn to_annotated_text(&self) -> String {
let mut output = String::new();
write_header(&mut output, &self.scenario_name);
for (index, capture) in self.captures.iter().enumerate() {
let step_number = index + 1;
write_capture_section(&mut output, step_number, capture);
}
write_footer(&mut output, self.captures.len());
output
}
}
fn write_header(output: &mut String, scenario_name: &str) {
let title = format!("Proof Report: {scenario_name}");
let border = "=".repeat(title.len().max(60));
let _ = writeln!(output, "{border}");
let _ = writeln!(output, "{title}");
let _ = writeln!(output, "{border}");
let _ = writeln!(output);
}
fn write_capture_section(output: &mut String, step_number: usize, capture: &ProofCapture) {
let heading = format!(
"Step {step_number}: [{}] {}",
capture.label, capture.description
);
let separator = "-".repeat(heading.len().max(60));
let _ = writeln!(output, "{separator}");
let _ = writeln!(output, "{heading}");
let _ = writeln!(output, " Terminal: {}x{}", capture.cols, capture.rows);
let _ = writeln!(output, "{separator}");
let _ = writeln!(output);
let frame_border = format!("+{}+", "-".repeat(usize::from(capture.cols) + 2));
let _ = writeln!(output, "{frame_border}");
for line in capture.frame_text.lines() {
let padded = format!("{line:<width$}", width = usize::from(capture.cols));
let _ = writeln!(output, "| {padded} |");
}
let _ = writeln!(output, "{frame_border}");
let _ = writeln!(output);
if !capture.assertions.is_empty() {
let _ = writeln!(output, " Assertions:");
for assertion in &capture.assertions {
let marker = if assertion.passed { "PASS" } else { "FAIL" };
let mut lines = assertion.description.lines();
let first = lines.next().unwrap_or("");
let _ = writeln!(output, " [{marker}] {first}");
for line in lines {
let _ = writeln!(output, " {line}");
}
}
let _ = writeln!(output);
}
}
fn write_footer(output: &mut String, capture_count: usize) {
let _ = writeln!(output, "Total captures: {capture_count}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assertion::{Expected, SoftAssertions, match_not_visible, match_text_in_region};
use crate::region::Region;
#[test]
fn proof_report_collects_captures() {
let frame = TerminalFrame::new(80, 24, b"Hello, World!");
let mut report = ProofReport::new("test_scenario");
report.add_capture("startup", "Application launched", &frame);
report.add_capture("after_input", "User typed text", &frame);
assert_eq!(report.captures.len(), 2);
assert_eq!(report.captures[0].label, "startup");
assert_eq!(report.captures[1].label, "after_input");
assert!(report.captures[0].frame_text.contains("Hello, World!"));
}
#[test]
fn add_assertion_attaches_to_labeled_capture() {
let frame = TerminalFrame::new(80, 24, b"Test");
let mut report = ProofReport::new("assert_test");
report.add_capture("check", "Checking state", &frame);
report.add_assertion("check", true, "Text 'Test' is visible");
report.add_assertion("check", false, "Color is blue");
let capture = &report.captures[0];
assert_eq!(capture.assertions.len(), 2);
assert!(capture.assertions[0].passed);
assert!(!capture.assertions[1].passed);
}
#[test]
fn add_assertion_targets_specific_capture() {
let frame = TerminalFrame::new(80, 24, b"Test");
let mut report = ProofReport::new("targeted_test");
report.add_capture("first", "First", &frame);
report.add_capture("second", "Second", &frame);
let found = report.add_assertion("first", true, "targeted assertion");
assert!(found);
assert_eq!(report.captures[0].assertions.len(), 1);
assert_eq!(
report.captures[0].assertions[0].description,
"targeted assertion"
);
assert!(report.captures[1].assertions.is_empty());
}
#[test]
fn add_assertion_returns_false_for_unknown_label() {
let frame = TerminalFrame::new(80, 24, b"Test");
let mut report = ProofReport::new("miss_test");
report.add_capture("existing", "Existing", &frame);
let found = report.add_assertion("nonexistent", true, "orphan");
assert!(!found);
assert!(report.captures[0].assertions.is_empty());
}
#[test]
fn annotated_text_contains_scenario_name() {
let report = ProofReport::new("my_scenario");
let text = report.to_annotated_text();
assert!(text.contains("Proof Report: my_scenario"));
}
#[test]
fn annotated_text_contains_step_labels_and_descriptions() {
let frame = TerminalFrame::new(40, 5, b"content");
let mut report = ProofReport::new("labeled_test");
report.add_capture("init", "Initial state", &frame);
report.add_capture("done", "Final state", &frame);
let text = report.to_annotated_text();
assert!(text.contains("Step 1: [init] Initial state"));
assert!(text.contains("Step 2: [done] Final state"));
assert!(text.contains("Total captures: 2"));
}
#[test]
fn annotated_text_contains_frame_content() {
let frame = TerminalFrame::new(40, 5, b"Hello proof");
let mut report = ProofReport::new("frame_test");
report.add_capture("snap", "Snapshot", &frame);
let text = report.to_annotated_text();
assert!(text.contains("Hello proof"));
assert!(text.contains("Terminal: 40x5"));
}
#[test]
fn annotated_text_contains_assertion_markers() {
let frame = TerminalFrame::new(40, 5, b"Test");
let mut report = ProofReport::new("assert_output");
report.add_capture("check", "Verify state", &frame);
report.add_assertion("check", true, "text visible");
report.add_assertion("check", false, "color match");
let text = report.to_annotated_text();
assert!(text.contains("[PASS] text visible"));
assert!(text.contains("[FAIL] color match"));
}
#[test]
fn proof_capture_stores_dimensions() {
let frame = TerminalFrame::new(120, 40, b"wide");
let mut report = ProofReport::new("dims");
report.add_capture("wide_term", "Wide terminal", &frame);
assert_eq!(report.captures[0].cols, 120);
assert_eq!(report.captures[0].rows, 40);
}
#[test]
fn auto_diff_computed_between_consecutive_captures() {
let frame_a = TerminalFrame::new(80, 24, b"Hello");
let frame_b = TerminalFrame::new(80, 24, b"World");
let mut report = ProofReport::new("diff_test");
report.add_capture("before", "Before change", &frame_a);
report.add_capture("after", "After change", &frame_b);
assert_eq!(report.diffs.len(), 1);
assert!(!report.diffs[0].is_identical());
}
#[test]
fn no_diff_for_single_capture() {
let frame = TerminalFrame::new(80, 24, b"Solo");
let mut report = ProofReport::new("single");
report.add_capture("only", "Only capture", &frame);
assert!(report.diffs.is_empty());
}
#[test]
fn identical_captures_produce_identical_diff() {
let frame = TerminalFrame::new(80, 24, b"Same");
let mut report = ProofReport::new("same");
report.add_capture("first", "First", &frame);
report.add_capture("second", "Second", &frame);
assert_eq!(report.diffs.len(), 1);
assert!(report.diffs[0].is_identical());
}
#[test]
fn frame_bytes_preserves_style_for_diff() {
let frame_a = TerminalFrame::new(80, 24, b"Hello");
let frame_b = TerminalFrame::new(80, 24, b"\x1b[1mHello\x1b[0m");
let mut report = ProofReport::new("style_diff");
report.add_capture("plain", "Plain text", &frame_a);
report.add_capture("bold", "Bold text", &frame_b);
assert_eq!(report.diffs.len(), 1);
assert!(!report.diffs[0].is_identical());
}
#[test]
fn record_soft_failure_attaches_to_latest_capture() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let mut report = ProofReport::new("soft_failure_routing");
report.add_capture("first", "First snapshot", &frame);
report.add_capture("second", "Second snapshot", &frame);
let failure = AssertionFailure {
message: "boom".to_string(),
expected: Expected::TextInRegion {
needle: "missing".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let attached = report.record_soft_failure(&failure);
assert!(attached);
assert!(report.captures[0].assertions.is_empty());
assert_eq!(report.captures[1].assertions.len(), 1);
let result = &report.captures[1].assertions[0];
assert!(!result.passed);
assert_eq!(result.description, "boom");
let stored = result
.failure
.as_deref()
.expect("structured failure should be preserved");
assert_eq!(stored.message, "boom");
assert!(matches!(stored.expected, Expected::TextInRegion { .. }));
}
#[test]
fn record_soft_failure_keeps_description_single_line_for_multiline_message() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let mut report = ProofReport::new("soft_failure_multiline_summary");
report.add_capture("only", "Only capture", &frame);
let failure = AssertionFailure {
message: "first line summary\n detail line\n another detail".to_string(),
expected: Expected::TextInRegion {
needle: "missing".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
report.record_soft_failure(&failure);
let result = &report.captures[0].assertions[0];
assert_eq!(result.description, "first line summary");
let stored = result.failure.as_deref().expect("failure preserved");
assert!(stored.message.contains("detail line"));
assert!(stored.message.contains("another detail"));
}
#[test]
fn annotated_text_indents_multiline_assertion_descriptions() {
let frame = TerminalFrame::new(40, 5, b"Test");
let mut report = ProofReport::new("multiline_render");
report.add_capture("check", "Verify state", &frame);
report
.captures
.last_mut()
.expect("capture exists")
.assertions
.push(AssertionResult {
passed: false,
description: "first summary line\nsecond detail line".to_string(),
failure: None,
});
report
.captures
.last_mut()
.expect("capture exists")
.assertions
.push(AssertionResult {
passed: true,
description: "next assertion".to_string(),
failure: None,
});
let text = report.to_annotated_text();
assert!(text.contains(" [FAIL] first summary line"));
assert!(text.contains(" second detail line"));
assert!(text.contains(" [PASS] next assertion"));
}
#[test]
fn record_soft_failure_returns_false_without_captures() {
let mut report = ProofReport::new("empty_report");
let failure = AssertionFailure {
message: "no captures yet".to_string(),
expected: Expected::NotVisible {
needle: "x".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let attached = report.record_soft_failure(&failure);
assert!(!attached);
}
#[test]
fn soft_assertions_attach_each_failure_to_active_capture() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let visible_region = Region::new(0, 0, 80, 1);
let empty_region = Region::new(20, 0, 60, 1);
let mut report = ProofReport::new("soft_capture_routing");
report.add_capture("only", "Only capture", &frame);
{
let mut soft = SoftAssertions::with_report(&mut report);
soft.check(match_text_in_region(&frame, "Hello", &empty_region));
soft.check(match_text_in_region(&frame, "Hello", &visible_region));
soft.check(match_not_visible(&frame, "Hello"));
let failures = soft.into_failures();
assert_eq!(failures.len(), 2);
}
let assertions = &report.captures[0].assertions;
assert_eq!(assertions.len(), 2);
assert!(assertions.iter().all(|result| !result.passed));
assert!(assertions[0].description.contains("not found in region"));
assert!(assertions[1].description.contains("NOT be visible"));
assert!(!assertions[0].description.contains('\n'));
assert!(!assertions[1].description.contains('\n'));
assert!(matches!(
assertions[0].failure.as_deref().map(|f| &f.expected),
Some(Expected::TextInRegion { .. })
));
assert!(matches!(
assertions[1].failure.as_deref().map(|f| &f.expected),
Some(Expected::NotVisible { .. })
));
}
#[test]
fn frame_bytes_stores_formatted_output() {
let frame = TerminalFrame::new(80, 24, b"\x1b[31mRed\x1b[0m");
let mut report = ProofReport::new("bytes_test");
report.add_capture("colored", "Red text", &frame);
let capture = &report.captures[0];
assert!(!capture.frame_bytes.is_empty());
assert!(capture.frame_bytes.len() > capture.frame_text.len());
}
}