Skip to main content

allow_report/
html.rs

1use allow_core::{Finding, MatchOutcome, MatchStatus};
2
3use crate::text::html_escape;
4use crate::{
5    CLAIM_BOUNDARY_TEXT, FilePosture, ReportContext, ReviewSignals, STATUS_COUNT_ORDER, Summary,
6    audit_review_queue, non_rust_file_rows, render_source_inventory_html,
7};
8
9pub fn render_html(
10    command: &str,
11    findings: &[Finding],
12    outcomes: &[MatchOutcome],
13    failed: bool,
14) -> String {
15    render_html_with_context(
16        command,
17        findings,
18        outcomes,
19        failed,
20        ReportContext::default(),
21    )
22}
23
24pub fn render_html_with_context(
25    command: &str,
26    findings: &[Finding],
27    outcomes: &[MatchOutcome],
28    failed: bool,
29    context: ReportContext<'_>,
30) -> String {
31    let summary = Summary::from_outcomes(outcomes);
32    let mut out = String::new();
33    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
34    out.push_str("  <meta charset=\"utf-8\">\n");
35    out.push_str(&format!(
36        "  <title>cargo-allow {}</title>\n",
37        html_escape(command)
38    ));
39    out.push_str("  <style>body{font-family:system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;line-height:1.45}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{border:1px solid #d0d7de;padding:.4rem .55rem;text-align:left}th{background:#f6f8fa}td.count{text-align:right;font-variant-numeric:tabular-nums}.status{font-weight:700}.failed{color:#b42318}.passed{color:#1a7f37}code{background:#f6f8fa;padding:.1rem .25rem;border-radius:4px}.claim{border-left:4px solid #57606a;padding-left:1rem;color:#57606a}</style>\n");
40    out.push_str("</head>\n<body>\n");
41    out.push_str(&format!("<h1>cargo-allow {}</h1>\n", html_escape(command)));
42    out.push_str(&format!(
43        "<p class=\"status {}\">Result: {}</p>\n",
44        if failed { "failed" } else { "passed" },
45        if failed { "failed" } else { "passed/advisory" }
46    ));
47    out.push_str(&format!(
48        "<p>Findings scanned: <code>{}</code></p>\n",
49        findings.len()
50    ));
51    out.push_str(&format!(
52        "<p>Inventory: <code>source_tree</code> / <code>source_syntax</code> via <code>{}</code>{}</p>\n",
53        html_escape(context.inventory.source),
54        inventory_files_html_suffix(context)
55    ));
56    if let Some(root) = context.inventory.root {
57        out.push_str(&format!(
58            "<p>Source tree root: <code>{}</code></p>\n",
59            html_escape(root)
60        ));
61    }
62    out.push_str("<h2>Status Counts</h2>\n");
63    render_status_count_table_html(&summary, &mut out);
64    if command == "audit" {
65        render_source_inventory_html(findings, outcomes, &mut out);
66        render_audit_summary_html(&summary, outcomes, context, &mut out);
67    }
68    render_non_rust_html(findings, outcomes, &mut out);
69    render_non_matched_html(outcomes, &mut out);
70    out.push_str("<h2>Claim Boundary</h2>\n");
71    out.push_str(&format!(
72        "<p class=\"claim\">{}</p>\n",
73        html_escape(CLAIM_BOUNDARY_TEXT)
74    ));
75    out.push_str("</body>\n</html>\n");
76    out
77}
78
79fn render_status_count_table_html(summary: &Summary, out: &mut String) {
80    out.push_str("<table><thead><tr><th>Status</th><th>Count</th></tr></thead><tbody>\n");
81    for status in STATUS_COUNT_ORDER {
82        out.push_str(&format!(
83            "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
84            status.as_str(),
85            summary.count(status)
86        ));
87    }
88    out.push_str("</tbody></table>\n");
89}
90
91fn render_audit_summary_html(
92    summary: &Summary,
93    outcomes: &[MatchOutcome],
94    context: ReportContext<'_>,
95    out: &mut String,
96) {
97    let signals = ReviewSignals::from_summary(summary, context);
98    let queue = audit_review_queue(outcomes);
99    out.push_str("<h2>Audit Summary</h2>\n");
100    out.push_str("<table><thead><tr><th>Signal</th><th>Count</th></tr></thead><tbody>\n");
101    for (name, value) in [
102        ("Match outcomes", summary.total),
103        ("Review items", signals.review_items),
104        ("New unreceipted", summary.count(MatchStatus::New)),
105        ("Expired", summary.count(MatchStatus::Expired)),
106        ("Evidence gaps", summary.count(MatchStatus::EvidenceMissing)),
107        ("Policy missing evidence", signals.policy_missing_evidence),
108        ("Broken evidence links", signals.broken_evidence_links),
109        ("Weak evidence references", signals.weak_evidence_references),
110        ("Baseline debt", signals.baseline_debt),
111    ] {
112        out.push_str(&format!(
113            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
114            html_escape(name),
115            value
116        ));
117    }
118    out.push_str("</tbody></table>\n");
119    if signals.review_items == 0 {
120        out.push_str("<p>Recommended next step: keep <code>cargo-allow check --mode no-new</code> in CI.</p>\n");
121    } else if queue.is_empty() && signals.broken_evidence_links > 0 {
122        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --item-kind broken_evidence_link --format json</code> to repair broken local evidence references.</p>\n");
123    } else if queue.is_empty()
124        && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
125    {
126        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --missing-evidence --format json</code> to route retained entries with no evidence references.</p>\n");
127    } else if queue.is_empty() && signals.weak_evidence_references > 0 {
128        out.push_str("<p>Recommended next step: replace unstructured or unknown-prefix evidence with known evidence prefixes before tightening policy.</p>\n");
129    } else if queue.is_empty() && signals.baseline_debt > 0 {
130        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --format json</code> to review generated baseline debt.</p>\n");
131    } else {
132        out.push_str(
133            "<p>Recommended next step: review the queue below before tightening policy.</p>\n",
134        );
135    }
136    if !queue.is_empty() {
137        out.push_str("<h2>Audit Review Queue</h2>\n<ul>\n");
138        for outcome in queue {
139            out.push_str(&format!(
140                "<li><code>{}</code>: {}</li>\n",
141                outcome.status.as_str(),
142                html_escape(&outcome.message)
143            ));
144        }
145        out.push_str("</ul>\n");
146    }
147}
148
149fn render_non_rust_html(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
150    let posture = FilePosture::from_report(findings, outcomes);
151    if !posture.has_files() {
152        return;
153    }
154    out.push_str("<h2>Non-Rust File Inventory</h2>\n");
155    out.push_str("<table><thead><tr><th>Metric</th><th>Count</th></tr></thead><tbody>\n");
156    for (name, value) in [
157        ("Files scanned", posture.total),
158        ("Matched", posture.matched),
159        ("New", posture.new),
160        ("Generated", posture.generated),
161    ] {
162        out.push_str(&format!(
163            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
164            html_escape(name),
165            value
166        ));
167    }
168    out.push_str("</tbody></table>\n");
169    if !posture.by_family.is_empty() {
170        out.push_str("<table><thead><tr><th>Family</th><th>Count</th></tr></thead><tbody>\n");
171        for (family, count) in posture.by_family {
172            out.push_str(&format!(
173                "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
174                html_escape(&family),
175                count
176            ));
177        }
178        out.push_str("</tbody></table>\n");
179    }
180    let rows = non_rust_file_rows(findings, outcomes);
181    if !rows.is_empty() {
182        out.push_str(
183            "<table><thead><tr><th>Status</th><th>Family</th><th>Path</th></tr></thead><tbody>\n",
184        );
185        for row in rows.into_iter().take(60) {
186            out.push_str(&format!(
187                "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
188                html_escape(row.status),
189                html_escape(&row.family),
190                html_escape(&row.path)
191            ));
192        }
193        out.push_str("</tbody></table>\n");
194    }
195}
196
197fn render_non_matched_html(outcomes: &[MatchOutcome], out: &mut String) {
198    let non_matched = outcomes
199        .iter()
200        .filter(|outcome| outcome.status != MatchStatus::Matched)
201        .take(100)
202        .collect::<Vec<_>>();
203    if non_matched.is_empty() {
204        return;
205    }
206    out.push_str("<h2>Non-matched Outcomes</h2>\n<ul>\n");
207    for outcome in non_matched {
208        out.push_str(&format!(
209            "<li><code>{}</code>: {}</li>\n",
210            outcome.status.as_str(),
211            html_escape(&outcome.message)
212        ));
213    }
214    out.push_str("</ul>\n");
215}
216
217fn inventory_files_html_suffix(context: ReportContext<'_>) -> String {
218    context
219        .inventory
220        .files_scanned
221        .map(|files| format!("; files scanned: <code>{files}</code>"))
222        .unwrap_or_default()
223}