1use std::collections::BTreeMap;
19use std::fmt::Write as _;
20
21use chrono::Utc;
22
23use crate::{
24 coverage::{CoverageCell, CoverageMatrix},
25 finding::{Finding, FindingKind, Severity},
26};
27
28pub struct ReportInputs<'a> {
32 pub findings: &'a [Finding],
34 pub coverage: Option<&'a CoverageMatrix>,
37 pub title: Option<&'a str>,
40 pub target: Option<&'a str>,
42}
43
44pub fn render_html(inputs: &ReportInputs<'_>) -> String {
47 let title = inputs.title.unwrap_or("mcp-wallfacer report");
48 let target = inputs.target.unwrap_or("(unspecified)");
49 let now = Utc::now().to_rfc3339();
50
51 let mut html = String::with_capacity(8 * 1024 + inputs.findings.len() * 1024);
52
53 let _ = writeln!(html, "<!DOCTYPE html>");
54 let _ = writeln!(html, "<html lang=\"en\">");
55 let _ = writeln!(html, "<head>");
56 let _ = writeln!(html, "<meta charset=\"utf-8\">");
57 let _ = writeln!(
58 html,
59 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
60 );
61 let _ = writeln!(html, "<title>{}</title>", esc(title));
62 html.push_str(STYLES);
63 let _ = writeln!(html, "</head>");
64 let _ = writeln!(html, "<body>");
65
66 let _ = writeln!(html, "<header class=\"hero\">");
68 let _ = writeln!(html, "<h1>{}</h1>", esc(title));
69 let _ = writeln!(
70 html,
71 "<p class=\"meta\">Target: <code>{}</code> · Generated {}</p>",
72 esc(target),
73 esc(&now),
74 );
75 let _ = writeln!(html, "</header>");
76
77 render_summary(&mut html, inputs.findings);
79
80 if let Some(matrix) = inputs.coverage {
82 render_coverage(&mut html, matrix);
83 }
84
85 render_findings_table(&mut html, inputs.findings);
87
88 let _ = writeln!(html, "<footer class=\"foot\">");
89 let _ = writeln!(
90 html,
91 "Generated by <a href=\"https://github.com/lacausecrypto/mcp-wallfacer\">mcp-wallfacer</a> v{}",
92 env!("CARGO_PKG_VERSION")
93 );
94 let _ = writeln!(html, "</footer>");
95
96 let _ = writeln!(html, "</body></html>");
97 html
98}
99
100fn render_summary(html: &mut String, findings: &[Finding]) {
101 let total = findings.len();
102 let mut by_severity: BTreeMap<Severity, usize> = BTreeMap::new();
103 let mut by_kind: BTreeMap<&'static str, usize> = BTreeMap::new();
104 let mut by_tool: BTreeMap<String, usize> = BTreeMap::new();
105 for f in findings {
106 *by_severity.entry(f.severity).or_insert(0) += 1;
107 *by_kind.entry(f.kind.keyword()).or_insert(0) += 1;
108 *by_tool.entry(f.tool.clone()).or_insert(0) += 1;
109 }
110
111 let _ = writeln!(html, "<section class=\"summary\">");
112 let _ = writeln!(html, "<h2>Summary</h2>");
113 let _ = writeln!(html, "<div class=\"cards\">");
114 push_card(html, "Findings", &total.to_string(), "neutral");
115 push_card(
116 html,
117 "Critical",
118 &by_severity
119 .get(&Severity::Critical)
120 .unwrap_or(&0)
121 .to_string(),
122 "sev-critical",
123 );
124 push_card(
125 html,
126 "High",
127 &by_severity.get(&Severity::High).unwrap_or(&0).to_string(),
128 "sev-high",
129 );
130 push_card(
131 html,
132 "Medium",
133 &by_severity.get(&Severity::Medium).unwrap_or(&0).to_string(),
134 "sev-medium",
135 );
136 push_card(
137 html,
138 "Low",
139 &by_severity.get(&Severity::Low).unwrap_or(&0).to_string(),
140 "sev-low",
141 );
142 let _ = writeln!(html, "</div>");
143
144 if !by_kind.is_empty() {
145 let _ = writeln!(html, "<h3>By kind</h3>");
146 let _ = writeln!(html, "<ul class=\"breakdown\">");
147 for (kind, count) in &by_kind {
148 let _ = writeln!(
149 html,
150 "<li><code>{}</code> × {}</li>",
151 esc(kind),
152 count
153 );
154 }
155 let _ = writeln!(html, "</ul>");
156 }
157 if !by_tool.is_empty() {
158 let _ = writeln!(html, "<h3>By tool</h3>");
159 let _ = writeln!(html, "<ul class=\"breakdown\">");
160 for (tool, count) in &by_tool {
161 let _ = writeln!(
162 html,
163 "<li><code>{}</code> × {}</li>",
164 esc(tool),
165 count
166 );
167 }
168 let _ = writeln!(html, "</ul>");
169 }
170 let _ = writeln!(html, "</section>");
171}
172
173fn push_card(html: &mut String, label: &str, value: &str, class: &str) {
174 let _ = writeln!(
175 html,
176 "<div class=\"card {}\"><div class=\"card-value\">{}</div><div class=\"card-label\">{}</div></div>",
177 esc(class),
178 esc(value),
179 esc(label),
180 );
181}
182
183fn render_coverage(html: &mut String, matrix: &CoverageMatrix) {
184 let _ = writeln!(html, "<section class=\"coverage\">");
185 let _ = writeln!(html, "<h2>Tool coverage</h2>");
186 let _ = writeln!(
187 html,
188 "<p class=\"meta\">{} / {} cells covered · {} tool(s) uncovered</p>",
189 matrix.covered_cells(),
190 matrix.total_cells(),
191 matrix.uncovered_tools.len()
192 );
193 let _ = writeln!(html, "<table class=\"matrix\">");
194 let _ = writeln!(html, "<thead><tr><th>Tool</th>");
195 for pack in &matrix.packs {
196 let _ = writeln!(html, "<th>{}</th>", esc(pack));
197 }
198 let _ = writeln!(html, "</tr></thead><tbody>");
199 for tool in &matrix.tools {
200 let _ = writeln!(html, "<tr><td><code>{}</code></td>", esc(tool));
201 for pack in &matrix.packs {
202 let cell = matrix
203 .cells
204 .get(tool)
205 .and_then(|row| row.get(pack))
206 .copied()
207 .unwrap_or(CoverageCell::Uncovered);
208 let (class, glyph) = match cell {
209 CoverageCell::Covered => ("cov-covered", "●"),
210 CoverageCell::Blocked => ("cov-blocked", "⊘"),
211 CoverageCell::Uncovered => ("cov-uncovered", "·"),
212 };
213 let _ = writeln!(html, "<td class=\"{}\">{}</td>", class, glyph);
214 }
215 let _ = writeln!(html, "</tr>");
216 }
217 let _ = writeln!(html, "</tbody></table>");
218 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>");
219 let _ = writeln!(html, "</section>");
220}
221
222fn render_findings_table(html: &mut String, findings: &[Finding]) {
223 let _ = writeln!(html, "<section class=\"findings\">");
224 let _ = writeln!(html, "<h2>Findings ({})</h2>", findings.len());
225 if findings.is_empty() {
226 let _ = writeln!(
227 html,
228 "<p class=\"meta\">no findings — pack run was clean.</p>"
229 );
230 let _ = writeln!(html, "</section>");
231 return;
232 }
233 let _ = writeln!(html, "<table class=\"findings-table\">");
234 let _ = writeln!(
235 html,
236 "<thead><tr><th>Severity</th><th>Kind</th><th>Tool</th><th>Message</th><th>Time</th></tr></thead>"
237 );
238 let _ = writeln!(html, "<tbody>");
239 for finding in findings {
240 let sev_class = match finding.severity {
241 Severity::Critical => "sev-critical",
242 Severity::High => "sev-high",
243 Severity::Medium => "sev-medium",
244 Severity::Low => "sev-low",
245 };
246 let kind_label = format_kind_label(&finding.kind);
247 let _ = writeln!(html, "<tr>");
248 let _ = writeln!(
249 html,
250 "<td class=\"{}\">{}</td>",
251 sev_class,
252 esc(&format!("{:?}", finding.severity).to_lowercase())
253 );
254 let _ = writeln!(html, "<td><code>{}</code></td>", esc(&kind_label));
255 let _ = writeln!(html, "<td><code>{}</code></td>", esc(&finding.tool));
256 let _ = writeln!(html, "<td>{}</td>", esc(&finding.message));
257 let _ = writeln!(
258 html,
259 "<td class=\"meta\">{}</td>",
260 esc(&finding.timestamp.to_rfc3339())
261 );
262 let _ = writeln!(html, "</tr>");
263 let _ = writeln!(html, "<tr class=\"detail-row\">");
265 let _ = writeln!(html, "<td colspan=\"5\">");
266 let _ = writeln!(
267 html,
268 "<details><summary>id <code>{}</code></summary>",
269 esc(&finding.id)
270 );
271 let _ = writeln!(
272 html,
273 "<pre class=\"details-pre\">{}</pre>",
274 esc(&finding.details)
275 );
276 let _ = writeln!(html, "</details>");
277 let _ = writeln!(html, "</td></tr>");
278 }
279 let _ = writeln!(html, "</tbody></table>");
280 let _ = writeln!(html, "</section>");
281}
282
283fn format_kind_label(kind: &FindingKind) -> String {
284 match kind {
285 FindingKind::Crash => "crash".to_string(),
286 FindingKind::Hang { ms } => format!("hang ({ms} ms)"),
287 FindingKind::SchemaViolation => "schema_violation".to_string(),
288 FindingKind::PropertyFailure { invariant } => format!("property: {invariant}"),
289 FindingKind::ProtocolError => "protocol_error".to_string(),
290 FindingKind::StateLeak => "state_leak".to_string(),
291 FindingKind::SequenceFailure {
292 sequence,
293 step_index,
294 step_call,
295 } => format!("sequence: {sequence} (step {step_index} `{step_call}`)"),
296 }
297}
298
299fn esc(s: &str) -> String {
303 let mut out = String::with_capacity(s.len());
304 for c in s.chars() {
305 match c {
306 '&' => out.push_str("&"),
307 '<' => out.push_str("<"),
308 '>' => out.push_str(">"),
309 '"' => out.push_str("""),
310 '\'' => out.push_str("'"),
311 other => out.push(other),
312 }
313 }
314 out
315}
316
317const STYLES: &str = r#"<style>
320:root {
321 --bg: #0d1117;
322 --fg: #c9d1d9;
323 --muted: #8b949e;
324 --accent: #58a6ff;
325 --critical: #f85149;
326 --high: #ff7b72;
327 --medium: #d29922;
328 --low: #58a6ff;
329 --green: #3fb950;
330 --yellow: #d29922;
331 --grey: #484f58;
332 --card: #161b22;
333 --border: #30363d;
334}
335* { box-sizing: border-box; }
336html, 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; }
337body { padding: 0 0 4rem; }
338header.hero { padding: 2rem 2rem 1rem; border-bottom: 1px solid var(--border); }
339header.hero h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
340.meta { color: var(--muted); font-size: .85rem; margin: .25rem 0; }
341section { padding: 1.5rem 2rem; border-bottom: 1px solid var(--border); }
342h2 { margin: 0 0 1rem; font-size: 1.2rem; }
343h3 { margin: 1rem 0 .5rem; font-size: 1rem; color: var(--muted); font-weight: 500; }
344code { background: var(--card); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .9em; }
345.cards { display: flex; gap: 1rem; flex-wrap: wrap; }
346.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.5rem; min-width: 110px; }
347.card-value { font-size: 1.8rem; font-weight: 600; }
348.card-label { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; }
349.card.neutral .card-value { color: var(--fg); }
350.card.sev-critical .card-value { color: var(--critical); }
351.card.sev-high .card-value { color: var(--high); }
352.card.sev-medium .card-value { color: var(--medium); }
353.card.sev-low .card-value { color: var(--low); }
354ul.breakdown { list-style: none; padding: 0; margin: 0; display: flex; gap: .75rem; flex-wrap: wrap; }
355ul.breakdown li { background: var(--card); padding: .25rem .75rem; border-radius: 4px; font-size: .9rem; }
356table { border-collapse: collapse; width: 100%; }
357th, td { padding: .5rem .75rem; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
358th { color: var(--muted); font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
359.findings-table tbody tr.detail-row td { padding-top: 0; }
360.findings-table .sev-critical { color: var(--critical); font-weight: 600; }
361.findings-table .sev-high { color: var(--high); font-weight: 600; }
362.findings-table .sev-medium { color: var(--medium); }
363.findings-table .sev-low { color: var(--low); }
364details summary { cursor: pointer; color: var(--muted); }
365.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; }
366table.matrix { font-size: .9rem; }
367table.matrix th { font-size: .75rem; }
368table.matrix td.cov-covered { color: var(--green); text-align: center; }
369table.matrix td.cov-blocked { color: var(--yellow); text-align: center; }
370table.matrix td.cov-uncovered { color: var(--grey); text-align: center; }
371.legend { font-size: .85rem; color: var(--muted); }
372.legend .cov-covered { color: var(--green); }
373.legend .cov-blocked { color: var(--yellow); }
374.legend .cov-uncovered { color: var(--grey); }
375footer.foot { padding: 1rem 2rem; color: var(--muted); font-size: .85rem; }
376footer.foot a { color: var(--accent); }
377@media (max-width: 700px) {
378 section, header.hero { padding: 1rem; }
379 .cards { gap: .5rem; }
380 .card { min-width: 80px; padding: .75rem 1rem; }
381}
382</style>
383"#;
384
385#[cfg(test)]
386#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
387mod tests {
388 use super::*;
389 use crate::finding::{Finding, FindingKind, ReproInfo, Severity};
390 use serde_json::json;
391
392 fn finding(severity: Severity, kind: FindingKind) -> Finding {
393 Finding::new(
394 kind,
395 "tool_x",
396 "test message",
397 "test details",
398 ReproInfo {
399 seed: 42,
400 tool_call: json!({}),
401 transport: "stdio".to_string(),
402 composition_trail: Vec::new(),
403 },
404 )
405 .with_severity(severity)
406 }
407
408 #[test]
409 fn empty_report_renders_no_findings_message() {
410 let html = render_html(&ReportInputs {
411 findings: &[],
412 coverage: None,
413 title: None,
414 target: None,
415 });
416 assert!(html.contains("<!DOCTYPE html>"));
417 assert!(html.contains("no findings"));
418 assert!(html.contains("Summary"));
419 }
420
421 #[test]
422 fn html_escapes_finding_message_payloads() {
423 let mut f = finding(Severity::High, FindingKind::Crash);
424 f.message = "<script>alert('xss')</script>".to_string();
425 let html = render_html(&ReportInputs {
426 findings: &[f],
427 coverage: None,
428 title: None,
429 target: None,
430 });
431 assert!(!html.contains("<script>alert"));
432 assert!(html.contains("<script>"));
433 }
434
435 #[test]
436 fn severity_buckets_appear_in_summary_cards() {
437 let findings = vec![
438 finding(Severity::Critical, FindingKind::Crash),
439 finding(Severity::High, FindingKind::ProtocolError),
440 finding(Severity::Medium, FindingKind::SchemaViolation),
441 ];
442 let html = render_html(&ReportInputs {
443 findings: &findings,
444 coverage: None,
445 title: Some("test"),
446 target: Some("python"),
447 });
448 assert!(html.contains("Critical"));
450 assert!(html.contains("sev-critical"));
451 assert!(html.contains("High"));
452 assert!(html.contains("Medium"));
453 }
454
455 #[test]
456 fn sequence_failure_kind_label_includes_step() {
457 let f = finding(
458 Severity::High,
459 FindingKind::SequenceFailure {
460 sequence: "stateful.delete_purges".to_string(),
461 step_index: 2,
462 step_call: "record_read".to_string(),
463 },
464 );
465 let html = render_html(&ReportInputs {
466 findings: &[f],
467 coverage: None,
468 title: None,
469 target: None,
470 });
471 assert!(html.contains("stateful.delete_purges"));
472 assert!(html.contains("step 2"));
473 }
474}