Skip to main content

allow_report/
lib.rs

1use allow_core::{Finding, FindingKind, MatchOutcome, MatchStatus, json_escape, normalize_path};
2use std::collections::BTreeMap;
3
4pub const REPORT_SCHEMA_VERSION: u32 = 1;
5pub const REPORT_SCHEMA_ID: &str = "cargo-allow.report.v1";
6pub const RECEIPT_SCHEMA_VERSION: u32 = 1;
7pub const RECEIPT_SCHEMA_ID: &str = "cargo-allow.receipt.v1";
8
9const CLAIM_BOUNDARY: &[&str] = &[
10    "source_tree_inventory",
11    "source_syntax_only",
12    "cargo_metadata_not_invoked",
13    "cargo_commands_not_invoked",
14    "rustc_not_invoked",
15    "clippy_not_invoked",
16    "build_scripts_not_executed",
17    "proc_macros_not_executed",
18    "macro_expansion_not_analyzed",
19    "macro_token_tree_contents_not_analyzed",
20    "type_information_not_analyzed",
21    "mir_not_analyzed",
22    "build_output_not_analyzed",
23    "control_flow_not_analyzed",
24    "data_flow_not_analyzed",
25    "repository_code_not_executed",
26];
27
28const SCANNER_LIMITATIONS: &[&str] = &[
29    "cargo_metadata_not_invoked",
30    "cargo_commands_not_invoked",
31    "rustc_not_invoked",
32    "clippy_not_invoked",
33    "build_scripts_not_executed",
34    "proc_macros_not_executed",
35    "macro_expansion_not_analyzed",
36    "macro_token_tree_contents_not_analyzed",
37    "type_information_not_analyzed",
38    "mir_not_analyzed",
39    "build_output_not_analyzed",
40    "control_flow_not_analyzed",
41    "data_flow_not_analyzed",
42    "repository_code_not_executed",
43];
44
45pub const CLAIM_BOUNDARY_TEXT: &str = "Claim boundary: scanned source-tree/source syntax only; cargo-allow did not invoke Cargo metadata, Cargo commands, rustc, Clippy, build scripts, proc macros, or repository code. Macro expansion, macro token-tree contents, type information, MIR, build output, control flow, and data flow were not analyzed.";
46
47#[derive(Debug, Clone, Copy)]
48pub struct ReportContext<'a> {
49    pub inventory_source: &'a str,
50}
51
52impl Default for ReportContext<'static> {
53    fn default() -> Self {
54        Self {
55            inventory_source: "unknown",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct Summary {
62    pub total: usize,
63    pub by_status: BTreeMap<MatchStatus, usize>,
64}
65
66impl Summary {
67    pub fn from_outcomes(outcomes: &[MatchOutcome]) -> Self {
68        let mut summary = Self {
69            total: outcomes.len(),
70            by_status: BTreeMap::new(),
71        };
72        for outcome in outcomes {
73            *summary.by_status.entry(outcome.status).or_insert(0) += 1;
74        }
75        summary
76    }
77    pub fn count(&self, status: MatchStatus) -> usize {
78        *self.by_status.get(&status).unwrap_or(&0)
79    }
80}
81
82pub fn render_human(
83    command: &str,
84    findings: &[Finding],
85    outcomes: &[MatchOutcome],
86    failed: bool,
87) -> String {
88    render_human_with_context(
89        command,
90        findings,
91        outcomes,
92        failed,
93        ReportContext::default(),
94    )
95}
96
97pub fn render_human_with_context(
98    command: &str,
99    findings: &[Finding],
100    outcomes: &[MatchOutcome],
101    failed: bool,
102    context: ReportContext<'_>,
103) -> String {
104    let summary = Summary::from_outcomes(outcomes);
105    let mut out = String::new();
106    out.push_str(&format!("cargo-allow {command}\n\n"));
107    out.push_str(&format!("Findings scanned: {}\n", findings.len()));
108    out.push_str(&format!(
109        "Inventory: source_tree/source_syntax via {}\n",
110        context.inventory_source
111    ));
112    for status in [
113        MatchStatus::Matched,
114        MatchStatus::New,
115        MatchStatus::Expired,
116        MatchStatus::ReviewDue,
117        MatchStatus::Stale,
118        MatchStatus::Ambiguous,
119        MatchStatus::InvalidSelector,
120        MatchStatus::EvidenceMissing,
121        MatchStatus::MissingRequiredField,
122        MatchStatus::BaselineDebt,
123    ] {
124        let count = summary.count(status);
125        if count > 0 {
126            out.push_str(&format!("  {:24} {}\n", status.as_str(), count));
127        }
128    }
129    if outcomes.is_empty() {
130        out.push_str("  no outcomes\n");
131    }
132    render_non_rust_human(findings, outcomes, &mut out);
133    out.push('\n');
134    for outcome in outcomes
135        .iter()
136        .filter(|o| o.status != MatchStatus::Matched)
137        .take(80)
138    {
139        out.push_str(&format!(
140            "{}: {}\n",
141            outcome.status.as_str(),
142            outcome.message
143        ));
144    }
145    out.push('\n');
146    out.push_str(CLAIM_BOUNDARY_TEXT);
147    out.push('\n');
148    out.push_str(if failed {
149        "Result: failed\n"
150    } else {
151        "Result: passed/advisory\n"
152    });
153    out
154}
155
156pub fn render_markdown(
157    command: &str,
158    findings: &[Finding],
159    outcomes: &[MatchOutcome],
160    failed: bool,
161) -> String {
162    render_markdown_with_context(
163        command,
164        findings,
165        outcomes,
166        failed,
167        ReportContext::default(),
168    )
169}
170
171pub fn render_markdown_with_context(
172    command: &str,
173    findings: &[Finding],
174    outcomes: &[MatchOutcome],
175    failed: bool,
176    context: ReportContext<'_>,
177) -> String {
178    let summary = Summary::from_outcomes(outcomes);
179    let mut out = String::new();
180    out.push_str(&format!("# cargo-allow {command}\n\n"));
181    out.push_str(&format!(
182        "**Result:** {}\n\n",
183        if failed { "failed" } else { "passed/advisory" }
184    ));
185    out.push_str(&format!("Findings scanned: `{}`\n\n", findings.len()));
186    out.push_str(&format!(
187        "Inventory: `source_tree` / `source_syntax` via `{}`\n\n",
188        json_escape(context.inventory_source)
189    ));
190    out.push_str("| Status | Count |\n|---|---:|\n");
191    for status in [
192        MatchStatus::Matched,
193        MatchStatus::New,
194        MatchStatus::Expired,
195        MatchStatus::ReviewDue,
196        MatchStatus::Stale,
197        MatchStatus::Ambiguous,
198        MatchStatus::InvalidSelector,
199        MatchStatus::EvidenceMissing,
200        MatchStatus::MissingRequiredField,
201        MatchStatus::BaselineDebt,
202    ] {
203        let count = summary.count(status);
204        out.push_str(&format!("| `{}` | {} |\n", status.as_str(), count));
205    }
206    if command == "audit" {
207        render_audit_summary_markdown(&summary, outcomes, &mut out);
208    }
209    render_non_rust_markdown(findings, outcomes, &mut out);
210    let non_matched = outcomes
211        .iter()
212        .filter(|o| o.status != MatchStatus::Matched)
213        .take(100)
214        .collect::<Vec<_>>();
215    if !non_matched.is_empty() {
216        out.push_str("\n## Non-matched outcomes\n\n");
217        for outcome in non_matched {
218            out.push_str(&format!(
219                "- `{}`: {}\n",
220                outcome.status.as_str(),
221                outcome.message
222            ));
223        }
224    }
225    out.push_str("\n> ");
226    out.push_str(CLAIM_BOUNDARY_TEXT);
227    out.push('\n');
228    out
229}
230
231pub fn render_html(
232    command: &str,
233    findings: &[Finding],
234    outcomes: &[MatchOutcome],
235    failed: bool,
236) -> String {
237    render_html_with_context(
238        command,
239        findings,
240        outcomes,
241        failed,
242        ReportContext::default(),
243    )
244}
245
246pub fn render_html_with_context(
247    command: &str,
248    findings: &[Finding],
249    outcomes: &[MatchOutcome],
250    failed: bool,
251    context: ReportContext<'_>,
252) -> String {
253    let summary = Summary::from_outcomes(outcomes);
254    let mut out = String::new();
255    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
256    out.push_str("  <meta charset=\"utf-8\">\n");
257    out.push_str(&format!(
258        "  <title>cargo-allow {}</title>\n",
259        html_escape(command)
260    ));
261    out.push_str("  <style>body{font-family:system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;line-height:1.45}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{border:1px solid #d0d7de;padding:.4rem .55rem;text-align:left}th{background:#f6f8fa}td.count{text-align:right;font-variant-numeric:tabular-nums}.status{font-weight:700}.failed{color:#b42318}.passed{color:#1a7f37}code{background:#f6f8fa;padding:.1rem .25rem;border-radius:4px}.claim{border-left:4px solid #57606a;padding-left:1rem;color:#57606a}</style>\n");
262    out.push_str("</head>\n<body>\n");
263    out.push_str(&format!("<h1>cargo-allow {}</h1>\n", html_escape(command)));
264    out.push_str(&format!(
265        "<p class=\"status {}\">Result: {}</p>\n",
266        if failed { "failed" } else { "passed" },
267        if failed { "failed" } else { "passed/advisory" }
268    ));
269    out.push_str(&format!(
270        "<p>Findings scanned: <code>{}</code></p>\n",
271        findings.len()
272    ));
273    out.push_str(&format!(
274        "<p>Inventory: <code>source_tree</code> / <code>source_syntax</code> via <code>{}</code></p>\n",
275        html_escape(context.inventory_source)
276    ));
277    out.push_str("<h2>Status Counts</h2>\n");
278    render_status_count_table_html(&summary, &mut out);
279    if command == "audit" {
280        render_audit_summary_html(&summary, outcomes, &mut out);
281    }
282    render_non_rust_html(findings, outcomes, &mut out);
283    render_non_matched_html(outcomes, &mut out);
284    out.push_str("<h2>Claim Boundary</h2>\n");
285    out.push_str(&format!(
286        "<p class=\"claim\">{}</p>\n",
287        html_escape(CLAIM_BOUNDARY_TEXT)
288    ));
289    out.push_str("</body>\n</html>\n");
290    out
291}
292
293fn render_status_count_table_html(summary: &Summary, out: &mut String) {
294    out.push_str("<table><thead><tr><th>Status</th><th>Count</th></tr></thead><tbody>\n");
295    for status in [
296        MatchStatus::Matched,
297        MatchStatus::New,
298        MatchStatus::Expired,
299        MatchStatus::ReviewDue,
300        MatchStatus::Stale,
301        MatchStatus::Ambiguous,
302        MatchStatus::InvalidSelector,
303        MatchStatus::EvidenceMissing,
304        MatchStatus::MissingRequiredField,
305        MatchStatus::BaselineDebt,
306    ] {
307        out.push_str(&format!(
308            "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
309            status.as_str(),
310            summary.count(status)
311        ));
312    }
313    out.push_str("</tbody></table>\n");
314}
315
316fn render_audit_summary_html(summary: &Summary, outcomes: &[MatchOutcome], out: &mut String) {
317    let review_items = review_item_count(summary);
318    out.push_str("<h2>Audit Summary</h2>\n");
319    out.push_str("<table><thead><tr><th>Signal</th><th>Count</th></tr></thead><tbody>\n");
320    for (name, value) in [
321        ("Match outcomes", summary.total),
322        ("Review items", review_items),
323        ("New unreceipted", summary.count(MatchStatus::New)),
324        ("Expired", summary.count(MatchStatus::Expired)),
325        ("Evidence gaps", summary.count(MatchStatus::EvidenceMissing)),
326        ("Baseline debt", summary.count(MatchStatus::BaselineDebt)),
327    ] {
328        out.push_str(&format!(
329            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
330            html_escape(name),
331            value
332        ));
333    }
334    out.push_str("</tbody></table>\n");
335    if review_items == 0 {
336        out.push_str("<p>Recommended next step: keep <code>cargo-allow check --mode no-new</code> in CI.</p>\n");
337    } else {
338        out.push_str(
339            "<p>Recommended next step: review the queue below before tightening policy.</p>\n",
340        );
341    }
342    let queue = outcomes
343        .iter()
344        .filter(|outcome| outcome.status != MatchStatus::Matched)
345        .take(20)
346        .collect::<Vec<_>>();
347    if !queue.is_empty() {
348        out.push_str("<h2>Audit Review Queue</h2>\n<ul>\n");
349        for outcome in queue {
350            out.push_str(&format!(
351                "<li><code>{}</code>: {}</li>\n",
352                outcome.status.as_str(),
353                html_escape(&outcome.message)
354            ));
355        }
356        out.push_str("</ul>\n");
357    }
358}
359
360fn render_audit_summary_markdown(summary: &Summary, outcomes: &[MatchOutcome], out: &mut String) {
361    let review_statuses = [
362        MatchStatus::New,
363        MatchStatus::Expired,
364        MatchStatus::Ambiguous,
365        MatchStatus::EvidenceMissing,
366        MatchStatus::MissingRequiredField,
367        MatchStatus::BaselineDebt,
368        MatchStatus::Stale,
369        MatchStatus::ReviewDue,
370    ];
371    let review_items = review_item_count(summary);
372    out.push_str("\n## Audit Summary\n\n");
373    out.push_str("| Signal | Count |\n|---|---:|\n");
374    out.push_str(&format!("| Match outcomes | {} |\n", summary.total));
375    out.push_str(&format!("| Review items | {} |\n", review_items));
376    out.push_str(&format!(
377        "| New unreceipted | {} |\n",
378        summary.count(MatchStatus::New)
379    ));
380    out.push_str(&format!(
381        "| Expired | {} |\n",
382        summary.count(MatchStatus::Expired)
383    ));
384    out.push_str(&format!(
385        "| Evidence gaps | {} |\n",
386        summary.count(MatchStatus::EvidenceMissing)
387    ));
388    out.push_str(&format!(
389        "| Baseline debt | {} |\n",
390        summary.count(MatchStatus::BaselineDebt)
391    ));
392    if review_items == 0 {
393        out.push_str("\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n");
394    } else {
395        out.push_str("\nRecommended next step: review the queue below before tightening policy.\n");
396    }
397
398    let queue = outcomes
399        .iter()
400        .filter(|outcome| review_statuses.contains(&outcome.status))
401        .take(20)
402        .collect::<Vec<_>>();
403    if !queue.is_empty() {
404        out.push_str("\n## Audit Review Queue\n\n");
405        for outcome in queue {
406            out.push_str(&format!(
407                "- `{}`: {}\n",
408                outcome.status.as_str(),
409                outcome.message
410            ));
411        }
412    }
413}
414
415pub fn render_json(
416    command: &str,
417    findings: &[Finding],
418    outcomes: &[MatchOutcome],
419    failed: bool,
420) -> String {
421    render_json_with_context(
422        command,
423        findings,
424        outcomes,
425        failed,
426        ReportContext::default(),
427    )
428}
429
430pub fn render_json_with_context(
431    command: &str,
432    findings: &[Finding],
433    outcomes: &[MatchOutcome],
434    failed: bool,
435    context: ReportContext<'_>,
436) -> String {
437    let summary = Summary::from_outcomes(outcomes);
438    let mut out = String::new();
439    out.push_str("{\n");
440    out.push_str(&format!("  \"schema_version\": {REPORT_SCHEMA_VERSION},\n"));
441    out.push_str(&format!("  \"schema_id\": \"{REPORT_SCHEMA_ID}\",\n"));
442    out.push_str("  \"tool\": \"cargo-allow\",\n");
443    out.push_str(&format!("  \"command\": \"{}\",\n", json_escape(command)));
444    out.push_str(&format!(
445        "  \"status\": \"{}\",\n",
446        if failed { "failed" } else { "passed" }
447    ));
448    out.push_str(&format!("  \"failed\": {},\n", bool_json(failed)));
449    out.push_str(&format!(
450        "  \"claim_boundary\": {},\n",
451        json_string_array(CLAIM_BOUNDARY)
452    ));
453    out.push_str(&format!(
454        "  \"scanner_limitations\": {},\n",
455        json_string_array(SCANNER_LIMITATIONS)
456    ));
457    out.push_str("  \"inventory\": {\n");
458    out.push_str("    \"scope\": \"source_tree\",\n");
459    out.push_str("    \"scanner\": \"source_syntax\",\n");
460    out.push_str(&format!(
461        "    \"source\": \"{}\"\n",
462        json_escape(context.inventory_source)
463    ));
464    out.push_str("  },\n");
465    out.push_str("  \"summary\": {\n");
466    out.push_str(&format!("    \"findings\": {},\n", findings.len()));
467    out.push_str(&format!("    \"outcomes\": {},\n", summary.total));
468    out.push_str(&render_counts_fields(&summary, "    "));
469    out.push_str("  },\n");
470    out.push_str("  \"trend\": {\n");
471    out.push_str(&render_trend_fields(&summary, "    "));
472    out.push_str("  },\n");
473    out.push_str("  \"outcomes\": [\n");
474    for (i, outcome) in outcomes.iter().enumerate() {
475        if i > 0 {
476            out.push_str(",\n");
477        }
478        out.push_str("    {");
479        out.push_str(&format!("\"status\": \"{}\", ", outcome.status.as_str()));
480        out.push_str(&format!(
481            "\"allow_id\": {}, ",
482            option_json(outcome.allow_id.as_deref())
483        ));
484        out.push_str(&format!(
485            "\"finding_index\": {}, ",
486            outcome
487                .finding_index
488                .map(|v| v.to_string())
489                .unwrap_or_else(|| "null".to_string())
490        ));
491        out.push_str(&format!("\"score\": {}, ", outcome.score));
492        out.push_str(&format!(
493            "\"message\": \"{}\"",
494            json_escape(&outcome.message)
495        ));
496        out.push('}');
497    }
498    out.push_str("\n  ],\n");
499    out.push_str("  \"findings\": [\n");
500    for (i, finding) in findings.iter().enumerate() {
501        if i > 0 {
502            out.push_str(",\n");
503        }
504        out.push_str("    {");
505        out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
506        out.push_str(&format!(
507            "\"family\": {}, ",
508            option_json(finding.family.as_deref())
509        ));
510        out.push_str(&format!(
511            "\"path\": \"{}\", ",
512            json_escape(&normalize_path(&finding.path))
513        ));
514        out.push_str(&format!(
515            "\"line\": {}, ",
516            finding
517                .span
518                .as_ref()
519                .map(|s| s.line.to_string())
520                .unwrap_or_else(|| "null".to_string())
521        ));
522        out.push_str(&format!(
523            "\"container\": {}, ",
524            option_json(finding.identity.container.as_deref())
525        ));
526        out.push_str(&format!(
527            "\"ast_kind\": \"{}\"",
528            json_escape(&finding.identity.ast_kind)
529        ));
530        out.push('}');
531    }
532    out.push_str("\n  ]\n}");
533    out
534}
535
536pub fn render_sarif(
537    command: &str,
538    findings: &[Finding],
539    outcomes: &[MatchOutcome],
540    failed: bool,
541) -> String {
542    render_sarif_with_context(
543        command,
544        findings,
545        outcomes,
546        failed,
547        ReportContext::default(),
548    )
549}
550
551pub fn render_sarif_with_context(
552    command: &str,
553    findings: &[Finding],
554    outcomes: &[MatchOutcome],
555    failed: bool,
556    context: ReportContext<'_>,
557) -> String {
558    let reportable = outcomes
559        .iter()
560        .filter(|outcome| outcome.status != MatchStatus::Matched)
561        .collect::<Vec<_>>();
562    let mut out = String::new();
563    out.push_str("{\n");
564    out.push_str("  \"$schema\": \"https://json.schemastore.org/sarif-2.1.0.json\",\n");
565    out.push_str("  \"version\": \"2.1.0\",\n");
566    out.push_str("  \"runs\": [\n");
567    out.push_str("    {\n");
568    out.push_str("      \"tool\": {\n");
569    out.push_str("        \"driver\": {\n");
570    out.push_str("          \"name\": \"cargo-allow\",\n");
571    out.push_str(
572        "          \"informationUri\": \"https://github.com/EffortlessMetrics/cargo-allow\",\n",
573    );
574    out.push_str("          \"rules\": [\n");
575    for (index, status) in SARIF_STATUSES.iter().enumerate() {
576        if index > 0 {
577            out.push_str(",\n");
578        }
579        out.push_str(&render_sarif_rule(*status));
580    }
581    out.push_str("\n          ]\n");
582    out.push_str("        }\n");
583    out.push_str("      },\n");
584    out.push_str("      \"properties\": {\n");
585    out.push_str(&format!(
586        "        \"command\": \"{}\",\n",
587        json_escape(command)
588    ));
589    out.push_str(&format!(
590        "        \"status\": \"{}\",\n",
591        if failed { "failed" } else { "passed" }
592    ));
593    out.push_str(&format!("        \"failed\": {},\n", bool_json(failed)));
594    out.push_str("        \"inventory\": {\n");
595    out.push_str("          \"scope\": \"source_tree\",\n");
596    out.push_str("          \"scanner\": \"source_syntax\",\n");
597    out.push_str(&format!(
598        "          \"source\": \"{}\"\n",
599        json_escape(context.inventory_source)
600    ));
601    out.push_str("        },\n");
602    out.push_str(&format!(
603        "        \"claim_boundary\": {},\n",
604        json_string_array(CLAIM_BOUNDARY)
605    ));
606    out.push_str(&format!(
607        "        \"scanner_limitations\": {}\n",
608        json_string_array(SCANNER_LIMITATIONS)
609    ));
610    out.push_str("      },\n");
611    out.push_str("      \"results\": [\n");
612    for (index, outcome) in reportable.iter().enumerate() {
613        if index > 0 {
614            out.push_str(",\n");
615        }
616        let finding = outcome.finding_index.and_then(|idx| findings.get(idx));
617        out.push_str(&render_sarif_result(outcome, finding));
618    }
619    out.push_str("\n      ]\n");
620    out.push_str("    }\n");
621    out.push_str("  ]\n");
622    out.push_str("}\n");
623    out
624}
625
626const SARIF_STATUSES: &[MatchStatus] = &[
627    MatchStatus::New,
628    MatchStatus::Expired,
629    MatchStatus::ReviewDue,
630    MatchStatus::Stale,
631    MatchStatus::Ambiguous,
632    MatchStatus::InvalidSelector,
633    MatchStatus::MissingRequiredField,
634    MatchStatus::EvidenceMissing,
635    MatchStatus::BaselineDebt,
636];
637
638fn render_sarif_rule(status: MatchStatus) -> String {
639    format!(
640        "            {{\"id\": \"{}\", \"name\": \"{}\", \"shortDescription\": {{\"text\": \"{}\"}}}}",
641        sarif_rule_id(status),
642        status.as_str(),
643        sarif_rule_description(status)
644    )
645}
646
647fn render_sarif_result(outcome: &MatchOutcome, finding: Option<&Finding>) -> String {
648    let mut out = String::new();
649    out.push_str("        {\n");
650    out.push_str(&format!(
651        "          \"ruleId\": \"{}\",\n",
652        sarif_rule_id(outcome.status)
653    ));
654    out.push_str(&format!(
655        "          \"level\": \"{}\",\n",
656        sarif_level(outcome.status)
657    ));
658    out.push_str(&format!(
659        "          \"message\": {{\"text\": \"{}\"}},\n",
660        json_escape(&outcome.message)
661    ));
662    out.push_str("          \"properties\": {\n");
663    out.push_str(&format!(
664        "            \"status\": \"{}\",\n",
665        outcome.status.as_str()
666    ));
667    out.push_str(&format!(
668        "            \"allow_id\": {},\n",
669        option_json(outcome.allow_id.as_deref())
670    ));
671    out.push_str(&format!(
672        "            \"finding_index\": {},\n",
673        outcome
674            .finding_index
675            .map(|idx| idx.to_string())
676            .unwrap_or_else(|| "null".to_string())
677    ));
678    out.push_str(&format!("            \"score\": {}\n", outcome.score));
679    out.push_str("          }");
680    if let Some(finding) = finding {
681        out.push_str(",\n");
682        out.push_str("          \"locations\": [\n");
683        out.push_str(&render_sarif_location(finding));
684        out.push_str("\n          ]\n");
685        out.push_str("        }");
686    } else {
687        out.push('\n');
688        out.push_str("        }");
689    }
690    out
691}
692
693fn render_sarif_location(finding: &Finding) -> String {
694    let mut out = String::new();
695    out.push_str("            {\n");
696    out.push_str("              \"physicalLocation\": {\n");
697    out.push_str(&format!(
698        "                \"artifactLocation\": {{\"uri\": \"{}\"}}",
699        json_escape(&normalize_path(&finding.path))
700    ));
701    if let Some(span) = &finding.span {
702        out.push_str(",\n");
703        out.push_str("                \"region\": {\n");
704        out.push_str(&format!(
705            "                  \"startLine\": {},\n",
706            span.line
707        ));
708        out.push_str(&format!(
709            "                  \"startColumn\": {}\n",
710            span.column
711        ));
712        out.push_str("                }\n");
713        out.push_str("              }\n");
714    } else {
715        out.push('\n');
716        out.push_str("              }\n");
717    }
718    out.push_str("            }");
719    out
720}
721
722fn sarif_rule_id(status: MatchStatus) -> String {
723    format!("cargo-allow/{}", status.as_str())
724}
725
726fn sarif_rule_description(status: MatchStatus) -> &'static str {
727    match status {
728        MatchStatus::New => "New unreceipted source-tree exception finding.",
729        MatchStatus::Expired => "Matched allow entry is expired.",
730        MatchStatus::ReviewDue => "Matched allow entry is due for review.",
731        MatchStatus::Stale => "Allow entry did not match any current finding.",
732        MatchStatus::Ambiguous => "Selector matched ambiguously and needs narrowing.",
733        MatchStatus::InvalidSelector => "Allow entry selector is invalid.",
734        MatchStatus::MissingRequiredField => "Allow entry is missing required policy metadata.",
735        MatchStatus::EvidenceMissing => "Allow entry is missing required evidence.",
736        MatchStatus::BaselineDebt => "Generated baseline debt remains in policy.",
737        MatchStatus::Matched => "Finding matched policy.",
738    }
739}
740
741fn sarif_level(status: MatchStatus) -> &'static str {
742    match status {
743        MatchStatus::New
744        | MatchStatus::Expired
745        | MatchStatus::Ambiguous
746        | MatchStatus::InvalidSelector
747        | MatchStatus::MissingRequiredField
748        | MatchStatus::EvidenceMissing => "error",
749        MatchStatus::ReviewDue | MatchStatus::BaselineDebt => "warning",
750        MatchStatus::Stale => "note",
751        MatchStatus::Matched => "none",
752    }
753}
754
755pub fn render_receipt(command: &str, outcomes: &[MatchOutcome], failed: bool) -> String {
756    render_receipt_with_context(command, outcomes, failed, ReportContext::default())
757}
758
759pub fn render_receipt_with_context(
760    command: &str,
761    outcomes: &[MatchOutcome],
762    failed: bool,
763    context: ReportContext<'_>,
764) -> String {
765    let summary = Summary::from_outcomes(outcomes);
766    format!(
767        "{{\n  \"schema_version\": {RECEIPT_SCHEMA_VERSION},\n  \"schema_id\": \"{RECEIPT_SCHEMA_ID}\",\n  \"tool\": \"cargo-allow\",\n  \"command\": \"{}\",\n  \"status\": \"{}\",\n  \"failed\": {},\n  \"claim_boundary\": {},\n  \"scanner_limitations\": {},\n  \"inventory\": {{\n    \"scope\": \"source_tree\",\n    \"scanner\": \"source_syntax\",\n    \"source\": \"{}\"\n  }},\n  \"counts\": {{\n{}  }}\n}}\n",
768        json_escape(command),
769        if failed { "failed" } else { "passed" },
770        bool_json(failed),
771        json_string_array(CLAIM_BOUNDARY),
772        json_string_array(SCANNER_LIMITATIONS),
773        json_escape(context.inventory_source),
774        render_counts_fields(&summary, "    ")
775    )
776}
777
778fn option_json(value: Option<&str>) -> String {
779    value
780        .map(|v| format!("\"{}\"", json_escape(v)))
781        .unwrap_or_else(|| "null".to_string())
782}
783
784fn bool_json(value: bool) -> &'static str {
785    if value { "true" } else { "false" }
786}
787
788fn json_string_array(values: &[&str]) -> String {
789    format!(
790        "[{}]",
791        values
792            .iter()
793            .map(|value| format!("\"{}\"", json_escape(value)))
794            .collect::<Vec<_>>()
795            .join(", ")
796    )
797}
798
799fn render_counts_fields(summary: &Summary, indent: &str) -> String {
800    let statuses = [
801        MatchStatus::Matched,
802        MatchStatus::New,
803        MatchStatus::Expired,
804        MatchStatus::ReviewDue,
805        MatchStatus::Stale,
806        MatchStatus::Ambiguous,
807        MatchStatus::InvalidSelector,
808        MatchStatus::MissingRequiredField,
809        MatchStatus::EvidenceMissing,
810        MatchStatus::BaselineDebt,
811    ];
812    statuses
813        .iter()
814        .enumerate()
815        .map(|(idx, status)| {
816            let comma = if idx + 1 == statuses.len() { "" } else { "," };
817            format!(
818                "{indent}\"{}\": {}{comma}\n",
819                status.as_str(),
820                summary.count(*status)
821            )
822        })
823        .collect::<String>()
824}
825
826fn render_trend_fields(summary: &Summary, indent: &str) -> String {
827    let fields = [
828        ("review_items", review_item_count(summary)),
829        ("new", summary.count(MatchStatus::New)),
830        ("expired", summary.count(MatchStatus::Expired)),
831        ("review_due", summary.count(MatchStatus::ReviewDue)),
832        ("stale", summary.count(MatchStatus::Stale)),
833        ("ambiguous", summary.count(MatchStatus::Ambiguous)),
834        (
835            "invalid_selector",
836            summary.count(MatchStatus::InvalidSelector),
837        ),
838        (
839            "missing_required_field",
840            summary.count(MatchStatus::MissingRequiredField),
841        ),
842        (
843            "evidence_missing",
844            summary.count(MatchStatus::EvidenceMissing),
845        ),
846        ("baseline_debt", summary.count(MatchStatus::BaselineDebt)),
847    ];
848    fields
849        .iter()
850        .enumerate()
851        .map(|(idx, (name, value))| {
852            let comma = if idx + 1 == fields.len() { "" } else { "," };
853            format!("{indent}\"{name}\": {value}{comma}\n")
854        })
855        .collect()
856}
857
858fn review_item_count(summary: &Summary) -> usize {
859    [
860        MatchStatus::New,
861        MatchStatus::Expired,
862        MatchStatus::ReviewDue,
863        MatchStatus::Stale,
864        MatchStatus::Ambiguous,
865        MatchStatus::InvalidSelector,
866        MatchStatus::MissingRequiredField,
867        MatchStatus::EvidenceMissing,
868        MatchStatus::BaselineDebt,
869    ]
870    .iter()
871    .map(|status| summary.count(*status))
872    .sum()
873}
874
875#[derive(Debug, Default)]
876struct FilePosture {
877    total: usize,
878    by_family: BTreeMap<String, usize>,
879    matched: usize,
880    new: usize,
881    generated: usize,
882}
883
884impl FilePosture {
885    fn from_report(findings: &[Finding], outcomes: &[MatchOutcome]) -> Self {
886        let mut posture = Self::default();
887        for finding in findings.iter().filter(|finding| is_file_finding(finding)) {
888            posture.total += 1;
889            if finding.kind == FindingKind::GeneratedCode {
890                posture.generated += 1;
891            }
892            *posture
893                .by_family
894                .entry(
895                    finding
896                        .family
897                        .clone()
898                        .unwrap_or_else(|| "unknown".to_string()),
899                )
900                .or_insert(0) += 1;
901        }
902        for outcome in outcomes {
903            let applies_to_file = outcome
904                .finding_index
905                .and_then(|idx| findings.get(idx))
906                .map(is_file_finding)
907                .unwrap_or(false);
908            match outcome.status {
909                MatchStatus::Matched if applies_to_file => posture.matched += 1,
910                MatchStatus::New if applies_to_file => posture.new += 1,
911                _ => {}
912            }
913        }
914        posture
915    }
916
917    fn has_files(&self) -> bool {
918        self.total > 0
919    }
920}
921
922fn render_non_rust_human(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
923    let posture = FilePosture::from_report(findings, outcomes);
924    if !posture.has_files() {
925        return;
926    }
927    out.push('\n');
928    out.push_str("Non-Rust file inventory:\n");
929    out.push_str(&format!("  files scanned              {}\n", posture.total));
930    out.push_str(&format!(
931        "  matched                    {}\n",
932        posture.matched
933    ));
934    out.push_str(&format!("  new                        {}\n", posture.new));
935    out.push_str(&format!(
936        "  generated                  {}\n",
937        posture.generated
938    ));
939    if !posture.by_family.is_empty() {
940        out.push_str("  by family:\n");
941        for (family, count) in posture.by_family {
942            out.push_str(&format!("    {:24} {}\n", family, count));
943        }
944    }
945    let rows = non_rust_file_rows(findings, outcomes);
946    if !rows.is_empty() {
947        out.push_str("  files:\n");
948        for row in rows.into_iter().take(40) {
949            out.push_str(&format!(
950                "    {:12} {:24} {}\n",
951                row.status, row.family, row.path
952            ));
953        }
954    }
955}
956
957fn render_non_rust_markdown(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
958    let posture = FilePosture::from_report(findings, outcomes);
959    if !posture.has_files() {
960        return;
961    }
962    out.push_str("\n## Non-Rust File Inventory\n\n");
963    out.push_str("| Metric | Count |\n|---|---:|\n");
964    out.push_str(&format!("| Files scanned | {} |\n", posture.total));
965    out.push_str(&format!("| Matched | {} |\n", posture.matched));
966    out.push_str(&format!("| New | {} |\n", posture.new));
967    out.push_str(&format!("| Generated | {} |\n", posture.generated));
968    if !posture.by_family.is_empty() {
969        out.push_str("\n| Family | Count |\n|---|---:|\n");
970        for (family, count) in posture.by_family {
971            out.push_str(&format!("| `{}` | {} |\n", markdown_cell(&family), count));
972        }
973    }
974    let rows = non_rust_file_rows(findings, outcomes);
975    if !rows.is_empty() {
976        out.push_str("\n| Status | Family | Path |\n|---|---|---|\n");
977        for row in rows.into_iter().take(60) {
978            out.push_str(&format!(
979                "| `{}` | `{}` | `{}` |\n",
980                markdown_cell(row.status),
981                markdown_cell(&row.family),
982                markdown_cell(&row.path)
983            ));
984        }
985    }
986}
987
988fn render_non_rust_html(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
989    let posture = FilePosture::from_report(findings, outcomes);
990    if !posture.has_files() {
991        return;
992    }
993    out.push_str("<h2>Non-Rust File Inventory</h2>\n");
994    out.push_str("<table><thead><tr><th>Metric</th><th>Count</th></tr></thead><tbody>\n");
995    for (name, value) in [
996        ("Files scanned", posture.total),
997        ("Matched", posture.matched),
998        ("New", posture.new),
999        ("Generated", posture.generated),
1000    ] {
1001        out.push_str(&format!(
1002            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
1003            html_escape(name),
1004            value
1005        ));
1006    }
1007    out.push_str("</tbody></table>\n");
1008    if !posture.by_family.is_empty() {
1009        out.push_str("<table><thead><tr><th>Family</th><th>Count</th></tr></thead><tbody>\n");
1010        for (family, count) in posture.by_family {
1011            out.push_str(&format!(
1012                "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
1013                html_escape(&family),
1014                count
1015            ));
1016        }
1017        out.push_str("</tbody></table>\n");
1018    }
1019    let rows = non_rust_file_rows(findings, outcomes);
1020    if !rows.is_empty() {
1021        out.push_str(
1022            "<table><thead><tr><th>Status</th><th>Family</th><th>Path</th></tr></thead><tbody>\n",
1023        );
1024        for row in rows.into_iter().take(60) {
1025            out.push_str(&format!(
1026                "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
1027                html_escape(row.status),
1028                html_escape(&row.family),
1029                html_escape(&row.path)
1030            ));
1031        }
1032        out.push_str("</tbody></table>\n");
1033    }
1034}
1035
1036fn render_non_matched_html(outcomes: &[MatchOutcome], out: &mut String) {
1037    let non_matched = outcomes
1038        .iter()
1039        .filter(|outcome| outcome.status != MatchStatus::Matched)
1040        .take(100)
1041        .collect::<Vec<_>>();
1042    if non_matched.is_empty() {
1043        return;
1044    }
1045    out.push_str("<h2>Non-matched Outcomes</h2>\n<ul>\n");
1046    for outcome in non_matched {
1047        out.push_str(&format!(
1048            "<li><code>{}</code>: {}</li>\n",
1049            outcome.status.as_str(),
1050            html_escape(&outcome.message)
1051        ));
1052    }
1053    out.push_str("</ul>\n");
1054}
1055
1056fn markdown_cell(value: &str) -> String {
1057    value.replace('|', "\\|").replace('`', "\\`")
1058}
1059
1060fn html_escape(value: &str) -> String {
1061    value
1062        .replace('&', "&amp;")
1063        .replace('<', "&lt;")
1064        .replace('>', "&gt;")
1065        .replace('"', "&quot;")
1066        .replace('\'', "&#39;")
1067}
1068
1069fn is_file_finding(finding: &Finding) -> bool {
1070    matches!(
1071        finding.kind,
1072        FindingKind::NonRustFile | FindingKind::GeneratedCode
1073    )
1074}
1075
1076#[derive(Debug)]
1077struct FileRow {
1078    status: &'static str,
1079    family: String,
1080    path: String,
1081}
1082
1083fn non_rust_file_rows(findings: &[Finding], outcomes: &[MatchOutcome]) -> Vec<FileRow> {
1084    let mut status_by_index = BTreeMap::new();
1085    for outcome in outcomes {
1086        if let Some(index) = outcome.finding_index {
1087            status_by_index.insert(index, outcome.status.as_str());
1088        }
1089    }
1090    let mut rows = findings
1091        .iter()
1092        .enumerate()
1093        .filter(|(_, finding)| is_file_finding(finding))
1094        .map(|(index, finding)| FileRow {
1095            status: status_by_index.get(&index).copied().unwrap_or("unmatched"),
1096            family: finding
1097                .family
1098                .clone()
1099                .unwrap_or_else(|| "unknown".to_string()),
1100            path: normalize_path(&finding.path),
1101        })
1102        .collect::<Vec<_>>();
1103    rows.sort_by(|left, right| {
1104        left.path
1105            .cmp(&right.path)
1106            .then_with(|| left.family.cmp(&right.family))
1107            .then_with(|| left.status.cmp(right.status))
1108    });
1109    rows
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use allow_core::{Finding, FindingKind, Span, StructuralIdentity};
1116    use std::path::PathBuf;
1117
1118    #[test]
1119    fn json_contains_claim_boundary() {
1120        let json = render_json("audit", &[], &[], false);
1121        assert!(json.contains("source_tree_inventory"));
1122        assert!(json.contains("cargo_metadata_not_invoked"));
1123        assert!(json.contains("cargo_commands_not_invoked"));
1124        assert!(json.contains("rustc_not_invoked"));
1125        assert!(json.contains("clippy_not_invoked"));
1126        assert!(json.contains("build_scripts_not_executed"));
1127        assert!(json.contains("proc_macros_not_executed"));
1128        assert!(json.contains("macro_expansion_not_analyzed"));
1129        assert!(json.contains("macro_token_tree_contents_not_analyzed"));
1130        assert!(json.contains("repository_code_not_executed"));
1131    }
1132
1133    #[test]
1134    fn json_report_exposes_v1_schema_contract() {
1135        let json = render_json("audit", &[], &[], false);
1136        assert!(json.contains("\"schema_version\": 1"));
1137        assert!(json.contains("\"schema_id\": \"cargo-allow.report.v1\""));
1138        assert!(json.contains("\"failed\": false"));
1139        assert!(json.contains("\"scanner_limitations\""));
1140        assert!(json.contains("\"scope\": \"source_tree\""));
1141        assert!(json.contains("\"scanner\": \"source_syntax\""));
1142        assert!(json.contains("\"source\": \"unknown\""));
1143        assert!(json.contains("\"review_due\": 0"));
1144        assert!(json.contains("\"baseline_debt\": 0"));
1145        assert!(json.contains("\"trend\""));
1146        assert!(json.contains("\"review_items\": 0"));
1147    }
1148
1149    #[test]
1150    fn json_report_exposes_trend_metrics() {
1151        let outcomes = vec![
1152            outcome(MatchStatus::New, Some(0)),
1153            outcome(MatchStatus::EvidenceMissing, Some(1)),
1154            outcome(MatchStatus::Stale, None),
1155        ];
1156
1157        let json = render_json("audit", &[], &outcomes, false);
1158
1159        assert!(json.contains("\"trend\""));
1160        assert!(json.contains("\"review_items\": 3"));
1161        assert!(json.contains("\"new\": 1"));
1162        assert!(json.contains("\"stale\": 1"));
1163        assert!(json.contains("\"evidence_missing\": 1"));
1164        assert!(json.contains("\"baseline_debt\": 0"));
1165    }
1166
1167    #[test]
1168    fn sarif_report_emits_non_matched_results_with_locations() {
1169        let findings = vec![file_finding(
1170            FindingKind::NonRustFile,
1171            "shell_script",
1172            "scripts/new.sh",
1173        )];
1174        let outcomes = vec![
1175            outcome(MatchStatus::Matched, Some(0)),
1176            MatchOutcome {
1177                status: MatchStatus::New,
1178                allow_id: None,
1179                finding_index: Some(0),
1180                message: "unreceipted shell script at scripts/new.sh".to_string(),
1181                score: 0,
1182            },
1183        ];
1184
1185        let sarif = render_sarif_with_context(
1186            "check",
1187            &findings,
1188            &outcomes,
1189            true,
1190            ReportContext {
1191                inventory_source: "git_tracked",
1192            },
1193        );
1194
1195        assert!(sarif.contains("\"version\": \"2.1.0\""));
1196        assert!(sarif.contains("\"name\": \"cargo-allow\""));
1197        assert!(sarif.contains("\"ruleId\": \"cargo-allow/new\""));
1198        assert!(sarif.contains("\"level\": \"error\""));
1199        assert!(sarif.contains("\"uri\": \"scripts/new.sh\""));
1200        assert!(sarif.contains("\"startLine\": 1"));
1201        assert!(sarif.contains("\"source_tree_inventory\""));
1202        assert!(sarif.contains("\"cargo_commands_not_invoked\""));
1203        assert!(!sarif.contains("\"ruleId\": \"cargo-allow/matched\""));
1204    }
1205
1206    #[test]
1207    fn receipt_exposes_v1_schema_contract() {
1208        let json = render_receipt_with_context(
1209            "check",
1210            &[],
1211            true,
1212            ReportContext {
1213                inventory_source: "git_tracked",
1214            },
1215        );
1216        assert!(json.contains("\"schema_version\": 1"));
1217        assert!(json.contains("\"schema_id\": \"cargo-allow.receipt.v1\""));
1218        assert!(json.contains("\"failed\": true"));
1219        assert!(json.contains("\"source\": \"git_tracked\""));
1220        assert!(json.contains("\"cargo_metadata_not_invoked\""));
1221        assert!(json.contains("\"cargo_commands_not_invoked\""));
1222        assert!(json.contains("\"build_output_not_analyzed\""));
1223        assert!(json.contains("\"macro_token_tree_contents_not_analyzed\""));
1224        assert!(json.contains("\"missing_required_field\": 0"));
1225        assert!(json.contains("\"evidence_missing\": 0"));
1226    }
1227
1228    #[test]
1229    fn schemas_reference_current_contract_ids() {
1230        let report_schema = include_str!("../../../docs/schemas/report.schema.json");
1231        let receipt_schema = include_str!("../../../docs/schemas/receipt.schema.json");
1232        assert!(report_schema.contains(REPORT_SCHEMA_ID));
1233        assert!(receipt_schema.contains(RECEIPT_SCHEMA_ID));
1234    }
1235
1236    #[test]
1237    fn human_report_summarizes_non_rust_inventory() {
1238        let findings = vec![
1239            file_finding(FindingKind::NonRustFile, "configuration", ".gitignore"),
1240            file_finding(
1241                FindingKind::GeneratedCode,
1242                "generated_code",
1243                "schemas/api.yaml",
1244            ),
1245        ];
1246        let outcomes = vec![
1247            outcome(MatchStatus::Matched, Some(0)),
1248            outcome(MatchStatus::New, Some(1)),
1249        ];
1250
1251        let text = render_human_with_context(
1252            "audit",
1253            &findings,
1254            &outcomes,
1255            false,
1256            ReportContext {
1257                inventory_source: "filesystem_fallback",
1258            },
1259        );
1260
1261        assert!(text.contains("Inventory: source_tree/source_syntax via filesystem_fallback"));
1262        assert!(text.contains("Non-Rust file inventory:"));
1263        assert!(text.contains("files scanned              2"));
1264        assert!(text.contains("new                        1"));
1265        assert!(text.contains("generated                  1"));
1266        assert!(text.contains("configuration"));
1267        assert!(text.contains("generated_code"));
1268        assert!(text.contains("    matched      configuration            .gitignore"));
1269        assert!(text.contains("schemas/api.yaml"));
1270        assert!(text.contains("did not invoke Cargo metadata"));
1271        assert!(text.contains("repository code"));
1272    }
1273
1274    #[test]
1275    fn markdown_report_summarizes_non_rust_inventory() {
1276        let findings = vec![file_finding(
1277            FindingKind::NonRustFile,
1278            "ci_declarative",
1279            ".github/workflows/ci.yml",
1280        )];
1281        let outcomes = vec![outcome(MatchStatus::Matched, Some(0))];
1282
1283        let text = render_markdown_with_context(
1284            "audit",
1285            &findings,
1286            &outcomes,
1287            false,
1288            ReportContext {
1289                inventory_source: "git_tracked",
1290            },
1291        );
1292
1293        assert!(text.contains("Inventory: `source_tree` / `source_syntax` via `git_tracked`"));
1294        assert!(text.contains("## Non-Rust File Inventory"));
1295        assert!(text.contains("| Files scanned | 1 |"));
1296        assert!(text.contains("| `ci_declarative` | 1 |"));
1297        assert!(text.contains("| `matched` | `ci_declarative` | `.github/workflows/ci.yml` |"));
1298        assert!(!text.contains("## Non-matched outcomes"));
1299        assert!(text.contains("did not invoke Cargo metadata"));
1300        assert!(text.contains("proc macros"));
1301    }
1302
1303    #[test]
1304    fn html_report_summarizes_audit_posture() {
1305        let findings = vec![file_finding(
1306            FindingKind::NonRustFile,
1307            "shell_script",
1308            "scripts/new.sh",
1309        )];
1310        let outcomes = vec![MatchOutcome {
1311            status: MatchStatus::New,
1312            allow_id: None,
1313            finding_index: Some(0),
1314            message: "unreceipted shell script at scripts/new.sh".to_string(),
1315            score: 0,
1316        }];
1317
1318        let html = render_html_with_context(
1319            "audit",
1320            &findings,
1321            &outcomes,
1322            true,
1323            ReportContext {
1324                inventory_source: "git_tracked",
1325            },
1326        );
1327
1328        assert!(html.contains("<!doctype html>"));
1329        assert!(html.contains("<h1>cargo-allow audit</h1>"));
1330        assert!(html.contains("Result: failed"));
1331        assert!(html.contains("<h2>Audit Summary</h2>"));
1332        assert!(html.contains("<h2>Non-Rust File Inventory</h2>"));
1333        assert!(html.contains("<code>new</code>"));
1334        assert!(html.contains("<code>scripts/new.sh</code>"));
1335        assert!(html.contains("did not invoke Cargo metadata"));
1336    }
1337
1338    #[test]
1339    fn markdown_audit_report_includes_review_summary() {
1340        let findings = vec![
1341            file_finding(FindingKind::NonRustFile, "shell_script", "scripts/new.sh"),
1342            file_finding(FindingKind::Unsafe, "unsafe_block", "src/ffi.rs"),
1343        ];
1344        let outcomes = vec![
1345            MatchOutcome {
1346                status: MatchStatus::New,
1347                allow_id: None,
1348                finding_index: Some(0),
1349                message: "unreceipted shell script at scripts/new.sh".to_string(),
1350                score: 0,
1351            },
1352            MatchOutcome {
1353                status: MatchStatus::EvidenceMissing,
1354                allow_id: Some("allow-unsafe-ffi".to_string()),
1355                finding_index: Some(1),
1356                message: "allow-unsafe-ffi matched unsafe finding but has no evidence".to_string(),
1357                score: 0,
1358            },
1359        ];
1360
1361        let text = render_markdown_with_context(
1362            "audit",
1363            &findings,
1364            &outcomes,
1365            false,
1366            ReportContext {
1367                inventory_source: "git_tracked",
1368            },
1369        );
1370
1371        assert!(text.contains("## Audit Summary"));
1372        assert!(text.contains("| Match outcomes | 2 |"));
1373        assert!(text.contains("| Review items | 2 |"));
1374        assert!(text.contains("| New unreceipted | 1 |"));
1375        assert!(text.contains("| Evidence gaps | 1 |"));
1376        assert!(
1377            text.contains(
1378                "Recommended next step: review the queue below before tightening policy."
1379            )
1380        );
1381        assert!(text.contains("## Audit Review Queue"));
1382        assert!(text.contains("- `new`: unreceipted shell script at scripts/new.sh"));
1383        assert!(text.contains(
1384            "- `evidence_missing`: allow-unsafe-ffi matched unsafe finding but has no evidence"
1385        ));
1386    }
1387
1388    #[test]
1389    fn text_reports_include_review_due_and_invalid_selector_counts() {
1390        let outcomes = vec![
1391            MatchOutcome {
1392                status: MatchStatus::ReviewDue,
1393                allow_id: Some("allow-review".to_string()),
1394                finding_index: None,
1395                message: "allow-review is due for review".to_string(),
1396                score: 0,
1397            },
1398            MatchOutcome {
1399                status: MatchStatus::InvalidSelector,
1400                allow_id: Some("allow-invalid".to_string()),
1401                finding_index: None,
1402                message: "allow-invalid selector is invalid".to_string(),
1403                score: 0,
1404            },
1405        ];
1406
1407        let human = render_human("check", &[], &outcomes, true);
1408        let markdown = render_markdown("check", &[], &outcomes, true);
1409
1410        assert!(human.contains("review_due"));
1411        assert!(human.contains("invalid_selector"));
1412        assert!(markdown.contains("| `review_due` | 1 |"));
1413        assert!(markdown.contains("| `invalid_selector` | 1 |"));
1414    }
1415
1416    fn file_finding(kind: FindingKind, family: &str, path: &str) -> Finding {
1417        Finding {
1418            kind,
1419            family: Some(family.to_string()),
1420            path: PathBuf::from(path),
1421            span: Some(Span { line: 1, column: 1 }),
1422            identity: StructuralIdentity::new("file", "tracked_file"),
1423            message: "tracked non-Rust file".to_string(),
1424        }
1425    }
1426
1427    fn outcome(status: MatchStatus, finding_index: Option<usize>) -> MatchOutcome {
1428        MatchOutcome {
1429            status,
1430            allow_id: None,
1431            finding_index,
1432            message: String::new(),
1433            score: 0,
1434        }
1435    }
1436}