1use crate::non_rust::{render_non_rust_human, render_non_rust_markdown};
2use crate::text::markdown_inline_code;
3use crate::{
4 AUDIT_REVIEW_QUEUE_STATUSES, CLAIM_BOUNDARY_TEXT, ReportContext, ReviewSignals,
5 STATUS_COUNT_ORDER, Summary, baseline_debt_count, broken_evidence_link_count,
6 policy_missing_evidence_count, render_source_inventory_human, render_source_inventory_markdown,
7 weak_evidence_reference_count,
8};
9use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape};
10
11const HUMAN_NON_MATCHED_OUTCOME_LIMIT: usize = 80;
12const MARKDOWN_NON_MATCHED_OUTCOME_LIMIT: usize = 100;
13const AUDIT_REVIEW_QUEUE_LIMIT: usize = 20;
14
15pub fn render_human(
16 command: &str,
17 findings: &[Finding],
18 outcomes: &[MatchOutcome],
19 failed: bool,
20) -> String {
21 render_human_with_context(
22 command,
23 findings,
24 outcomes,
25 failed,
26 ReportContext::default(),
27 )
28}
29
30pub fn render_human_with_context(
31 command: &str,
32 findings: &[Finding],
33 outcomes: &[MatchOutcome],
34 failed: bool,
35 context: ReportContext<'_>,
36) -> String {
37 let summary = Summary::from_outcomes(outcomes);
38 let mut out = String::new();
39 out.push_str(&format!("cargo-allow {command}\n\n"));
40 out.push_str(&format!("Findings scanned: {}\n", findings.len()));
41 out.push_str(&format!(
42 "Inventory: source_tree/source_syntax via {}{}\n",
43 context.inventory.source,
44 inventory_files_suffix(context)
45 ));
46 if let Some(root) = context.inventory.root {
47 out.push_str(&format!("Source tree root: {root}\n"));
48 }
49 for status in STATUS_COUNT_ORDER {
50 let count = summary.count(status);
51 if count > 0 {
52 out.push_str(&format!(" {:24} {}\n", status.as_str(), count));
53 }
54 }
55 if let Some(baseline_debt) = policy_baseline_debt_note(&summary, context) {
56 out.push_str(&format!(
57 " {:24} {}\n",
58 "policy_baseline_debt", baseline_debt
59 ));
60 }
61 if let Some(policy_missing_evidence) = policy_missing_evidence_note(&summary, context) {
62 out.push_str(&format!(
63 " {:24} {}\n",
64 "policy_missing_evidence", policy_missing_evidence
65 ));
66 }
67 let broken_evidence_links = broken_evidence_link_count(context);
68 if broken_evidence_links > 0 {
69 out.push_str(&format!(
70 " {:24} {}\n",
71 "broken_evidence_links", broken_evidence_links
72 ));
73 }
74 let weak_evidence_references = weak_evidence_reference_count(context);
75 if weak_evidence_references > 0 {
76 out.push_str(&format!(
77 " {:24} {}\n",
78 "weak_evidence_references", weak_evidence_references
79 ));
80 }
81 if outcomes.is_empty() {
82 out.push_str(" no outcomes\n");
83 }
84 if command == "audit" {
85 render_source_inventory_human(findings, outcomes, &mut out);
86 render_audit_summary_human(&summary, outcomes, context, &mut out);
87 }
88 render_non_rust_human(findings, outcomes, &mut out);
89 if command != "audit" {
90 let signals = ReviewSignals::from_summary(&summary, context);
91 append_evidence_repair_queues_human(&summary, signals, &mut out);
92 }
93 out.push('\n');
94 let non_matched = outcomes
95 .iter()
96 .filter(|o| o.status != MatchStatus::Matched)
97 .collect::<Vec<_>>();
98 for outcome in non_matched.iter().take(HUMAN_NON_MATCHED_OUTCOME_LIMIT) {
99 out.push_str(&format!(
100 "{}: {}\n",
101 outcome.status.as_str(),
102 outcome.message
103 ));
104 }
105 append_human_omitted_outcome_note(&mut out, non_matched.len());
106 out.push('\n');
107 out.push_str(CLAIM_BOUNDARY_TEXT);
108 out.push('\n');
109 out.push_str(if failed {
110 "Result: failed\n"
111 } else {
112 "Result: passed/advisory\n"
113 });
114 out
115}
116
117fn render_audit_summary_human(
118 summary: &Summary,
119 outcomes: &[MatchOutcome],
120 context: ReportContext<'_>,
121 out: &mut String,
122) {
123 let signals = ReviewSignals::from_summary(summary, context);
124 let queue = outcomes
125 .iter()
126 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
127 .collect::<Vec<_>>();
128 out.push_str("\nAudit summary:\n");
129 out.push_str(&format!(" {:24} {}\n", "match_outcomes", summary.total));
130 out.push_str(&format!(
131 " {:24} {}\n",
132 "review_items", signals.review_items
133 ));
134 out.push_str(&format!(
135 " {:24} {}\n",
136 "new_unreceipted",
137 summary.count(MatchStatus::New)
138 ));
139 out.push_str(&format!(
140 " {:24} {}\n",
141 "expired",
142 summary.count(MatchStatus::Expired)
143 ));
144 out.push_str(&format!(
145 " {:24} {}\n",
146 "evidence_gaps",
147 summary.count(MatchStatus::EvidenceMissing)
148 ));
149 out.push_str(&format!(
150 " {:24} {}\n",
151 "policy_missing_evidence", signals.policy_missing_evidence
152 ));
153 out.push_str(&format!(
154 " {:24} {}\n",
155 "broken_evidence_links", signals.broken_evidence_links
156 ));
157 out.push_str(&format!(
158 " {:24} {}\n",
159 "weak_evidence_references", signals.weak_evidence_references
160 ));
161 out.push_str(&format!(
162 " {:24} {}\n",
163 "baseline_debt", signals.baseline_debt
164 ));
165 out.push_str(audit_recommended_next_step(
166 summary,
167 signals,
168 queue.is_empty(),
169 ));
170 append_evidence_repair_queues_human(summary, signals, out);
171 if !queue.is_empty() {
172 out.push_str("\nAudit review queue:\n");
173 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
174 out.push_str(&format!(
175 " {}: {}\n",
176 outcome.status.as_str(),
177 outcome.message
178 ));
179 }
180 append_human_omitted_review_queue_note(out, queue.len());
181 }
182}
183
184pub fn render_markdown(
185 command: &str,
186 findings: &[Finding],
187 outcomes: &[MatchOutcome],
188 failed: bool,
189) -> String {
190 render_markdown_with_context(
191 command,
192 findings,
193 outcomes,
194 failed,
195 ReportContext::default(),
196 )
197}
198
199pub fn render_markdown_with_context(
200 command: &str,
201 findings: &[Finding],
202 outcomes: &[MatchOutcome],
203 failed: bool,
204 context: ReportContext<'_>,
205) -> String {
206 let summary = Summary::from_outcomes(outcomes);
207 let mut out = String::new();
208 out.push_str(&format!("# cargo-allow {command}\n\n"));
209 out.push_str(&format!(
210 "**Result:** {}\n\n",
211 if failed { "failed" } else { "passed/advisory" }
212 ));
213 out.push_str(&format!("Findings scanned: `{}`\n\n", findings.len()));
214 out.push_str(&format!(
215 "Inventory: `source_tree` / `source_syntax` via `{}`{}\n\n",
216 json_escape(context.inventory.source),
217 inventory_files_markdown_suffix(context)
218 ));
219 if let Some(root) = context.inventory.root {
220 out.push_str(&format!(
221 "Source tree root: `{}`\n\n",
222 markdown_inline_code(root)
223 ));
224 }
225 out.push_str("| Status | Count |\n|---|---:|\n");
226 for status in STATUS_COUNT_ORDER {
227 let count = summary.count(status);
228 out.push_str(&format!("| `{}` | {} |\n", status.as_str(), count));
229 }
230 if let Some(baseline_debt) = policy_baseline_debt_note(&summary, context) {
231 out.push_str(&format!("| `policy_baseline_debt` | {} |\n", baseline_debt));
232 }
233 if let Some(policy_missing_evidence) = policy_missing_evidence_note(&summary, context) {
234 out.push_str(&format!(
235 "| `policy_missing_evidence` | {} |\n",
236 policy_missing_evidence
237 ));
238 }
239 let broken_evidence_links = broken_evidence_link_count(context);
240 if broken_evidence_links > 0 {
241 out.push_str(&format!(
242 "| `broken_evidence_links` | {} |\n",
243 broken_evidence_links
244 ));
245 }
246 let weak_evidence_references = weak_evidence_reference_count(context);
247 if weak_evidence_references > 0 {
248 out.push_str(&format!(
249 "| `weak_evidence_references` | {} |\n",
250 weak_evidence_references
251 ));
252 }
253 if command == "audit" {
254 render_source_inventory_markdown(findings, outcomes, &mut out);
255 render_audit_summary_markdown(&summary, outcomes, context, &mut out);
256 }
257 render_non_rust_markdown(findings, outcomes, &mut out);
258 if command != "audit" {
259 let signals = ReviewSignals::from_summary(&summary, context);
260 append_evidence_repair_queues_markdown(&summary, signals, &mut out);
261 }
262 let non_matched = outcomes
263 .iter()
264 .filter(|o| o.status != MatchStatus::Matched)
265 .collect::<Vec<_>>();
266 if !non_matched.is_empty() {
267 out.push_str("\n## Non-matched outcomes\n\n");
268 for outcome in non_matched.iter().take(MARKDOWN_NON_MATCHED_OUTCOME_LIMIT) {
269 out.push_str(&format!(
270 "- `{}`: {}\n",
271 outcome.status.as_str(),
272 outcome.message
273 ));
274 }
275 append_markdown_omitted_outcome_note(&mut out, non_matched.len());
276 }
277 out.push_str("\n> ");
278 out.push_str(CLAIM_BOUNDARY_TEXT);
279 out.push('\n');
280 out
281}
282
283fn append_human_omitted_outcome_note(out: &mut String, outcome_count: usize) {
284 if outcome_count > HUMAN_NON_MATCHED_OUTCOME_LIMIT {
285 let omitted = outcome_count - HUMAN_NON_MATCHED_OUTCOME_LIMIT;
286 let plural = if omitted == 1 { "" } else { "s" };
287 out.push_str(&format!(
288 "... {omitted} additional non-matched outcome{plural} omitted from this listing\n"
289 ));
290 }
291}
292
293fn append_markdown_omitted_outcome_note(out: &mut String, outcome_count: usize) {
294 if outcome_count > MARKDOWN_NON_MATCHED_OUTCOME_LIMIT {
295 let omitted = outcome_count - MARKDOWN_NON_MATCHED_OUTCOME_LIMIT;
296 let plural = if omitted == 1 { "" } else { "s" };
297 out.push_str(&format!(
298 "\n{omitted} additional non-matched outcome{plural} omitted from this listing.\n"
299 ));
300 }
301}
302
303fn render_audit_summary_markdown(
304 summary: &Summary,
305 outcomes: &[MatchOutcome],
306 context: ReportContext<'_>,
307 out: &mut String,
308) {
309 let signals = ReviewSignals::from_summary(summary, context);
310 let queue = outcomes
311 .iter()
312 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
313 .collect::<Vec<_>>();
314 out.push_str("\n## Audit Summary\n\n");
315 out.push_str("| Signal | Count |\n|---|---:|\n");
316 out.push_str(&format!("| Match outcomes | {} |\n", summary.total));
317 out.push_str(&format!("| Review items | {} |\n", signals.review_items));
318 out.push_str(&format!(
319 "| New unreceipted | {} |\n",
320 summary.count(MatchStatus::New)
321 ));
322 out.push_str(&format!(
323 "| Expired | {} |\n",
324 summary.count(MatchStatus::Expired)
325 ));
326 out.push_str(&format!(
327 "| Evidence gaps | {} |\n",
328 summary.count(MatchStatus::EvidenceMissing)
329 ));
330 out.push_str(&format!(
331 "| Policy missing evidence | {} |\n",
332 signals.policy_missing_evidence
333 ));
334 out.push_str(&format!(
335 "| Broken evidence links | {} |\n",
336 signals.broken_evidence_links
337 ));
338 out.push_str(&format!(
339 "| Weak evidence/link references | {} |\n",
340 signals.weak_evidence_references
341 ));
342 out.push_str(&format!("| Baseline debt | {} |\n", signals.baseline_debt));
343 out.push_str(audit_recommended_next_step(
344 summary,
345 signals,
346 queue.is_empty(),
347 ));
348 append_evidence_repair_queues_markdown(summary, signals, out);
349
350 if !queue.is_empty() {
351 out.push_str("\n## Audit Review Queue\n\n");
352 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
353 out.push_str(&format!(
354 "- `{}`: {}\n",
355 outcome.status.as_str(),
356 outcome.message
357 ));
358 }
359 append_markdown_omitted_review_queue_note(out, queue.len());
360 }
361}
362
363fn append_evidence_repair_queues_human(
364 summary: &Summary,
365 signals: ReviewSignals,
366 out: &mut String,
367) {
368 let commands = evidence_repair_commands(summary, signals);
369 if commands.is_empty() {
370 return;
371 }
372 out.push_str("\nEvidence repair queues:\n");
373 for command in commands {
374 out.push_str(&format!(" {command}\n"));
375 }
376}
377
378fn append_evidence_repair_queues_markdown(
379 summary: &Summary,
380 signals: ReviewSignals,
381 out: &mut String,
382) {
383 let commands = evidence_repair_commands(summary, signals);
384 if commands.is_empty() {
385 return;
386 }
387 out.push_str("\n### Evidence Repair Queues\n\n");
388 for command in commands {
389 out.push_str(&format!("- `{command}`\n"));
390 }
391}
392
393fn evidence_repair_commands(summary: &Summary, signals: ReviewSignals) -> Vec<&'static str> {
394 let mut commands = Vec::new();
395 if signals.broken_evidence_links > 0 {
396 commands.push("cargo-allow worklist --item-kind broken_evidence_link --format json");
397 }
398 if signals.policy_missing_evidence > 0 || summary.count(MatchStatus::EvidenceMissing) > 0 {
399 commands.push("cargo-allow worklist --missing-evidence --format json");
400 }
401 if signals.weak_evidence_references > 0 {
402 commands.push("cargo-allow worklist --item-kind weak_evidence_reference --format json");
403 }
404 commands
405}
406
407fn append_human_omitted_review_queue_note(out: &mut String, queue_count: usize) {
408 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
409 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
410 let plural = if omitted == 1 { "" } else { "s" };
411 out.push_str(&format!(
412 " ... {omitted} additional audit review item{plural} omitted from this queue\n"
413 ));
414 }
415}
416
417fn append_markdown_omitted_review_queue_note(out: &mut String, queue_count: usize) {
418 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
419 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
420 let plural = if omitted == 1 { "" } else { "s" };
421 out.push_str(&format!(
422 "\n{omitted} additional audit review item{plural} omitted from this queue.\n"
423 ));
424 }
425}
426
427fn audit_recommended_next_step(
428 summary: &Summary,
429 signals: ReviewSignals,
430 queue_empty: bool,
431) -> &'static str {
432 if signals.review_items == 0 {
433 "\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n"
434 } else if queue_empty && signals.broken_evidence_links > 0 {
435 "\nRecommended next step: run `cargo-allow worklist --item-kind broken_evidence_link --format json` to repair broken local evidence/link references.\n"
436 } else if queue_empty
437 && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
438 {
439 "\nRecommended next step: run `cargo-allow worklist --format json` to route retained entries with no evidence references; add `--missing-evidence` to focus that queue.\n"
440 } else if queue_empty && signals.weak_evidence_references > 0 {
441 "\nRecommended next step: run `cargo-allow worklist --item-kind weak_evidence_reference --format json` to replace unstructured or unknown-prefix evidence/link references.\n"
442 } else if queue_empty && signals.baseline_debt > 0 {
443 "\nRecommended next step: run `cargo-allow worklist --format json` to review generated baseline debt.\n"
444 } else {
445 "\nRecommended next step: review the queue below before tightening policy.\n"
446 }
447}
448
449fn inventory_files_suffix(context: ReportContext<'_>) -> String {
450 context
451 .inventory
452 .files_scanned
453 .map(|files| format!("; files scanned: {files}"))
454 .unwrap_or_default()
455}
456
457fn inventory_files_markdown_suffix(context: ReportContext<'_>) -> String {
458 context
459 .inventory
460 .files_scanned
461 .map(|files| format!("; files scanned: `{files}`"))
462 .unwrap_or_default()
463}
464
465fn policy_baseline_debt_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
466 let baseline_debt = baseline_debt_count(summary, context);
467 (baseline_debt > summary.count(MatchStatus::BaselineDebt)).then_some(baseline_debt)
468}
469
470fn policy_missing_evidence_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
471 let policy_missing_evidence = policy_missing_evidence_count(summary, context);
472 (policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing))
473 .then_some(policy_missing_evidence)
474}