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 out.push('\n');
90 let non_matched = outcomes
91 .iter()
92 .filter(|o| o.status != MatchStatus::Matched)
93 .collect::<Vec<_>>();
94 for outcome in non_matched.iter().take(HUMAN_NON_MATCHED_OUTCOME_LIMIT) {
95 out.push_str(&format!(
96 "{}: {}\n",
97 outcome.status.as_str(),
98 outcome.message
99 ));
100 }
101 append_human_omitted_outcome_note(&mut out, non_matched.len());
102 out.push('\n');
103 out.push_str(CLAIM_BOUNDARY_TEXT);
104 out.push('\n');
105 out.push_str(if failed {
106 "Result: failed\n"
107 } else {
108 "Result: passed/advisory\n"
109 });
110 out
111}
112
113fn render_audit_summary_human(
114 summary: &Summary,
115 outcomes: &[MatchOutcome],
116 context: ReportContext<'_>,
117 out: &mut String,
118) {
119 let signals = ReviewSignals::from_summary(summary, context);
120 let queue = outcomes
121 .iter()
122 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
123 .collect::<Vec<_>>();
124 out.push_str("\nAudit summary:\n");
125 out.push_str(&format!(" {:24} {}\n", "match_outcomes", summary.total));
126 out.push_str(&format!(
127 " {:24} {}\n",
128 "review_items", signals.review_items
129 ));
130 out.push_str(&format!(
131 " {:24} {}\n",
132 "new_unreceipted",
133 summary.count(MatchStatus::New)
134 ));
135 out.push_str(&format!(
136 " {:24} {}\n",
137 "expired",
138 summary.count(MatchStatus::Expired)
139 ));
140 out.push_str(&format!(
141 " {:24} {}\n",
142 "evidence_gaps",
143 summary.count(MatchStatus::EvidenceMissing)
144 ));
145 out.push_str(&format!(
146 " {:24} {}\n",
147 "policy_missing_evidence", signals.policy_missing_evidence
148 ));
149 out.push_str(&format!(
150 " {:24} {}\n",
151 "broken_evidence_links", signals.broken_evidence_links
152 ));
153 out.push_str(&format!(
154 " {:24} {}\n",
155 "weak_evidence_references", signals.weak_evidence_references
156 ));
157 out.push_str(&format!(
158 " {:24} {}\n",
159 "baseline_debt", signals.baseline_debt
160 ));
161 out.push_str(audit_recommended_next_step(
162 summary,
163 signals,
164 queue.is_empty(),
165 ));
166 if !queue.is_empty() {
167 out.push_str("\nAudit review queue:\n");
168 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
169 out.push_str(&format!(
170 " {}: {}\n",
171 outcome.status.as_str(),
172 outcome.message
173 ));
174 }
175 append_human_omitted_review_queue_note(out, queue.len());
176 }
177}
178
179pub fn render_markdown(
180 command: &str,
181 findings: &[Finding],
182 outcomes: &[MatchOutcome],
183 failed: bool,
184) -> String {
185 render_markdown_with_context(
186 command,
187 findings,
188 outcomes,
189 failed,
190 ReportContext::default(),
191 )
192}
193
194pub fn render_markdown_with_context(
195 command: &str,
196 findings: &[Finding],
197 outcomes: &[MatchOutcome],
198 failed: bool,
199 context: ReportContext<'_>,
200) -> String {
201 let summary = Summary::from_outcomes(outcomes);
202 let mut out = String::new();
203 out.push_str(&format!("# cargo-allow {command}\n\n"));
204 out.push_str(&format!(
205 "**Result:** {}\n\n",
206 if failed { "failed" } else { "passed/advisory" }
207 ));
208 out.push_str(&format!("Findings scanned: `{}`\n\n", findings.len()));
209 out.push_str(&format!(
210 "Inventory: `source_tree` / `source_syntax` via `{}`{}\n\n",
211 json_escape(context.inventory.source),
212 inventory_files_markdown_suffix(context)
213 ));
214 if let Some(root) = context.inventory.root {
215 out.push_str(&format!(
216 "Source tree root: `{}`\n\n",
217 markdown_inline_code(root)
218 ));
219 }
220 out.push_str("| Status | Count |\n|---|---:|\n");
221 for status in STATUS_COUNT_ORDER {
222 let count = summary.count(status);
223 out.push_str(&format!("| `{}` | {} |\n", status.as_str(), count));
224 }
225 if let Some(baseline_debt) = policy_baseline_debt_note(&summary, context) {
226 out.push_str(&format!("| `policy_baseline_debt` | {} |\n", baseline_debt));
227 }
228 if let Some(policy_missing_evidence) = policy_missing_evidence_note(&summary, context) {
229 out.push_str(&format!(
230 "| `policy_missing_evidence` | {} |\n",
231 policy_missing_evidence
232 ));
233 }
234 let broken_evidence_links = broken_evidence_link_count(context);
235 if broken_evidence_links > 0 {
236 out.push_str(&format!(
237 "| `broken_evidence_links` | {} |\n",
238 broken_evidence_links
239 ));
240 }
241 let weak_evidence_references = weak_evidence_reference_count(context);
242 if weak_evidence_references > 0 {
243 out.push_str(&format!(
244 "| `weak_evidence_references` | {} |\n",
245 weak_evidence_references
246 ));
247 }
248 if command == "audit" {
249 render_source_inventory_markdown(findings, outcomes, &mut out);
250 render_audit_summary_markdown(&summary, outcomes, context, &mut out);
251 }
252 render_non_rust_markdown(findings, outcomes, &mut out);
253 let non_matched = outcomes
254 .iter()
255 .filter(|o| o.status != MatchStatus::Matched)
256 .collect::<Vec<_>>();
257 if !non_matched.is_empty() {
258 out.push_str("\n## Non-matched outcomes\n\n");
259 for outcome in non_matched.iter().take(MARKDOWN_NON_MATCHED_OUTCOME_LIMIT) {
260 out.push_str(&format!(
261 "- `{}`: {}\n",
262 outcome.status.as_str(),
263 outcome.message
264 ));
265 }
266 append_markdown_omitted_outcome_note(&mut out, non_matched.len());
267 }
268 out.push_str("\n> ");
269 out.push_str(CLAIM_BOUNDARY_TEXT);
270 out.push('\n');
271 out
272}
273
274fn append_human_omitted_outcome_note(out: &mut String, outcome_count: usize) {
275 if outcome_count > HUMAN_NON_MATCHED_OUTCOME_LIMIT {
276 let omitted = outcome_count - HUMAN_NON_MATCHED_OUTCOME_LIMIT;
277 let plural = if omitted == 1 { "" } else { "s" };
278 out.push_str(&format!(
279 "... {omitted} additional non-matched outcome{plural} omitted from this listing\n"
280 ));
281 }
282}
283
284fn append_markdown_omitted_outcome_note(out: &mut String, outcome_count: usize) {
285 if outcome_count > MARKDOWN_NON_MATCHED_OUTCOME_LIMIT {
286 let omitted = outcome_count - MARKDOWN_NON_MATCHED_OUTCOME_LIMIT;
287 let plural = if omitted == 1 { "" } else { "s" };
288 out.push_str(&format!(
289 "\n{omitted} additional non-matched outcome{plural} omitted from this listing.\n"
290 ));
291 }
292}
293
294fn render_audit_summary_markdown(
295 summary: &Summary,
296 outcomes: &[MatchOutcome],
297 context: ReportContext<'_>,
298 out: &mut String,
299) {
300 let signals = ReviewSignals::from_summary(summary, context);
301 let queue = outcomes
302 .iter()
303 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
304 .collect::<Vec<_>>();
305 out.push_str("\n## Audit Summary\n\n");
306 out.push_str("| Signal | Count |\n|---|---:|\n");
307 out.push_str(&format!("| Match outcomes | {} |\n", summary.total));
308 out.push_str(&format!("| Review items | {} |\n", signals.review_items));
309 out.push_str(&format!(
310 "| New unreceipted | {} |\n",
311 summary.count(MatchStatus::New)
312 ));
313 out.push_str(&format!(
314 "| Expired | {} |\n",
315 summary.count(MatchStatus::Expired)
316 ));
317 out.push_str(&format!(
318 "| Evidence gaps | {} |\n",
319 summary.count(MatchStatus::EvidenceMissing)
320 ));
321 out.push_str(&format!(
322 "| Policy missing evidence | {} |\n",
323 signals.policy_missing_evidence
324 ));
325 out.push_str(&format!(
326 "| Broken evidence links | {} |\n",
327 signals.broken_evidence_links
328 ));
329 out.push_str(&format!(
330 "| Weak evidence/link references | {} |\n",
331 signals.weak_evidence_references
332 ));
333 out.push_str(&format!("| Baseline debt | {} |\n", signals.baseline_debt));
334 out.push_str(audit_recommended_next_step(
335 summary,
336 signals,
337 queue.is_empty(),
338 ));
339
340 if !queue.is_empty() {
341 out.push_str("\n## Audit Review Queue\n\n");
342 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
343 out.push_str(&format!(
344 "- `{}`: {}\n",
345 outcome.status.as_str(),
346 outcome.message
347 ));
348 }
349 append_markdown_omitted_review_queue_note(out, queue.len());
350 }
351}
352
353fn append_human_omitted_review_queue_note(out: &mut String, queue_count: usize) {
354 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
355 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
356 let plural = if omitted == 1 { "" } else { "s" };
357 out.push_str(&format!(
358 " ... {omitted} additional audit review item{plural} omitted from this queue\n"
359 ));
360 }
361}
362
363fn append_markdown_omitted_review_queue_note(out: &mut String, queue_count: usize) {
364 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
365 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
366 let plural = if omitted == 1 { "" } else { "s" };
367 out.push_str(&format!(
368 "\n{omitted} additional audit review item{plural} omitted from this queue.\n"
369 ));
370 }
371}
372
373fn audit_recommended_next_step(
374 summary: &Summary,
375 signals: ReviewSignals,
376 queue_empty: bool,
377) -> &'static str {
378 if signals.review_items == 0 {
379 "\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n"
380 } else if queue_empty && signals.broken_evidence_links > 0 {
381 "\nRecommended next step: run `cargo-allow worklist --item-kind broken_evidence_link --format json` to repair broken local evidence/link references.\n"
382 } else if queue_empty
383 && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
384 {
385 "\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"
386 } else if queue_empty && signals.weak_evidence_references > 0 {
387 "\nRecommended next step: run `cargo-allow worklist --item-kind weak_evidence_reference --format json` to replace unstructured or unknown-prefix evidence/link references.\n"
388 } else if queue_empty && signals.baseline_debt > 0 {
389 "\nRecommended next step: run `cargo-allow worklist --format json` to review generated baseline debt.\n"
390 } else {
391 "\nRecommended next step: review the queue below before tightening policy.\n"
392 }
393}
394
395fn inventory_files_suffix(context: ReportContext<'_>) -> String {
396 context
397 .inventory
398 .files_scanned
399 .map(|files| format!("; files scanned: {files}"))
400 .unwrap_or_default()
401}
402
403fn inventory_files_markdown_suffix(context: ReportContext<'_>) -> String {
404 context
405 .inventory
406 .files_scanned
407 .map(|files| format!("; files scanned: `{files}`"))
408 .unwrap_or_default()
409}
410
411fn policy_baseline_debt_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
412 let baseline_debt = baseline_debt_count(summary, context);
413 (baseline_debt > summary.count(MatchStatus::BaselineDebt)).then_some(baseline_debt)
414}
415
416fn policy_missing_evidence_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
417 let policy_missing_evidence = policy_missing_evidence_count(summary, context);
418 (policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing))
419 .then_some(policy_missing_evidence)
420}