Skip to main content

allow_report/
report_text.rs

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}