Skip to main content

allow_report/
report_text.rs

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}