Skip to main content

wallfacer_core/
report.rs

1//! Phase U — static HTML dashboard renderer.
2//!
3//! Reads a [`crate::corpus::Corpus`] (already-persisted findings)
4//! and produces a single self-contained HTML file: inline CSS, no
5//! external assets, no JavaScript dependency.
6//!
7//! Sections:
8//! - **Summary** — total findings, breakdown by severity / kind /
9//!   tool, run timestamp.
10//! - **Findings table** — every finding with severity, kind, tool,
11//!   message, timestamp, JSON details (collapsible via `<details>`).
12//! - **Tool coverage** — when a [`crate::coverage::CoverageMatrix`]
13//!   is supplied, render it as a coloured grid.
14//!
15//! Designed to be e-mailable / archivable — open the file in any
16//! browser, anywhere, no server, no internet.
17
18use 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
28/// Optional inputs to [`render_html`]. `findings` is the only
29/// required input; the rest enrich the dashboard with metadata
30/// the corpus alone doesn't carry.
31pub struct ReportInputs<'a> {
32    /// Findings to render. Typically `corpus.list_findings()`.
33    pub findings: &'a [Finding],
34    /// Optional coverage matrix; when `Some`, a coverage section is
35    /// added to the report.
36    pub coverage: Option<&'a CoverageMatrix>,
37    /// Free-form title (e.g. the project name). Defaults to
38    /// `mcp-wallfacer report` when `None`.
39    pub title: Option<&'a str>,
40    /// Free-form server identifier (e.g. URL or package name).
41    pub target: Option<&'a str>,
42}
43
44/// Renders a self-contained HTML dashboard. Returns the full
45/// document as a `String` ready to be written to disk.
46pub 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    // Header
67    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    // Summary cards
78    render_summary(&mut html, inputs.findings);
79
80    // Coverage matrix (optional)
81    if let Some(matrix) = inputs.coverage {
82        render_coverage(&mut html, matrix);
83    }
84
85    // Findings table
86    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> &times; {}</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> &times; {}</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 &nbsp; <span class=\"cov-blocked\">⊘</span> blocked (destructive guard) &nbsp; <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        // Detail row
264        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
299/// HTML-escape `s` for safe insertion into element bodies and
300/// attribute values. Handles the five characters required by the
301/// HTML spec.
302fn 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("&amp;"),
307            '<' => out.push_str("&lt;"),
308            '>' => out.push_str("&gt;"),
309            '"' => out.push_str("&quot;"),
310            '\'' => out.push_str("&#39;"),
311            other => out.push(other),
312        }
313    }
314    out
315}
316
317/// Inline CSS — kept tight (~4 KB) and self-contained so the
318/// generated HTML works offline. No JS, no external assets.
319const 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("&lt;script&gt;"));
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        // Each severity bucket appears with its count.
449        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}