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 (
110 "Weak evidence/link references",
111 signals.weak_evidence_references,
112 ),
113 ("Baseline debt", signals.baseline_debt),
114 ] {
115 out.push_str(&format!(
116 "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
117 html_escape(name),
118 value
119 ));
120 }
121 out.push_str("</tbody></table>\n");
122 if signals.review_items == 0 {
123 out.push_str("<p>Recommended next step: keep <code>cargo-allow check --mode no-new</code> in CI.</p>\n");
124 } else if queue.is_empty() && signals.broken_evidence_links > 0 {
125 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");
126 } else if queue.is_empty()
127 && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
128 {
129 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");
130 } else if queue.is_empty() && signals.weak_evidence_references > 0 {
131 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");
132 } else if queue.is_empty() && signals.baseline_debt > 0 {
133 out.push_str("<p>Recommended next step: run <code>cargo-allow worklist --format json</code> to review generated baseline debt.</p>\n");
134 } else {
135 out.push_str(
136 "<p>Recommended next step: review the queue below before tightening policy.</p>\n",
137 );
138 }
139 if !queue.is_empty() {
140 out.push_str("<h2>Audit Review Queue</h2>\n<ul>\n");
141 for outcome in queue {
142 out.push_str(&format!(
143 "<li><code>{}</code>: {}</li>\n",
144 outcome.status.as_str(),
145 html_escape(&outcome.message)
146 ));
147 }
148 out.push_str("</ul>\n");
149 }
150}
151
152fn render_non_rust_html(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
153 let posture = FilePosture::from_report(findings, outcomes);
154 if !posture.has_files() {
155 return;
156 }
157 out.push_str("<h2>Non-Rust File Inventory</h2>\n");
158 out.push_str("<table><thead><tr><th>Metric</th><th>Count</th></tr></thead><tbody>\n");
159 for (name, value) in [
160 ("Files scanned", posture.total),
161 ("Matched", posture.matched),
162 ("New", posture.new),
163 ("Generated", posture.generated),
164 ] {
165 out.push_str(&format!(
166 "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
167 html_escape(name),
168 value
169 ));
170 }
171 out.push_str("</tbody></table>\n");
172 if !posture.by_family.is_empty() {
173 out.push_str("<table><thead><tr><th>Family</th><th>Count</th></tr></thead><tbody>\n");
174 for (family, count) in posture.by_family {
175 out.push_str(&format!(
176 "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
177 html_escape(&family),
178 count
179 ));
180 }
181 out.push_str("</tbody></table>\n");
182 }
183 let rows = non_rust_file_rows(findings, outcomes);
184 if !rows.is_empty() {
185 out.push_str(
186 "<table><thead><tr><th>Status</th><th>Family</th><th>Path</th></tr></thead><tbody>\n",
187 );
188 for row in rows.into_iter().take(60) {
189 out.push_str(&format!(
190 "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
191 html_escape(row.status),
192 html_escape(&row.family),
193 html_escape(&row.path)
194 ));
195 }
196 out.push_str("</tbody></table>\n");
197 }
198}
199
200fn render_non_matched_html(outcomes: &[MatchOutcome], out: &mut String) {
201 let non_matched = outcomes
202 .iter()
203 .filter(|outcome| outcome.status != MatchStatus::Matched)
204 .take(100)
205 .collect::<Vec<_>>();
206 if non_matched.is_empty() {
207 return;
208 }
209 out.push_str("<h2>Non-matched Outcomes</h2>\n<ul>\n");
210 for outcome in non_matched {
211 out.push_str(&format!(
212 "<li><code>{}</code>: {}</li>\n",
213 outcome.status.as_str(),
214 html_escape(&outcome.message)
215 ));
216 }
217 out.push_str("</ul>\n");
218}
219
220fn inventory_files_html_suffix(context: ReportContext<'_>) -> String {
221 context
222 .inventory
223 .files_scanned
224 .map(|files| format!("; files scanned: <code>{files}</code>"))
225 .unwrap_or_default()
226}