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('&', "&")
1063 .replace('<', "<")
1064 .replace('>', ">")
1065 .replace('"', """)
1066 .replace('\'', "'")
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}