use std::collections::BTreeMap;
use std::fmt::Write as _;
use chrono::Utc;
use crate::{
coverage::{CoverageCell, CoverageMatrix},
finding::{Finding, FindingKind, Severity},
};
pub struct ReportInputs<'a> {
pub findings: &'a [Finding],
pub coverage: Option<&'a CoverageMatrix>,
pub title: Option<&'a str>,
pub target: Option<&'a str>,
}
pub fn render_html(inputs: &ReportInputs<'_>) -> String {
let title = inputs.title.unwrap_or("mcp-wallfacer report");
let target = inputs.target.unwrap_or("(unspecified)");
let now = Utc::now().to_rfc3339();
let mut html = String::with_capacity(8 * 1024 + inputs.findings.len() * 1024);
let _ = writeln!(html, "<!DOCTYPE html>");
let _ = writeln!(html, "<html lang=\"en\">");
let _ = writeln!(html, "<head>");
let _ = writeln!(html, "<meta charset=\"utf-8\">");
let _ = writeln!(
html,
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
);
let _ = writeln!(html, "<title>{}</title>", esc(title));
html.push_str(STYLES);
let _ = writeln!(html, "</head>");
let _ = writeln!(html, "<body>");
let _ = writeln!(html, "<header class=\"hero\">");
let _ = writeln!(html, "<h1>{}</h1>", esc(title));
let _ = writeln!(
html,
"<p class=\"meta\">Target: <code>{}</code> · Generated {}</p>",
esc(target),
esc(&now),
);
let _ = writeln!(html, "</header>");
render_summary(&mut html, inputs.findings);
if let Some(matrix) = inputs.coverage {
render_coverage(&mut html, matrix);
}
render_findings_table(&mut html, inputs.findings);
let _ = writeln!(html, "<footer class=\"foot\">");
let _ = writeln!(
html,
"Generated by <a href=\"https://github.com/lacausecrypto/mcp-wallfacer\">mcp-wallfacer</a> v{}",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(html, "</footer>");
let _ = writeln!(html, "</body></html>");
html
}
fn render_summary(html: &mut String, findings: &[Finding]) {
let total = findings.len();
let mut by_severity: BTreeMap<Severity, usize> = BTreeMap::new();
let mut by_kind: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut by_tool: BTreeMap<String, usize> = BTreeMap::new();
for f in findings {
*by_severity.entry(f.severity).or_insert(0) += 1;
*by_kind.entry(f.kind.keyword()).or_insert(0) += 1;
*by_tool.entry(f.tool.clone()).or_insert(0) += 1;
}
let _ = writeln!(html, "<section class=\"summary\">");
let _ = writeln!(html, "<h2>Summary</h2>");
let _ = writeln!(html, "<div class=\"cards\">");
push_card(html, "Findings", &total.to_string(), "neutral");
push_card(
html,
"Critical",
&by_severity
.get(&Severity::Critical)
.unwrap_or(&0)
.to_string(),
"sev-critical",
);
push_card(
html,
"High",
&by_severity.get(&Severity::High).unwrap_or(&0).to_string(),
"sev-high",
);
push_card(
html,
"Medium",
&by_severity.get(&Severity::Medium).unwrap_or(&0).to_string(),
"sev-medium",
);
push_card(
html,
"Low",
&by_severity.get(&Severity::Low).unwrap_or(&0).to_string(),
"sev-low",
);
let _ = writeln!(html, "</div>");
if !by_kind.is_empty() {
let _ = writeln!(html, "<h3>By kind</h3>");
let _ = writeln!(html, "<ul class=\"breakdown\">");
for (kind, count) in &by_kind {
let _ = writeln!(
html,
"<li><code>{}</code> × {}</li>",
esc(kind),
count
);
}
let _ = writeln!(html, "</ul>");
}
if !by_tool.is_empty() {
let _ = writeln!(html, "<h3>By tool</h3>");
let _ = writeln!(html, "<ul class=\"breakdown\">");
for (tool, count) in &by_tool {
let _ = writeln!(
html,
"<li><code>{}</code> × {}</li>",
esc(tool),
count
);
}
let _ = writeln!(html, "</ul>");
}
let _ = writeln!(html, "</section>");
}
fn push_card(html: &mut String, label: &str, value: &str, class: &str) {
let _ = writeln!(
html,
"<div class=\"card {}\"><div class=\"card-value\">{}</div><div class=\"card-label\">{}</div></div>",
esc(class),
esc(value),
esc(label),
);
}
fn render_coverage(html: &mut String, matrix: &CoverageMatrix) {
let _ = writeln!(html, "<section class=\"coverage\">");
let _ = writeln!(html, "<h2>Tool coverage</h2>");
let _ = writeln!(
html,
"<p class=\"meta\">{} / {} cells covered · {} tool(s) uncovered</p>",
matrix.covered_cells(),
matrix.total_cells(),
matrix.uncovered_tools.len()
);
let _ = writeln!(html, "<table class=\"matrix\">");
let _ = writeln!(html, "<thead><tr><th>Tool</th>");
for pack in &matrix.packs {
let _ = writeln!(html, "<th>{}</th>", esc(pack));
}
let _ = writeln!(html, "</tr></thead><tbody>");
for tool in &matrix.tools {
let _ = writeln!(html, "<tr><td><code>{}</code></td>", esc(tool));
for pack in &matrix.packs {
let cell = matrix
.cells
.get(tool)
.and_then(|row| row.get(pack))
.copied()
.unwrap_or(CoverageCell::Uncovered);
let (class, glyph) = match cell {
CoverageCell::Covered => ("cov-covered", "●"),
CoverageCell::Blocked => ("cov-blocked", "⊘"),
CoverageCell::Uncovered => ("cov-uncovered", "·"),
};
let _ = writeln!(html, "<td class=\"{}\">{}</td>", class, glyph);
}
let _ = writeln!(html, "</tr>");
}
let _ = writeln!(html, "</tbody></table>");
let _ = writeln!(html, "<p class=\"legend\">Legend: <span class=\"cov-covered\">●</span> covered <span class=\"cov-blocked\">⊘</span> blocked (destructive guard) <span class=\"cov-uncovered\">·</span> uncovered</p>");
let _ = writeln!(html, "</section>");
}
fn render_findings_table(html: &mut String, findings: &[Finding]) {
let _ = writeln!(html, "<section class=\"findings\">");
let _ = writeln!(html, "<h2>Findings ({})</h2>", findings.len());
if findings.is_empty() {
let _ = writeln!(
html,
"<p class=\"meta\">no findings — pack run was clean.</p>"
);
let _ = writeln!(html, "</section>");
return;
}
let _ = writeln!(html, "<table class=\"findings-table\">");
let _ = writeln!(
html,
"<thead><tr><th>Severity</th><th>Kind</th><th>Tool</th><th>Message</th><th>Time</th></tr></thead>"
);
let _ = writeln!(html, "<tbody>");
for finding in findings {
let sev_class = match finding.severity {
Severity::Critical => "sev-critical",
Severity::High => "sev-high",
Severity::Medium => "sev-medium",
Severity::Low => "sev-low",
};
let kind_label = format_kind_label(&finding.kind);
let _ = writeln!(html, "<tr>");
let _ = writeln!(
html,
"<td class=\"{}\">{}</td>",
sev_class,
esc(&format!("{:?}", finding.severity).to_lowercase())
);
let _ = writeln!(html, "<td><code>{}</code></td>", esc(&kind_label));
let _ = writeln!(html, "<td><code>{}</code></td>", esc(&finding.tool));
let _ = writeln!(html, "<td>{}</td>", esc(&finding.message));
let _ = writeln!(
html,
"<td class=\"meta\">{}</td>",
esc(&finding.timestamp.to_rfc3339())
);
let _ = writeln!(html, "</tr>");
let _ = writeln!(html, "<tr class=\"detail-row\">");
let _ = writeln!(html, "<td colspan=\"5\">");
let _ = writeln!(
html,
"<details><summary>id <code>{}</code></summary>",
esc(&finding.id)
);
let _ = writeln!(
html,
"<pre class=\"details-pre\">{}</pre>",
esc(&finding.details)
);
let _ = writeln!(html, "</details>");
let _ = writeln!(html, "</td></tr>");
}
let _ = writeln!(html, "</tbody></table>");
let _ = writeln!(html, "</section>");
}
fn format_kind_label(kind: &FindingKind) -> String {
match kind {
FindingKind::Crash => "crash".to_string(),
FindingKind::Hang { ms } => format!("hang ({ms} ms)"),
FindingKind::SchemaViolation => "schema_violation".to_string(),
FindingKind::PropertyFailure { invariant } => format!("property: {invariant}"),
FindingKind::ProtocolError => "protocol_error".to_string(),
FindingKind::StateLeak => "state_leak".to_string(),
FindingKind::SequenceFailure {
sequence,
step_index,
step_call,
} => format!("sequence: {sequence} (step {step_index} `{step_call}`)"),
}
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
const STYLES: &str = r#"<style>
:root {
--bg: #0d1117;
--fg: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--critical: #f85149;
--high: #ff7b72;
--medium: #d29922;
--low: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--grey: #484f58;
--card: #161b22;
--border: #30363d;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.5; }
body { padding: 0 0 4rem; }
header.hero { padding: 2rem 2rem 1rem; border-bottom: 1px solid var(--border); }
header.hero h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
.meta { color: var(--muted); font-size: .85rem; margin: .25rem 0; }
section { padding: 1.5rem 2rem; border-bottom: 1px solid var(--border); }
h2 { margin: 0 0 1rem; font-size: 1.2rem; }
h3 { margin: 1rem 0 .5rem; font-size: 1rem; color: var(--muted); font-weight: 500; }
code { background: var(--card); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .9em; }
.cards { display: flex; gap: 1rem; flex-wrap: wrap; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.5rem; min-width: 110px; }
.card-value { font-size: 1.8rem; font-weight: 600; }
.card-label { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; }
.card.neutral .card-value { color: var(--fg); }
.card.sev-critical .card-value { color: var(--critical); }
.card.sev-high .card-value { color: var(--high); }
.card.sev-medium .card-value { color: var(--medium); }
.card.sev-low .card-value { color: var(--low); }
ul.breakdown { list-style: none; padding: 0; margin: 0; display: flex; gap: .75rem; flex-wrap: wrap; }
ul.breakdown li { background: var(--card); padding: .25rem .75rem; border-radius: 4px; font-size: .9rem; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: .5rem .75rem; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
.findings-table tbody tr.detail-row td { padding-top: 0; }
.findings-table .sev-critical { color: var(--critical); font-weight: 600; }
.findings-table .sev-high { color: var(--high); font-weight: 600; }
.findings-table .sev-medium { color: var(--medium); }
.findings-table .sev-low { color: var(--low); }
details summary { cursor: pointer; color: var(--muted); }
.details-pre { background: var(--card); padding: 1rem; border-radius: 6px; overflow-x: auto; max-height: 30rem; font-size: .85rem; white-space: pre-wrap; word-break: break-word; }
table.matrix { font-size: .9rem; }
table.matrix th { font-size: .75rem; }
table.matrix td.cov-covered { color: var(--green); text-align: center; }
table.matrix td.cov-blocked { color: var(--yellow); text-align: center; }
table.matrix td.cov-uncovered { color: var(--grey); text-align: center; }
.legend { font-size: .85rem; color: var(--muted); }
.legend .cov-covered { color: var(--green); }
.legend .cov-blocked { color: var(--yellow); }
.legend .cov-uncovered { color: var(--grey); }
footer.foot { padding: 1rem 2rem; color: var(--muted); font-size: .85rem; }
footer.foot a { color: var(--accent); }
@media (max-width: 700px) {
section, header.hero { padding: 1rem; }
.cards { gap: .5rem; }
.card { min-width: 80px; padding: .75rem 1rem; }
}
</style>
"#;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::finding::{Finding, FindingKind, ReproInfo, Severity};
use serde_json::json;
fn finding(severity: Severity, kind: FindingKind) -> Finding {
Finding::new(
kind,
"tool_x",
"test message",
"test details",
ReproInfo {
seed: 42,
tool_call: json!({}),
transport: "stdio".to_string(),
composition_trail: Vec::new(),
},
)
.with_severity(severity)
}
#[test]
fn empty_report_renders_no_findings_message() {
let html = render_html(&ReportInputs {
findings: &[],
coverage: None,
title: None,
target: None,
});
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("no findings"));
assert!(html.contains("Summary"));
}
#[test]
fn html_escapes_finding_message_payloads() {
let mut f = finding(Severity::High, FindingKind::Crash);
f.message = "<script>alert('xss')</script>".to_string();
let html = render_html(&ReportInputs {
findings: &[f],
coverage: None,
title: None,
target: None,
});
assert!(!html.contains("<script>alert"));
assert!(html.contains("<script>"));
}
#[test]
fn severity_buckets_appear_in_summary_cards() {
let findings = vec![
finding(Severity::Critical, FindingKind::Crash),
finding(Severity::High, FindingKind::ProtocolError),
finding(Severity::Medium, FindingKind::SchemaViolation),
];
let html = render_html(&ReportInputs {
findings: &findings,
coverage: None,
title: Some("test"),
target: Some("python"),
});
assert!(html.contains("Critical"));
assert!(html.contains("sev-critical"));
assert!(html.contains("High"));
assert!(html.contains("Medium"));
}
#[test]
fn sequence_failure_kind_label_includes_step() {
let f = finding(
Severity::High,
FindingKind::SequenceFailure {
sequence: "stateful.delete_purges".to_string(),
step_index: 2,
step_call: "record_read".to_string(),
},
);
let html = render_html(&ReportInputs {
findings: &[f],
coverage: None,
title: None,
target: None,
});
assert!(html.contains("stateful.delete_purges"));
assert!(html.contains("step 2"));
}
}