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, baseline_debt_count, broken_evidence_link_count, non_rust_file_rows,
7    policy_missing_evidence_count, render_source_inventory_html, weak_evidence_reference_count,
8};
9
10pub fn render_html(
11    command: &str,
12    findings: &[Finding],
13    outcomes: &[MatchOutcome],
14    failed: bool,
15) -> String {
16    render_html_with_context(
17        command,
18        findings,
19        outcomes,
20        failed,
21        ReportContext::default(),
22    )
23}
24
25pub fn render_html_with_context(
26    command: &str,
27    findings: &[Finding],
28    outcomes: &[MatchOutcome],
29    failed: bool,
30    context: ReportContext<'_>,
31) -> String {
32    let summary = Summary::from_outcomes(outcomes);
33    let mut out = String::new();
34    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
35    out.push_str("  <meta charset=\"utf-8\">\n");
36    out.push_str(&format!(
37        "  <title>cargo-allow {}</title>\n",
38        html_escape(command)
39    ));
40    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");
41    out.push_str("</head>\n<body>\n");
42    out.push_str(&format!("<h1>cargo-allow {}</h1>\n", html_escape(command)));
43    out.push_str(&format!(
44        "<p class=\"status {}\">Result: {}</p>\n",
45        if failed { "failed" } else { "passed" },
46        if failed { "failed" } else { "passed/advisory" }
47    ));
48    out.push_str(&format!(
49        "<p>Findings scanned: <code>{}</code></p>\n",
50        findings.len()
51    ));
52    out.push_str(&format!(
53        "<p>Inventory: <code>source_tree</code> / <code>source_syntax</code> via <code>{}</code>{}</p>\n",
54        html_escape(context.inventory.source),
55        inventory_files_html_suffix(context)
56    ));
57    if let Some(root) = context.inventory.root {
58        out.push_str(&format!(
59            "<p>Source tree root: <code>{}</code></p>\n",
60            html_escape(root)
61        ));
62    }
63    out.push_str("<h2>Status Counts</h2>\n");
64    render_status_count_table_html(&summary, context, &mut out);
65    if command != "audit" {
66        let signals = ReviewSignals::from_summary(&summary, context);
67        render_evidence_repair_queues_html(&summary, signals, &mut out);
68    }
69    if command == "audit" {
70        render_source_inventory_html(findings, outcomes, &mut out);
71        render_audit_summary_html(&summary, outcomes, context, &mut out);
72    }
73    render_non_rust_html(findings, outcomes, &mut out);
74    render_non_matched_html(outcomes, &mut out);
75    out.push_str("<h2>Claim Boundary</h2>\n");
76    out.push_str(&format!(
77        "<p class=\"claim\">{}</p>\n",
78        html_escape(CLAIM_BOUNDARY_TEXT)
79    ));
80    out.push_str("</body>\n</html>\n");
81    out
82}
83
84fn render_status_count_table_html(summary: &Summary, context: ReportContext<'_>, out: &mut String) {
85    out.push_str("<table><thead><tr><th>Status</th><th>Count</th></tr></thead><tbody>\n");
86    for status in STATUS_COUNT_ORDER {
87        out.push_str(&format!(
88            "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
89            status.as_str(),
90            summary.count(status)
91        ));
92    }
93    for (name, count) in policy_context_count_rows(summary, context) {
94        out.push_str(&format!(
95            "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
96            html_escape(name),
97            count
98        ));
99    }
100    out.push_str("</tbody></table>\n");
101}
102
103fn policy_context_count_rows(
104    summary: &Summary,
105    context: ReportContext<'_>,
106) -> Vec<(&'static str, usize)> {
107    let mut rows = Vec::new();
108    let baseline_debt = baseline_debt_count(summary, context);
109    if baseline_debt > summary.count(MatchStatus::BaselineDebt) {
110        rows.push(("policy_baseline_debt", baseline_debt));
111    }
112    let policy_missing_evidence = policy_missing_evidence_count(summary, context);
113    if policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
114        rows.push(("policy_missing_evidence", policy_missing_evidence));
115    }
116    let broken_evidence_links = broken_evidence_link_count(context);
117    if broken_evidence_links > 0 {
118        rows.push(("broken_evidence_links", broken_evidence_links));
119    }
120    let weak_evidence_references = weak_evidence_reference_count(context);
121    if weak_evidence_references > 0 {
122        rows.push(("weak_evidence_references", weak_evidence_references));
123    }
124    rows
125}
126
127fn render_audit_summary_html(
128    summary: &Summary,
129    outcomes: &[MatchOutcome],
130    context: ReportContext<'_>,
131    out: &mut String,
132) {
133    let signals = ReviewSignals::from_summary(summary, context);
134    let queue = audit_review_queue(outcomes);
135    out.push_str("<h2>Audit Summary</h2>\n");
136    out.push_str("<table><thead><tr><th>Signal</th><th>Count</th></tr></thead><tbody>\n");
137    for (name, value) in [
138        ("Match outcomes", summary.total),
139        ("Review items", signals.review_items),
140        ("New unreceipted", summary.count(MatchStatus::New)),
141        ("Expired", summary.count(MatchStatus::Expired)),
142        ("Evidence gaps", summary.count(MatchStatus::EvidenceMissing)),
143        ("Policy missing evidence", signals.policy_missing_evidence),
144        ("Broken evidence links", signals.broken_evidence_links),
145        (
146            "Weak evidence/link references",
147            signals.weak_evidence_references,
148        ),
149        ("Baseline debt", signals.baseline_debt),
150    ] {
151        out.push_str(&format!(
152            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
153            html_escape(name),
154            value
155        ));
156    }
157    out.push_str("</tbody></table>\n");
158    if signals.review_items == 0 {
159        out.push_str("<p>Recommended next step: keep <code>cargo-allow check --mode no-new</code> in CI.</p>\n");
160    } else if queue.is_empty() && signals.broken_evidence_links > 0 {
161        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/link references.</p>\n");
162    } else if queue.is_empty()
163        && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
164    {
165        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --format json</code> to route retained entries with no evidence references; add <code>--missing-evidence</code> to focus that queue.</p>\n");
166    } else if queue.is_empty() && signals.weak_evidence_references > 0 {
167        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --item-kind weak_evidence_reference --format json</code> to replace unstructured or unknown-prefix evidence/link references.</p>\n");
168    } else if queue.is_empty() && signals.baseline_debt > 0 {
169        out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --format json</code> to review generated baseline debt.</p>\n");
170    } else {
171        out.push_str(
172            "<p>Recommended next step: review the queue below before tightening policy.</p>\n",
173        );
174    }
175    render_evidence_repair_queues_html(summary, signals, out);
176    if !queue.is_empty() {
177        out.push_str("<h2>Audit Review Queue</h2>\n<ul>\n");
178        for outcome in queue {
179            out.push_str(&format!(
180                "<li><code>{}</code>: {}</li>\n",
181                outcome.status.as_str(),
182                html_escape(&outcome.message)
183            ));
184        }
185        out.push_str("</ul>\n");
186    }
187}
188
189fn render_evidence_repair_queues_html(summary: &Summary, signals: ReviewSignals, out: &mut String) {
190    let commands = evidence_repair_commands(summary, signals);
191    if commands.is_empty() {
192        return;
193    }
194    out.push_str("<h3>Evidence Repair Queues</h3>\n<ul>\n");
195    for command in commands {
196        out.push_str(&format!("<li><code>{}</code></li>\n", html_escape(command)));
197    }
198    out.push_str("</ul>\n");
199}
200
201fn evidence_repair_commands(summary: &Summary, signals: ReviewSignals) -> Vec<&'static str> {
202    let mut commands = Vec::new();
203    if signals.broken_evidence_links > 0 {
204        commands.push("cargo-allow worklist --item-kind broken_evidence_link --format json");
205    }
206    if signals.policy_missing_evidence > 0 || summary.count(MatchStatus::EvidenceMissing) > 0 {
207        commands.push("cargo-allow worklist --missing-evidence --format json");
208    }
209    if signals.weak_evidence_references > 0 {
210        commands.push("cargo-allow worklist --item-kind weak_evidence_reference --format json");
211    }
212    commands
213}
214
215fn render_non_rust_html(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
216    let posture = FilePosture::from_report(findings, outcomes);
217    if !posture.has_files() {
218        return;
219    }
220    out.push_str("<h2>Non-Rust File Inventory</h2>\n");
221    out.push_str("<table><thead><tr><th>Metric</th><th>Count</th></tr></thead><tbody>\n");
222    for (name, value) in [
223        ("Files scanned", posture.total),
224        ("Matched", posture.matched),
225        ("New", posture.new),
226        ("Generated", posture.generated),
227    ] {
228        out.push_str(&format!(
229            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
230            html_escape(name),
231            value
232        ));
233    }
234    out.push_str("</tbody></table>\n");
235    if !posture.by_family.is_empty() {
236        out.push_str("<table><thead><tr><th>Family</th><th>Count</th></tr></thead><tbody>\n");
237        for (family, count) in posture.by_family {
238            out.push_str(&format!(
239                "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
240                html_escape(&family),
241                count
242            ));
243        }
244        out.push_str("</tbody></table>\n");
245    }
246    let rows = non_rust_file_rows(findings, outcomes);
247    if !rows.is_empty() {
248        out.push_str(
249            "<table><thead><tr><th>Status</th><th>Family</th><th>Path</th></tr></thead><tbody>\n",
250        );
251        for row in rows.into_iter().take(60) {
252            out.push_str(&format!(
253                "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
254                html_escape(row.status),
255                html_escape(&row.family),
256                html_escape(&row.path)
257            ));
258        }
259        out.push_str("</tbody></table>\n");
260    }
261}
262
263fn render_non_matched_html(outcomes: &[MatchOutcome], out: &mut String) {
264    let non_matched = outcomes
265        .iter()
266        .filter(|outcome| outcome.status != MatchStatus::Matched)
267        .take(100)
268        .collect::<Vec<_>>();
269    if non_matched.is_empty() {
270        return;
271    }
272    out.push_str("<h2>Non-matched Outcomes</h2>\n<ul>\n");
273    for outcome in non_matched {
274        out.push_str(&format!(
275            "<li><code>{}</code>: {}</li>\n",
276            outcome.status.as_str(),
277            html_escape(&outcome.message)
278        ));
279    }
280    out.push_str("</ul>\n");
281}
282
283fn inventory_files_html_suffix(context: ReportContext<'_>) -> String {
284    context
285        .inventory
286        .files_scanned
287        .map(|files| format!("; files scanned: <code>{files}</code>"))
288        .unwrap_or_default()
289}