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