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    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}