1use crate::audit_remediation::audit_remediation_items;
2use crate::evidence_repair::{
3 BROKEN_EVIDENCE_LINK_COMMAND, WEAK_EVIDENCE_REFERENCE_COMMAND, evidence_repair_queues,
4};
5use crate::non_rust::{render_non_rust_human, render_non_rust_markdown};
6use crate::text::markdown_inline_code;
7use crate::{
8 AUDIT_REVIEW_QUEUE_STATUSES, CLAIM_BOUNDARY_TEXT, ReportContext, ReviewSignals,
9 STATUS_COUNT_ORDER, Summary, baseline_debt_count, broken_evidence_link_count,
10 policy_missing_evidence_count, render_source_inventory_human, render_source_inventory_markdown,
11 weak_evidence_reference_count,
12};
13use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape};
14
15const HUMAN_NON_MATCHED_OUTCOME_LIMIT: usize = 80;
16const MARKDOWN_NON_MATCHED_OUTCOME_LIMIT: usize = 100;
17const AUDIT_REVIEW_QUEUE_LIMIT: usize = 20;
18
19pub fn render_human(
20 command: &str,
21 findings: &[Finding],
22 outcomes: &[MatchOutcome],
23 failed: bool,
24) -> String {
25 render_human_with_context(
26 command,
27 findings,
28 outcomes,
29 failed,
30 ReportContext::default(),
31 )
32}
33
34pub fn render_human_with_context(
35 command: &str,
36 findings: &[Finding],
37 outcomes: &[MatchOutcome],
38 failed: bool,
39 context: ReportContext<'_>,
40) -> String {
41 let summary = Summary::from_outcomes(outcomes);
42 let mut out = String::new();
43 out.push_str(&format!("cargo-allow {command}\n\n"));
44 out.push_str(&format!("Findings scanned: {}\n", findings.len()));
45 out.push_str(&format!(
46 "Inventory: source_tree/source_syntax via {}{}\n",
47 context.inventory.source,
48 inventory_files_suffix(context)
49 ));
50 if let Some(root) = context.inventory.root {
51 out.push_str(&format!("Source tree root: {root}\n"));
52 }
53 for status in STATUS_COUNT_ORDER {
54 let count = summary.count(status);
55 if count > 0 {
56 out.push_str(&format!(" {:24} {}\n", status.as_str(), count));
57 }
58 }
59 if let Some(baseline_debt) = policy_baseline_debt_note(&summary, context) {
60 out.push_str(&format!(
61 " {:24} {}\n",
62 "policy_baseline_debt", baseline_debt
63 ));
64 }
65 if let Some(policy_missing_evidence) = policy_missing_evidence_note(&summary, context) {
66 out.push_str(&format!(
67 " {:24} {}\n",
68 "policy_missing_evidence", policy_missing_evidence
69 ));
70 }
71 let broken_evidence_links = broken_evidence_link_count(context);
72 if broken_evidence_links > 0 {
73 out.push_str(&format!(
74 " {:24} {}\n",
75 "broken_evidence_links", broken_evidence_links
76 ));
77 }
78 let weak_evidence_references = weak_evidence_reference_count(context);
79 if weak_evidence_references > 0 {
80 out.push_str(&format!(
81 " {:24} {}\n",
82 "weak_evidence_references", weak_evidence_references
83 ));
84 }
85 if outcomes.is_empty() {
86 out.push_str(" no outcomes\n");
87 }
88 if command == "audit" {
89 render_source_inventory_human(findings, outcomes, &mut out);
90 render_audit_summary_human(&summary, outcomes, context, &mut out);
91 }
92 render_non_rust_human(findings, outcomes, &mut out);
93 if command != "audit" {
94 let signals = ReviewSignals::from_summary(&summary, context);
95 append_evidence_repair_queues_human(&summary, signals, &mut out);
96 }
97 out.push('\n');
98 let non_matched = outcomes
99 .iter()
100 .filter(|o| o.status != MatchStatus::Matched)
101 .collect::<Vec<_>>();
102 for outcome in non_matched.iter().take(HUMAN_NON_MATCHED_OUTCOME_LIMIT) {
103 out.push_str(&format!(
104 "{}: {}\n",
105 outcome.status.as_str(),
106 outcome.message
107 ));
108 }
109 append_human_omitted_outcome_note(&mut out, non_matched.len());
110 out.push('\n');
111 out.push_str(CLAIM_BOUNDARY_TEXT);
112 out.push('\n');
113 out.push_str(if failed {
114 "Result: failed\n"
115 } else {
116 "Result: passed/advisory\n"
117 });
118 out
119}
120
121fn render_audit_summary_human(
122 summary: &Summary,
123 outcomes: &[MatchOutcome],
124 context: ReportContext<'_>,
125 out: &mut String,
126) {
127 let signals = ReviewSignals::from_summary(summary, context);
128 let queue = outcomes
129 .iter()
130 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
131 .collect::<Vec<_>>();
132 out.push_str("\nAudit summary:\n");
133 out.push_str(&format!(" {:24} {}\n", "match_outcomes", summary.total));
134 out.push_str(&format!(
135 " {:24} {}\n",
136 "review_items", signals.review_items
137 ));
138 out.push_str(&format!(
139 " {:24} {}\n",
140 "new_unreceipted",
141 summary.count(MatchStatus::New)
142 ));
143 out.push_str(&format!(
144 " {:24} {}\n",
145 "expired",
146 summary.count(MatchStatus::Expired)
147 ));
148 out.push_str(&format!(
149 " {:24} {}\n",
150 "review_due",
151 summary.count(MatchStatus::ReviewDue)
152 ));
153 out.push_str(&format!(
154 " {:24} {}\n",
155 "stale",
156 summary.count(MatchStatus::Stale)
157 ));
158 out.push_str(&format!(
159 " {:24} {}\n",
160 "ambiguous",
161 summary.count(MatchStatus::Ambiguous)
162 ));
163 out.push_str(&format!(
164 " {:24} {}\n",
165 "invalid_selector",
166 summary.count(MatchStatus::InvalidSelector)
167 ));
168 out.push_str(&format!(
169 " {:24} {}\n",
170 "missing_required_field",
171 summary.count(MatchStatus::MissingRequiredField)
172 ));
173 out.push_str(&format!(
174 " {:24} {}\n",
175 "evidence_gaps",
176 summary.count(MatchStatus::EvidenceMissing)
177 ));
178 out.push_str(&format!(
179 " {:24} {}\n",
180 "policy_missing_evidence", signals.policy_missing_evidence
181 ));
182 out.push_str(&format!(
183 " {:24} {}\n",
184 "broken_evidence_links", signals.broken_evidence_links
185 ));
186 out.push_str(&format!(
187 " {:24} {}\n",
188 "weak_evidence_references", signals.weak_evidence_references
189 ));
190 out.push_str(&format!(
191 " {:24} {}\n",
192 "baseline_debt", signals.baseline_debt
193 ));
194 out.push_str(&audit_recommended_next_step(
195 summary,
196 signals,
197 queue.is_empty(),
198 ));
199 append_audit_remediation_roadmap_human(summary, signals, out);
200 append_evidence_repair_queues_human(summary, signals, out);
201 if !queue.is_empty() {
202 out.push_str("\nAudit review queue:\n");
203 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
204 out.push_str(&format!(
205 " {}: {}\n",
206 outcome.status.as_str(),
207 outcome.message
208 ));
209 }
210 append_human_omitted_review_queue_note(out, queue.len());
211 }
212}
213
214pub fn render_markdown(
215 command: &str,
216 findings: &[Finding],
217 outcomes: &[MatchOutcome],
218 failed: bool,
219) -> String {
220 render_markdown_with_context(
221 command,
222 findings,
223 outcomes,
224 failed,
225 ReportContext::default(),
226 )
227}
228
229pub fn render_markdown_with_context(
230 command: &str,
231 findings: &[Finding],
232 outcomes: &[MatchOutcome],
233 failed: bool,
234 context: ReportContext<'_>,
235) -> String {
236 let summary = Summary::from_outcomes(outcomes);
237 let mut out = String::new();
238 out.push_str(&format!("# cargo-allow {command}\n\n"));
239 out.push_str(&format!(
240 "**Result:** {}\n\n",
241 if failed { "failed" } else { "passed/advisory" }
242 ));
243 out.push_str(&format!("Findings scanned: `{}`\n\n", findings.len()));
244 out.push_str(&format!(
245 "Inventory: `source_tree` / `source_syntax` via `{}`{}\n\n",
246 json_escape(context.inventory.source),
247 inventory_files_markdown_suffix(context)
248 ));
249 if let Some(root) = context.inventory.root {
250 out.push_str(&format!(
251 "Source tree root: `{}`\n\n",
252 markdown_inline_code(root)
253 ));
254 }
255 out.push_str("| Status | Count |\n|---|---:|\n");
256 for status in STATUS_COUNT_ORDER {
257 let count = summary.count(status);
258 out.push_str(&format!("| `{}` | {} |\n", status.as_str(), count));
259 }
260 if let Some(baseline_debt) = policy_baseline_debt_note(&summary, context) {
261 out.push_str(&format!("| `policy_baseline_debt` | {} |\n", baseline_debt));
262 }
263 if let Some(policy_missing_evidence) = policy_missing_evidence_note(&summary, context) {
264 out.push_str(&format!(
265 "| `policy_missing_evidence` | {} |\n",
266 policy_missing_evidence
267 ));
268 }
269 let broken_evidence_links = broken_evidence_link_count(context);
270 if broken_evidence_links > 0 {
271 out.push_str(&format!(
272 "| `broken_evidence_links` | {} |\n",
273 broken_evidence_links
274 ));
275 }
276 let weak_evidence_references = weak_evidence_reference_count(context);
277 if weak_evidence_references > 0 {
278 out.push_str(&format!(
279 "| `weak_evidence_references` | {} |\n",
280 weak_evidence_references
281 ));
282 }
283 if command == "audit" {
284 render_source_inventory_markdown(findings, outcomes, &mut out);
285 render_audit_summary_markdown(&summary, outcomes, context, &mut out);
286 }
287 render_non_rust_markdown(findings, outcomes, &mut out);
288 if command != "audit" {
289 let signals = ReviewSignals::from_summary(&summary, context);
290 append_evidence_repair_queues_markdown(&summary, signals, &mut out);
291 }
292 let non_matched = outcomes
293 .iter()
294 .filter(|o| o.status != MatchStatus::Matched)
295 .collect::<Vec<_>>();
296 if !non_matched.is_empty() {
297 out.push_str("\n## Non-matched outcomes\n\n");
298 for outcome in non_matched.iter().take(MARKDOWN_NON_MATCHED_OUTCOME_LIMIT) {
299 out.push_str(&format!(
300 "- `{}`: {}\n",
301 outcome.status.as_str(),
302 outcome.message
303 ));
304 }
305 append_markdown_omitted_outcome_note(&mut out, non_matched.len());
306 }
307 out.push_str("\n> ");
308 out.push_str(CLAIM_BOUNDARY_TEXT);
309 out.push('\n');
310 out
311}
312
313fn append_human_omitted_outcome_note(out: &mut String, outcome_count: usize) {
314 if outcome_count > HUMAN_NON_MATCHED_OUTCOME_LIMIT {
315 let omitted = outcome_count - HUMAN_NON_MATCHED_OUTCOME_LIMIT;
316 let plural = if omitted == 1 { "" } else { "s" };
317 out.push_str(&format!(
318 "... {omitted} additional non-matched outcome{plural} omitted from this listing\n"
319 ));
320 }
321}
322
323fn append_markdown_omitted_outcome_note(out: &mut String, outcome_count: usize) {
324 if outcome_count > MARKDOWN_NON_MATCHED_OUTCOME_LIMIT {
325 let omitted = outcome_count - MARKDOWN_NON_MATCHED_OUTCOME_LIMIT;
326 let plural = if omitted == 1 { "" } else { "s" };
327 out.push_str(&format!(
328 "\n{omitted} additional non-matched outcome{plural} omitted from this listing.\n"
329 ));
330 }
331}
332
333fn render_audit_summary_markdown(
334 summary: &Summary,
335 outcomes: &[MatchOutcome],
336 context: ReportContext<'_>,
337 out: &mut String,
338) {
339 let signals = ReviewSignals::from_summary(summary, context);
340 let queue = outcomes
341 .iter()
342 .filter(|outcome| AUDIT_REVIEW_QUEUE_STATUSES.contains(&outcome.status))
343 .collect::<Vec<_>>();
344 out.push_str("\n## Audit Summary\n\n");
345 out.push_str("| Signal | Count |\n|---|---:|\n");
346 out.push_str(&format!("| Match outcomes | {} |\n", summary.total));
347 out.push_str(&format!("| Review items | {} |\n", signals.review_items));
348 out.push_str(&format!(
349 "| New unreceipted | {} |\n",
350 summary.count(MatchStatus::New)
351 ));
352 out.push_str(&format!(
353 "| Expired | {} |\n",
354 summary.count(MatchStatus::Expired)
355 ));
356 out.push_str(&format!(
357 "| Review due | {} |\n",
358 summary.count(MatchStatus::ReviewDue)
359 ));
360 out.push_str(&format!(
361 "| Stale | {} |\n",
362 summary.count(MatchStatus::Stale)
363 ));
364 out.push_str(&format!(
365 "| Ambiguous | {} |\n",
366 summary.count(MatchStatus::Ambiguous)
367 ));
368 out.push_str(&format!(
369 "| Invalid selectors | {} |\n",
370 summary.count(MatchStatus::InvalidSelector)
371 ));
372 out.push_str(&format!(
373 "| Missing required fields | {} |\n",
374 summary.count(MatchStatus::MissingRequiredField)
375 ));
376 out.push_str(&format!(
377 "| Evidence gaps | {} |\n",
378 summary.count(MatchStatus::EvidenceMissing)
379 ));
380 out.push_str(&format!(
381 "| Policy missing evidence | {} |\n",
382 signals.policy_missing_evidence
383 ));
384 out.push_str(&format!(
385 "| Broken evidence links | {} |\n",
386 signals.broken_evidence_links
387 ));
388 out.push_str(&format!(
389 "| Weak evidence/link references | {} |\n",
390 signals.weak_evidence_references
391 ));
392 out.push_str(&format!("| Baseline debt | {} |\n", signals.baseline_debt));
393 out.push_str(&audit_recommended_next_step(
394 summary,
395 signals,
396 queue.is_empty(),
397 ));
398 append_audit_remediation_roadmap_markdown(summary, signals, out);
399 append_evidence_repair_queues_markdown(summary, signals, out);
400
401 if !queue.is_empty() {
402 out.push_str("\n## Audit Review Queue\n\n");
403 for outcome in queue.iter().take(AUDIT_REVIEW_QUEUE_LIMIT) {
404 out.push_str(&format!(
405 "- `{}`: {}\n",
406 outcome.status.as_str(),
407 outcome.message
408 ));
409 }
410 append_markdown_omitted_review_queue_note(out, queue.len());
411 }
412}
413
414fn append_evidence_repair_queues_human(
415 summary: &Summary,
416 signals: ReviewSignals,
417 out: &mut String,
418) {
419 let commands = evidence_repair_commands(summary, signals);
420 if commands.is_empty() {
421 return;
422 }
423 out.push_str("\nEvidence repair queues:\n");
424 for command in commands {
425 out.push_str(&format!(" {command}\n"));
426 }
427}
428
429fn append_evidence_repair_queues_markdown(
430 summary: &Summary,
431 signals: ReviewSignals,
432 out: &mut String,
433) {
434 let commands = evidence_repair_commands(summary, signals);
435 if commands.is_empty() {
436 return;
437 }
438 out.push_str("\n### Evidence Repair Queues\n\n");
439 for command in commands {
440 out.push_str(&format!("- `{command}`\n"));
441 }
442}
443
444fn evidence_repair_commands(summary: &Summary, signals: ReviewSignals) -> Vec<&'static str> {
445 evidence_repair_queues(summary, signals)
446 .into_iter()
447 .map(|queue| queue.command)
448 .collect()
449}
450
451fn append_audit_remediation_roadmap_human(
452 summary: &Summary,
453 signals: ReviewSignals,
454 out: &mut String,
455) {
456 let items = audit_remediation_items(summary, signals);
457 if items.is_empty() {
458 return;
459 }
460 out.push_str("\nAudit remediation roadmap:\n");
461 for item in items {
462 out.push_str(&format!(" {}: {}\n", item.label, item.command));
463 }
464}
465
466fn append_audit_remediation_roadmap_markdown(
467 summary: &Summary,
468 signals: ReviewSignals,
469 out: &mut String,
470) {
471 let items = audit_remediation_items(summary, signals);
472 if items.is_empty() {
473 return;
474 }
475 out.push_str("\n## Audit Remediation Roadmap\n\n");
476 out.push_str("| Signal | Command |\n|---|---|\n");
477 for item in items {
478 out.push_str(&format!("| {} | `{}` |\n", item.label, item.command));
479 }
480}
481
482fn append_human_omitted_review_queue_note(out: &mut String, queue_count: usize) {
483 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
484 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
485 let plural = if omitted == 1 { "" } else { "s" };
486 out.push_str(&format!(
487 " ... {omitted} additional audit review item{plural} omitted from this queue\n"
488 ));
489 }
490}
491
492fn append_markdown_omitted_review_queue_note(out: &mut String, queue_count: usize) {
493 if queue_count > AUDIT_REVIEW_QUEUE_LIMIT {
494 let omitted = queue_count - AUDIT_REVIEW_QUEUE_LIMIT;
495 let plural = if omitted == 1 { "" } else { "s" };
496 out.push_str(&format!(
497 "\n{omitted} additional audit review item{plural} omitted from this queue.\n"
498 ));
499 }
500}
501
502fn audit_recommended_next_step(
503 summary: &Summary,
504 signals: ReviewSignals,
505 queue_empty: bool,
506) -> String {
507 if signals.review_items == 0 {
508 "\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n".to_string()
509 } else if queue_empty && signals.broken_evidence_links > 0 {
510 format!(
511 "\nRecommended next step: run `{BROKEN_EVIDENCE_LINK_COMMAND}` to repair broken local evidence/link references.\n"
512 )
513 } else if queue_empty
514 && signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing)
515 {
516 "\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".to_string()
517 } else if queue_empty && signals.weak_evidence_references > 0 {
518 format!(
519 "\nRecommended next step: run `{WEAK_EVIDENCE_REFERENCE_COMMAND}` to replace unstructured or unknown-prefix evidence/link references.\n"
520 )
521 } else if queue_empty && signals.baseline_debt > 0 {
522 "\nRecommended next step: run `cargo-allow worklist --format json` to review generated baseline debt.\n".to_string()
523 } else {
524 "\nRecommended next step: review the queue below before tightening policy.\n".to_string()
525 }
526}
527
528fn inventory_files_suffix(context: ReportContext<'_>) -> String {
529 context
530 .inventory
531 .files_scanned
532 .map(|files| format!("; files scanned: {files}"))
533 .unwrap_or_default()
534}
535
536fn inventory_files_markdown_suffix(context: ReportContext<'_>) -> String {
537 context
538 .inventory
539 .files_scanned
540 .map(|files| format!("; files scanned: `{files}`"))
541 .unwrap_or_default()
542}
543
544fn policy_baseline_debt_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
545 let baseline_debt = baseline_debt_count(summary, context);
546 (baseline_debt > summary.count(MatchStatus::BaselineDebt)).then_some(baseline_debt)
547}
548
549fn policy_missing_evidence_note(summary: &Summary, context: ReportContext<'_>) -> Option<usize> {
550 let policy_missing_evidence = policy_missing_evidence_count(summary, context);
551 (policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing))
552 .then_some(policy_missing_evidence)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn outcome(status: MatchStatus, message: &str) -> MatchOutcome {
560 MatchOutcome {
561 status,
562 allow_id: None,
563 finding_index: None,
564 message: message.to_string(),
565 score: 0,
566 }
567 }
568
569 fn audit_queue_outcomes(count: usize) -> Vec<MatchOutcome> {
570 (0..count)
571 .map(|index| outcome(MatchStatus::New, &format!("new source exception {index}")))
572 .collect()
573 }
574
575 fn review_outcomes() -> Vec<MatchOutcome> {
576 vec![
577 outcome(MatchStatus::New, "new source exception"),
578 outcome(MatchStatus::Expired, "expired policy entry"),
579 outcome(MatchStatus::ReviewDue, "policy entry review is due"),
580 outcome(MatchStatus::Stale, "stale policy entry"),
581 outcome(MatchStatus::Ambiguous, "ambiguous policy selector"),
582 outcome(MatchStatus::InvalidSelector, "invalid selector"),
583 outcome(MatchStatus::MissingRequiredField, "missing owner field"),
584 outcome(MatchStatus::EvidenceMissing, "missing evidence reference"),
585 ]
586 }
587
588 fn evidence_context() -> ReportContext<'static> {
589 let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(3));
590 context.policy_missing_evidence_entries = Some(4);
591 context.broken_evidence_links = Some(2);
592 context.weak_evidence_references = Some(1);
593 context
594 }
595
596 fn review_signals(
597 baseline_debt: usize,
598 policy_missing_evidence: usize,
599 broken_evidence_links: usize,
600 weak_evidence_references: usize,
601 review_items: usize,
602 ) -> ReviewSignals {
603 ReviewSignals {
604 baseline_debt,
605 policy_missing_evidence,
606 broken_evidence_links,
607 weak_evidence_references,
608 review_items,
609 }
610 }
611
612 #[test]
613 fn audit_summary_human_lists_review_counts_and_repair_routes() {
614 let outcomes = review_outcomes();
615 let summary = Summary::from_outcomes(&outcomes);
616 let mut out = String::new();
617
618 render_audit_summary_human(&summary, &outcomes, evidence_context(), &mut out);
619
620 assert!(out.contains("Audit summary:"));
621 assert!(out.contains("match_outcomes"));
622 assert!(out.contains("review_items"));
623 assert!(out.contains("new_unreceipted"));
624 assert!(out.contains("expired"));
625 assert!(out.contains("review_due"));
626 assert!(out.contains("stale"));
627 assert!(out.contains("ambiguous"));
628 assert!(out.contains("invalid_selector"));
629 assert!(out.contains("missing_required_field"));
630 assert!(out.contains("evidence_gaps"));
631 assert!(out.contains("policy_missing_evidence"));
632 assert!(out.contains("broken_evidence_links"));
633 assert!(out.contains("weak_evidence_references"));
634 assert!(out.contains("baseline_debt"));
635 assert!(out.contains("Recommended next step: review the queue below"));
636 assert!(out.contains("Audit remediation roadmap:"));
637 assert!(out.contains("cargo-allow worklist --status new --format json"));
638 assert!(out.contains("cargo-allow prune --stale --dry-run --format json"));
639 assert!(out.contains("cargo-allow worklist --broken-evidence --format json"));
640 assert!(out.contains("Evidence repair queues:"));
641 assert!(out.contains("cargo-allow worklist --missing-evidence --format json"));
642 assert!(out.contains("cargo-allow worklist --weak-evidence --format json"));
643 assert!(out.contains("Audit review queue:"));
644 assert!(out.contains("new: new source exception"));
645 }
646
647 #[test]
648 fn audit_summary_markdown_lists_review_counts_and_repair_routes() {
649 let outcomes = review_outcomes();
650 let summary = Summary::from_outcomes(&outcomes);
651 let mut out = String::new();
652
653 render_audit_summary_markdown(&summary, &outcomes, evidence_context(), &mut out);
654
655 assert!(out.contains("## Audit Summary"));
656 assert!(out.contains("| Match outcomes | 8 |"));
657 assert!(out.contains("| Review items | 17 |"));
658 assert!(out.contains("| New unreceipted | 1 |"));
659 assert!(out.contains("| Expired | 1 |"));
660 assert!(out.contains("| Review due | 1 |"));
661 assert!(out.contains("| Stale | 1 |"));
662 assert!(out.contains("| Ambiguous | 1 |"));
663 assert!(out.contains("| Invalid selectors | 1 |"));
664 assert!(out.contains("| Missing required fields | 1 |"));
665 assert!(out.contains("| Evidence gaps | 1 |"));
666 assert!(out.contains("| Policy missing evidence | 4 |"));
667 assert!(out.contains("| Broken evidence links | 2 |"));
668 assert!(out.contains("| Weak evidence/link references | 1 |"));
669 assert!(out.contains("| Baseline debt | 3 |"));
670 assert!(out.contains("## Audit Remediation Roadmap"));
671 assert!(
672 out.contains("| new unreceipted | `cargo-allow worklist --status new --format json` |")
673 );
674 assert!(out.contains(
675 "| broken evidence links | `cargo-allow worklist --broken-evidence --format json` |"
676 ));
677 assert!(out.contains("### Evidence Repair Queues"));
678 assert!(out.contains("- `cargo-allow worklist --missing-evidence --format json`"));
679 assert!(out.contains("## Audit Review Queue"));
680 assert!(out.contains("- `new`: new source exception"));
681 }
682
683 #[test]
684 fn audit_recommended_next_step_routes_empty_queue_evidence_signals() {
685 let summary = Summary::from_outcomes(&[]);
686
687 assert_eq!(
688 audit_recommended_next_step(&summary, review_signals(0, 0, 0, 0, 0), true),
689 "\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n"
690 );
691 assert_eq!(
692 audit_recommended_next_step(&summary, review_signals(0, 0, 1, 0, 1), true),
693 "\nRecommended next step: run `cargo-allow worklist --broken-evidence --format json` to repair broken local evidence/link references.\n"
694 );
695 assert_eq!(
696 audit_recommended_next_step(&summary, review_signals(0, 1, 0, 0, 1), true),
697 "\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"
698 );
699 assert_eq!(
700 audit_recommended_next_step(&summary, review_signals(0, 0, 0, 1, 1), true),
701 "\nRecommended next step: run `cargo-allow worklist --weak-evidence --format json` to replace unstructured or unknown-prefix evidence/link references.\n"
702 );
703 assert_eq!(
704 audit_recommended_next_step(&summary, review_signals(1, 0, 0, 0, 1), true),
705 "\nRecommended next step: run `cargo-allow worklist --format json` to review generated baseline debt.\n"
706 );
707 assert_eq!(
708 audit_recommended_next_step(&summary, review_signals(0, 0, 0, 0, 1), false),
709 "\nRecommended next step: review the queue below before tightening policy.\n"
710 );
711 }
712
713 #[test]
714 fn omitted_review_queue_notes_report_extra_items() {
715 let mut human = String::new();
716 append_human_omitted_review_queue_note(&mut human, AUDIT_REVIEW_QUEUE_LIMIT + 2);
717 assert!(human.contains("2 additional audit review items omitted from this queue"));
718
719 let mut markdown = String::new();
720 append_markdown_omitted_review_queue_note(&mut markdown, AUDIT_REVIEW_QUEUE_LIMIT + 1);
721 assert!(markdown.contains("1 additional audit review item omitted from this queue."));
722 }
723
724 #[test]
725 fn omitted_non_matched_notes_report_extra_items() {
726 let mut human = String::new();
727 append_human_omitted_outcome_note(&mut human, HUMAN_NON_MATCHED_OUTCOME_LIMIT + 1);
728 assert!(human.contains("1 additional non-matched outcome omitted from this listing"));
729
730 let mut markdown = String::new();
731 append_markdown_omitted_outcome_note(&mut markdown, MARKDOWN_NON_MATCHED_OUTCOME_LIMIT + 2);
732 assert!(markdown.contains("2 additional non-matched outcomes omitted from this listing."));
733 }
734
735 #[test]
736 fn audit_summary_omits_review_queue_only_when_queue_is_empty() {
737 let outcomes = audit_queue_outcomes(AUDIT_REVIEW_QUEUE_LIMIT + 1);
738 let summary = Summary::from_outcomes(&outcomes);
739 let mut human = String::new();
740 let mut markdown = String::new();
741
742 render_audit_summary_human(&summary, &outcomes, ReportContext::default(), &mut human);
743 render_audit_summary_markdown(&summary, &outcomes, ReportContext::default(), &mut markdown);
744
745 assert!(human.contains("Audit review queue:"));
746 assert!(human.contains("1 additional audit review item omitted from this queue"));
747 assert!(markdown.contains("## Audit Review Queue"));
748 assert!(markdown.contains("1 additional audit review item omitted from this queue."));
749
750 let empty_summary = Summary::from_outcomes(&[]);
751 let mut empty = String::new();
752 render_audit_summary_human(&empty_summary, &[], ReportContext::default(), &mut empty);
753 assert!(!empty.contains("Audit review queue:"));
754 }
755
756 #[test]
757 fn policy_context_notes_only_report_policy_excess() {
758 let outcomes = vec![
759 outcome(
760 MatchStatus::EvidenceMissing,
761 "matched entry has no evidence",
762 ),
763 outcome(MatchStatus::BaselineDebt, "generated baseline debt"),
764 ];
765 let summary = Summary::from_outcomes(&outcomes);
766 let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(3));
767 context.policy_missing_evidence_entries = Some(4);
768
769 assert_eq!(policy_baseline_debt_note(&summary, context), Some(3));
770 assert_eq!(policy_missing_evidence_note(&summary, context), Some(4));
771
772 let mut matching_context = ReportContext::source_syntax("git_tracked", None, None, Some(1));
773 matching_context.policy_missing_evidence_entries = Some(1);
774
775 assert_eq!(policy_baseline_debt_note(&summary, matching_context), None);
776 assert_eq!(
777 policy_missing_evidence_note(&summary, matching_context),
778 None
779 );
780 }
781}