use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::program::Target;
pub const FAILURE_REEL_SCHEMA_V: u32 = 1;
pub const MAX_FAILURE_REEL_FRAMES: usize = 3;
pub const MAX_FAILURE_REEL_FRAME_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_FAILURE_REEL_CAUSE_CHARS: usize = 512;
const GEOMETRIC_PREDICATES: [&str; 4] = [
"no_overlap",
"inside_viewport",
"text_not_clipped",
"receives_events",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FailureReelCapture {
pub program: String,
pub step: usize,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<Target>,
pub failure: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub highlight_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_path: Option<PathBuf>,
#[serde(default)]
pub limitations: Vec<String>,
}
impl FailureReelCapture {
#[must_use]
pub fn frame_paths(&self) -> Vec<&Path> {
[&self.before_path, &self.highlight_path, &self.after_path]
.into_iter()
.filter_map(Option::as_deref)
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureCauseKind {
Assertion,
Geometric,
Action,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FailureCause {
pub kind: FailureCauseKind,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FailureReelFrames {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub highlight: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
}
impl FailureReelFrames {
#[must_use]
pub fn count(&self) -> usize {
usize::from(self.before.is_some())
+ usize::from(self.highlight.is_some())
+ usize::from(self.after.is_some())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FailureReel {
pub schema_v: u32,
pub diagnostic: bool,
pub program: String,
pub step: usize,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub cause: FailureCause,
pub frames: FailureReelFrames,
#[serde(default)]
pub limitations: Vec<String>,
pub runtime_llm_tokens: u32,
}
#[must_use]
pub fn copy_reel_frame(
source: &Path,
evidence_dir: &Path,
program: &str,
step: usize,
slot: &str,
) -> Option<PathBuf> {
let name = format!(
"{}-reel-{step}-{slot}.png",
safe_file_token(program)
);
let dest = evidence_dir.join(name);
std::fs::copy(source, &dest).ok()?;
Some(dest)
}
#[must_use]
pub fn assemble_failure_reel(
passed: bool,
capture: Option<&FailureReelCapture>,
) -> Option<FailureReel> {
if passed {
return None;
}
let capture = capture?;
if capture.program.trim().is_empty() {
return None;
}
let mut limitations = capture.limitations.clone();
let before = accept_frame(capture.before_path.as_deref(), "before", &mut limitations);
let highlight = accept_frame(
capture.highlight_path.as_deref(),
"highlight",
&mut limitations,
);
let after = accept_frame(capture.after_path.as_deref(), "after", &mut limitations);
if before.is_none() && !limitations.iter().any(|item| item == "before_frame_unmeasured") {
limitations.push("before_frame_unmeasured".into());
}
if capture.target.is_some()
&& highlight.is_none()
&& !limitations
.iter()
.any(|item| item == "target_not_located" || item == "highlight_unmeasured")
{
limitations.push("highlight_unmeasured".into());
}
if capture.target.is_none()
&& !limitations
.iter()
.any(|item| item == "target_not_applicable")
{
limitations.push("target_not_applicable".into());
}
limitations.sort();
limitations.dedup();
let frames = FailureReelFrames {
before,
highlight,
after,
};
debug_assert!(frames.count() <= MAX_FAILURE_REEL_FRAMES);
Some(FailureReel {
schema_v: FAILURE_REEL_SCHEMA_V,
diagnostic: true,
program: capture.program.clone(),
step: capture.step,
action: capture.action.clone(),
target: capture.target.as_ref().map(summarize_target),
cause: failure_cause(&capture.failure),
frames,
limitations,
runtime_llm_tokens: 0,
})
}
#[must_use]
pub fn failure_cause(failure: &str) -> FailureCause {
let text = truncate_cause(failure);
let geometric = GEOMETRIC_PREDICATES
.iter()
.copied()
.find(|marker| failure.contains(marker));
if let Some(check) = geometric {
return FailureCause {
kind: FailureCauseKind::Geometric,
obligation: obligation_from_failure(failure),
check: Some(check.into()),
text,
};
}
if failure.starts_with("assertion_failed:")
|| failure.starts_with("condition_not_established:")
{
return FailureCause {
kind: FailureCauseKind::Assertion,
obligation: obligation_from_failure(failure),
check: None,
text,
};
}
FailureCause {
kind: FailureCauseKind::Action,
obligation: None,
check: None,
text,
}
}
#[must_use]
pub fn summarize_target(target: &Target) -> String {
if let Some(test_id) = target.test_id.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
return format!("testid:{test_id}");
}
match (
target.role.as_deref().map(str::trim).filter(|v| !v.is_empty()),
target
.accessible_name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty()),
) {
(Some(role), Some(name)) => format!("{role}:{name}"),
(Some(role), None) => format!("role:{role}"),
(None, Some(name)) => format!("name:{name}"),
(None, None) => {
if let Some(label) = target.label.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
format!("label:{label}")
} else if let Some(hint) = target
.component_hint
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
format!("component:{hint}")
} else {
"semantic-target".into()
}
}
}
}
fn accept_frame(path: Option<&Path>, slot: &str, limitations: &mut Vec<String>) -> Option<String> {
let path = path?;
let meta = match std::fs::metadata(path) {
Ok(meta) if meta.is_file() => meta,
_ => {
limitations.push(format!("frame_unreadable:{slot}"));
return None;
}
};
if meta.len() == 0 {
limitations.push(format!("frame_empty:{slot}"));
return None;
}
if meta.len() > MAX_FAILURE_REEL_FRAME_BYTES {
limitations.push(format!("frame_exceeded_bound:{slot}"));
return None;
}
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
}
fn obligation_from_failure(failure: &str) -> Option<String> {
let rest = failure
.strip_prefix("assertion_failed:")
.or_else(|| failure.strip_prefix("condition_not_established:"))?;
let obligation = rest.split_once(':').map_or(rest, |(id, _)| id).trim();
if obligation.is_empty() {
None
} else {
Some(obligation.to_owned())
}
}
fn truncate_cause(failure: &str) -> String {
let trimmed = failure.trim();
if trimmed.chars().count() <= MAX_FAILURE_REEL_CAUSE_CHARS {
return trimmed.to_owned();
}
trimmed.chars().take(MAX_FAILURE_REEL_CAUSE_CHARS).collect()
}
fn safe_file_token(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes().take(100) {
if byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_' || byte == b'-' {
out.push(byte as char);
} else {
out.push('-');
}
}
if out.is_empty() {
"program".into()
} else {
out
}
}