1use crate::finding::{Finding, FindingKind, SeverityClass, TierSummary};
9use clap::ValueEnum;
10use serde::Serialize;
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Copy, ValueEnum, Default)]
15#[value(rename_all = "kebab-case")]
16pub enum Format {
17 #[default]
18 Text,
19 Markdown,
20 Json,
21 Sarif,
26 PrComment,
31}
32
33#[derive(Debug, Serialize)]
35pub struct Report<'a> {
36 pub version: &'static str,
37 pub changed_files: &'a [PathBuf],
38 pub candidate_symbols: &'a [String],
39 pub findings: &'a [Finding],
40 pub summary: ReportSummary,
41}
42
43#[derive(Debug, Serialize)]
44pub struct ReportSummary {
45 pub total: usize,
46 pub by_severity: BTreeMap<String, u32>,
47 pub by_tier: TierSummary,
48}
49
50impl ReportSummary {
51 pub fn build(findings: &[Finding]) -> Self {
52 let mut by_severity: BTreeMap<String, u32> = BTreeMap::new();
53 for f in findings {
54 *by_severity
55 .entry(f.severity.as_label().to_lowercase())
56 .or_insert(0) += 1;
57 }
58 Self {
59 total: findings.len(),
60 by_severity,
61 by_tier: TierSummary::from_findings(findings),
62 }
63 }
64}
65
66pub fn render(
67 format: Format,
68 changed_files: &[PathBuf],
69 candidate_symbols: &[String],
70 findings: &[Finding],
71) -> anyhow::Result<String> {
72 render_with_budget(format, changed_files, candidate_symbols, findings, 0)
73}
74
75pub fn render_with_budget(
80 format: Format,
81 changed_files: &[PathBuf],
82 candidate_symbols: &[String],
83 findings: &[Finding],
84 budget: usize,
85) -> anyhow::Result<String> {
86 match format {
87 Format::Text => Ok(render_text(changed_files, candidate_symbols, findings)),
88 Format::Markdown => Ok(render_markdown(
89 changed_files,
90 candidate_symbols,
91 findings,
92 budget,
93 )),
94 Format::Json => render_json(changed_files, candidate_symbols, findings),
95 Format::Sarif => render_sarif(findings),
96 Format::PrComment => Ok(render_pr_comment(
97 changed_files,
98 candidate_symbols,
99 findings,
100 budget,
101 )),
102 }
103}
104
105fn render_text(
106 changed_files: &[PathBuf],
107 candidate_symbols: &[String],
108 findings: &[Finding],
109) -> String {
110 let mut out = String::new();
111 out.push_str(&format!("cargo-impact v{}\n\n", env!("CARGO_PKG_VERSION")));
112
113 out.push_str(&format!("Changed files ({}):\n", changed_files.len()));
114 for f in changed_files {
115 out.push_str(&format!(" {}\n", f.display()));
116 }
117 out.push('\n');
118
119 out.push_str(&format!(
120 "Candidate symbols ({}):\n",
121 candidate_symbols.len()
122 ));
123 for s in candidate_symbols {
124 out.push_str(&format!(" {s}\n"));
125 }
126 out.push('\n');
127
128 let grouped = group_by_severity(findings);
129 for severity in [
130 SeverityClass::High,
131 SeverityClass::Medium,
132 SeverityClass::Low,
133 SeverityClass::Unknown,
134 ] {
135 let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
136 out.push_str(&format!(
137 "{icon} {label} ({n})\n",
138 icon = severity.icon(),
139 label = severity.as_label(),
140 n = group.len()
141 ));
142 for f in group {
143 out.push_str(&format!(
144 " [{id}] {summary} · {tier:?} {conf:.2}\n",
145 id = f.id,
146 summary = finding_summary(f),
147 tier = f.tier,
148 conf = f.confidence,
149 ));
150 }
151 out.push('\n');
152 }
153
154 out
155}
156
157fn render_markdown(
158 changed_files: &[PathBuf],
159 candidate_symbols: &[String],
160 findings: &[Finding],
161 budget: usize,
162) -> String {
163 let mut out = String::new();
164 render_markdown_header(&mut out, changed_files, candidate_symbols, findings);
165
166 let unlimited = budget == 0;
173 let mut omitted_findings = 0usize;
174 let mut omitted_checklist = 0usize;
175
176 let grouped = group_by_severity(findings);
177 for severity in [
178 SeverityClass::High,
179 SeverityClass::Medium,
180 SeverityClass::Low,
181 SeverityClass::Unknown,
182 ] {
183 let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
184 if group.is_empty() {
185 continue;
186 }
187 let header = format!(
188 "## {icon} {label} ({n})\n\n",
189 icon = severity.icon(),
190 label = severity.as_label(),
191 n = group.len()
192 );
193 let mut header_written = false;
197 for f in group {
198 let body = render_finding_bullet(f);
199 if !header_written {
200 if !unlimited && out.len() + header.len() + body.len() > budget {
201 omitted_findings += 1;
202 continue;
203 }
204 out.push_str(&header);
205 header_written = true;
206 }
207 if !unlimited && out.len() + body.len() > budget {
208 omitted_findings += 1;
209 continue;
210 }
211 out.push_str(&body);
212 }
213 if header_written {
214 out.push('\n');
215 }
216 }
217
218 let checklist_header = "## Verification checklist\n\n";
219 if findings.is_empty() {
220 out.push_str(checklist_header);
221 out.push_str("_No findings — nothing to verify._\n");
222 } else {
223 let mut header_written = false;
224 for f in findings {
225 let body = render_checklist_line(f);
226 if !header_written {
227 if !unlimited && out.len() + checklist_header.len() + body.len() > budget {
228 omitted_checklist += 1;
229 continue;
230 }
231 out.push_str(checklist_header);
232 header_written = true;
233 }
234 if !unlimited && out.len() + body.len() > budget {
235 omitted_checklist += 1;
236 continue;
237 }
238 out.push_str(&body);
239 }
240 }
241
242 if !unlimited && (omitted_findings > 0 || omitted_checklist > 0) {
243 out.push_str(&format!(
244 "\n---\n\n> **Budget truncation:** {omitted_findings} findings and \
245 {omitted_checklist} checklist items omitted because the rendered \
246 markdown would exceed the `--budget={budget}` character limit. \
247 Priority order was severity → tier → confidence, so what you see \
248 is what matters most. Re-run with `--budget=0` or `--format=json` \
249 to get everything.\n"
250 ));
251 }
252
253 out
254}
255
256fn render_markdown_header(
257 out: &mut String,
258 changed_files: &[PathBuf],
259 candidate_symbols: &[String],
260 findings: &[Finding],
261) {
262 out.push_str(&format!(
263 "# cargo-impact v{} blast radius\n\n",
264 env!("CARGO_PKG_VERSION")
265 ));
266 let summary = ReportSummary::build(findings);
267 out.push_str("## Summary\n\n");
268 out.push_str(&format!(
269 "- **Changed files:** {}\n- **Candidate symbols:** {}\n- **Findings:** {} \
270 ({} high, {} medium, {} low, {} unknown)\n\n",
271 changed_files.len(),
272 candidate_symbols.len(),
273 summary.total,
274 summary.by_severity.get("high").unwrap_or(&0),
275 summary.by_severity.get("medium").unwrap_or(&0),
276 summary.by_severity.get("low").unwrap_or(&0),
277 summary.by_severity.get("unknown").unwrap_or(&0),
278 ));
279}
280
281fn render_finding_bullet(f: &Finding) -> String {
282 let mut body = format!(
283 "- **[{id}]** {summary} — *{tier:?} {conf:.2}* — {evidence}\n",
284 id = f.id,
285 summary = finding_summary(f),
286 tier = f.tier,
287 conf = f.confidence,
288 evidence = f.evidence,
289 );
290 if let Some(action) = &f.suggested_action {
291 body.push_str(&format!(" - Suggested: `{action}`\n"));
292 }
293 body
294}
295
296fn render_checklist_line(f: &Finding) -> String {
297 format!(
298 "- [ ] **{label}** {summary} — *{tier:?} {conf:.2}*\n",
299 label = f.severity.as_label(),
300 summary = finding_summary(f),
301 tier = f.tier,
302 conf = f.confidence,
303 )
304}
305
306fn render_json(
307 changed_files: &[PathBuf],
308 candidate_symbols: &[String],
309 findings: &[Finding],
310) -> anyhow::Result<String> {
311 let report = Report {
312 version: env!("CARGO_PKG_VERSION"),
313 changed_files,
314 candidate_symbols,
315 findings,
316 summary: ReportSummary::build(findings),
317 };
318 Ok(serde_json::to_string_pretty(&report)?)
319}
320
321fn render_sarif(findings: &[Finding]) -> anyhow::Result<String> {
340 let rules = FindingKind::all_tags()
341 .iter()
342 .map(|tag| sarif_rule(tag))
343 .collect::<Vec<_>>();
344
345 let results = findings.iter().map(sarif_result).collect::<Vec<_>>();
346
347 let doc = serde_json::json!({
348 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
349 "version": "2.1.0",
350 "runs": [
351 {
352 "tool": {
353 "driver": {
354 "name": "cargo-impact",
355 "version": env!("CARGO_PKG_VERSION"),
356 "informationUri": "https://github.com/asmuelle/cargo-impact",
357 "rules": rules,
358 }
359 },
360 "results": results,
361 }
362 ]
363 });
364 Ok(serde_json::to_string_pretty(&doc)?)
365}
366
367fn sarif_rule(tag: &str) -> serde_json::Value {
368 serde_json::json!({
369 "id": tag,
370 "name": sarif_rule_name(tag),
371 "shortDescription": { "text": sarif_rule_short(tag) },
372 "helpUri": "https://github.com/asmuelle/cargo-impact#README",
373 })
374}
375
376fn sarif_rule_name(tag: &str) -> String {
377 tag.split('_')
380 .enumerate()
381 .map(|(i, part)| {
382 if i == 0 {
383 part.to_string()
384 } else {
385 let mut chars = part.chars();
386 match chars.next() {
387 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
388 None => String::new(),
389 }
390 }
391 })
392 .collect()
393}
394
395fn sarif_rule_short(tag: &str) -> &'static str {
396 match tag {
397 "test_reference" => "Test function references a changed symbol",
398 "trait_impl" => "impl of a changed trait",
399 "derived_trait_impl" => "#[derive(…)] of a changed trait",
400 "dyn_dispatch" => "dyn Trait use of a changed trait",
401 "doc_drift_link" => "Intra-doc link to a changed symbol",
402 "doc_drift_keyword" => "Bare mention of a changed symbol in prose",
403 "ffi_signature_change" => "FFI signature added/removed/modified",
404 "build_script_changed" => "build.rs modified",
405 "semver_check" => "cargo-semver-checks reports public API change",
406 "trait_definition_change" => "Method added/removed/changed on a trait",
407 "resolved_reference" => "rust-analyzer-resolved reference to a changed symbol",
408 "runtime_surface" => "Framework runtime surface (axum route, clap command) affected",
409 _ => "cargo-impact finding",
410 }
411}
412
413fn sarif_result(f: &Finding) -> serde_json::Value {
414 let mut location = serde_json::json!({});
415 if let Some(path) = f.primary_path() {
416 let uri = path.to_string_lossy().replace('\\', "/");
417 let mut physical = serde_json::json!({
418 "artifactLocation": { "uri": uri },
419 });
420 if let Some(line) = finding_line(f) {
421 physical["region"] = serde_json::json!({ "startLine": line });
422 }
423 location = serde_json::json!({ "physicalLocation": physical });
424 }
425
426 let mut locations = Vec::new();
427 if !location.as_object().is_none_or(serde_json::Map::is_empty) {
428 locations.push(location);
429 }
430
431 serde_json::json!({
432 "ruleId": f.kind.tag(),
433 "level": sarif_level(f.severity),
434 "message": { "text": f.evidence },
435 "locations": locations,
436 "partialFingerprints": {
437 "primaryLocationLineHash": f.id
438 },
439 "properties": {
440 "tier": format!("{:?}", f.tier).to_lowercase(),
441 "confidence": f.confidence,
442 "severity": f.severity.as_label().to_lowercase(),
443 "suggestedAction": f.suggested_action,
444 }
445 })
446}
447
448fn sarif_level(severity: SeverityClass) -> &'static str {
449 match severity {
451 SeverityClass::High => "error",
452 SeverityClass::Medium => "warning",
453 SeverityClass::Low => "note",
454 SeverityClass::Unknown => "none",
455 }
456}
457
458fn finding_line(f: &Finding) -> Option<u32> {
462 match &f.kind {
463 FindingKind::DocDriftLink { line, .. } | FindingKind::DocDriftKeyword { line, .. } => {
464 Some(*line)
465 }
466 _ => None,
467 }
468}
469
470fn render_pr_comment(
477 changed_files: &[PathBuf],
478 candidate_symbols: &[String],
479 findings: &[Finding],
480 budget: usize,
481) -> String {
482 let summary = ReportSummary::build(findings);
483 let unlimited = budget == 0;
484 let mut out = String::new();
485
486 out.push_str(&format!(
489 "### 🎯 cargo-impact — {total} findings across {nfiles} file{s}\n\
490 `🔴 {h} · 🟡 {m} · 🔵 {l} · ⚪ {u}`\n\n",
491 total = summary.total,
492 nfiles = changed_files.len(),
493 s = if changed_files.len() == 1 { "" } else { "s" },
494 h = summary.by_severity.get("high").unwrap_or(&0),
495 m = summary.by_severity.get("medium").unwrap_or(&0),
496 l = summary.by_severity.get("low").unwrap_or(&0),
497 u = summary.by_severity.get("unknown").unwrap_or(&0),
498 ));
499
500 if findings.is_empty() {
501 out.push_str("_No findings — nothing to verify._\n");
502 return out;
503 }
504
505 let grouped = group_by_severity(findings);
509 let mut omitted = 0usize;
510 for (severity, expand_default) in [
511 (SeverityClass::High, true),
512 (SeverityClass::Medium, false),
513 (SeverityClass::Low, false),
514 (SeverityClass::Unknown, false),
515 ] {
516 let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
517 if group.is_empty() {
518 continue;
519 }
520 let open = if expand_default { " open" } else { "" };
521 let block_header = format!(
522 "<details{open}><summary>{icon} {label} ({n})</summary>\n\n\
523 | Kind | Location | Evidence |\n\
524 |---|---|---|\n",
525 icon = severity.icon(),
526 label = severity.as_label(),
527 n = group.len(),
528 );
529 let mut rows = String::new();
530 for f in group {
531 let row = format!(
532 "| `{kind}` | {loc} | {evidence} |\n",
533 kind = f.kind.tag(),
534 loc = pr_comment_location(f),
535 evidence = escape_pipe(&f.evidence),
536 );
537 if !unlimited && out.len() + block_header.len() + rows.len() + row.len() > budget {
538 omitted += 1;
539 continue;
540 }
541 rows.push_str(&row);
542 }
543 if !rows.is_empty() {
544 out.push_str(&block_header);
545 out.push_str(&rows);
546 out.push_str("\n</details>\n\n");
547 }
548 }
549
550 out.push_str(&format!(
553 "<details><summary>📁 {n} changed file{s} · {k} candidate symbol{ks}</summary>\n\n",
554 n = changed_files.len(),
555 s = if changed_files.len() == 1 { "" } else { "s" },
556 k = candidate_symbols.len(),
557 ks = if candidate_symbols.len() == 1 {
558 ""
559 } else {
560 "s"
561 },
562 ));
563 out.push_str("**Changed files:**\n\n");
564 for f in changed_files {
565 out.push_str(&format!("- `{}`\n", f.display()));
566 }
567 if !candidate_symbols.is_empty() {
568 out.push_str("\n**Candidate symbols:** ");
569 out.push_str(
570 &candidate_symbols
571 .iter()
572 .map(|s| format!("`{s}`"))
573 .collect::<Vec<_>>()
574 .join(", "),
575 );
576 out.push('\n');
577 }
578 out.push_str("\n</details>\n");
579
580 if !unlimited && omitted > 0 {
581 out.push_str(&format!(
582 "\n> ⚠ {omitted} findings omitted to fit the `--budget={budget}` cap.\n"
583 ));
584 }
585
586 out.push_str(&format!(
587 "\n<sub>Generated by [cargo-impact v{}](https://github.com/asmuelle/cargo-impact) · [full report](#) · [raw JSON](#)</sub>\n",
588 env!("CARGO_PKG_VERSION")
589 ));
590
591 out
592}
593
594fn pr_comment_location(f: &Finding) -> String {
595 match f.primary_path() {
596 Some(p) => match finding_line(f) {
597 Some(line) => format!("`{}:{line}`", p.display()),
598 None => format!("`{}`", p.display()),
599 },
600 None => "—".to_string(),
601 }
602}
603
604fn escape_pipe(s: &str) -> String {
605 s.replace('|', "\\|").replace('\n', " ")
608}
609
610fn group_by_severity(findings: &[Finding]) -> BTreeMap<SeverityClass, Vec<&Finding>> {
611 let mut out: BTreeMap<SeverityClass, Vec<&Finding>> = BTreeMap::new();
612 for f in findings {
613 out.entry(f.severity).or_default().push(f);
614 }
615 out
616}
617
618fn finding_summary(f: &Finding) -> String {
620 match &f.kind {
621 FindingKind::TestReference {
622 test,
623 matched_symbols,
624 } => format!(
625 "test `{}` ({}) references {}",
626 test.symbol,
627 test.file.display(),
628 matched_symbols.join(", ")
629 ),
630 FindingKind::TraitImpl {
631 trait_name,
632 impl_for,
633 impl_site,
634 } => format!(
635 "impl `{trait_name}` for `{impl_for}` ({})",
636 impl_site.file.display()
637 ),
638 FindingKind::DerivedTraitImpl {
639 trait_name,
640 impl_for,
641 derive_site,
642 } => format!(
643 "`#[derive({trait_name})]` on `{impl_for}` ({})",
644 derive_site.file.display()
645 ),
646 FindingKind::DynDispatch { trait_name, site } => {
647 format!("`dyn {trait_name}` used in {}", site.file.display())
648 }
649 FindingKind::DocDriftLink { symbol, doc, line } => format!(
650 "intra-doc link to `{symbol}` in {}:{line}",
651 doc.file.display()
652 ),
653 FindingKind::DocDriftKeyword { symbol, doc, line } => {
654 format!("`{symbol}` mentioned in {}:{line}", doc.file.display())
655 }
656 FindingKind::FfiSignatureChange {
657 symbol,
658 file,
659 change,
660 } => format!("FFI `{symbol}` {change} in {}", file.display()),
661 FindingKind::BuildScriptChanged { file } => {
662 format!("`build.rs` changed ({})", file.display())
663 }
664 FindingKind::SemverCheck { level, .. } => {
665 format!("cargo-semver-checks reports `{level}` public-API change")
666 }
667 FindingKind::ResolvedReference {
668 source_symbol,
669 target,
670 } => format!(
671 "resolved reference: `{source_symbol}` used by `{}` in {}",
672 target.symbol,
673 target.file.display()
674 ),
675 FindingKind::RuntimeSurface {
676 framework,
677 identifier,
678 site,
679 } => format!(
680 "{framework} runtime surface `{identifier}` ({})",
681 site.file.display()
682 ),
683 FindingKind::TraitDefinitionChange {
684 trait_name,
685 method,
686 change,
687 file,
688 } => match method {
689 Some(m) => format!(
690 "trait `{trait_name}`: {} — `{m}` ({})",
691 change.phrase(),
692 file.display()
693 ),
694 None => format!(
695 "trait `{trait_name}`: {} ({})",
696 change.phrase(),
697 file.display()
698 ),
699 },
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::finding::{Finding, FindingKind, Location, Tier};
707 use std::path::PathBuf;
708
709 fn sample_finding(id: &str) -> Finding {
710 let kind = FindingKind::TestReference {
711 test: Location {
712 file: PathBuf::from("tests/smoke.rs"),
713 symbol: "smoke".into(),
714 },
715 matched_symbols: vec!["login".into()],
716 };
717 Finding::new(id, Tier::Likely, 0.85, kind, "direct ref")
718 }
719
720 #[test]
721 fn json_envelope_has_documented_fields() {
722 let findings = vec![sample_finding("f-0001")];
723 let out =
724 render_json(&[PathBuf::from("src/lib.rs")], &["login".into()], &findings).unwrap();
725 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
726 assert!(v["version"].is_string());
727 assert_eq!(v["changed_files"].as_array().unwrap().len(), 1);
728 assert_eq!(v["findings"].as_array().unwrap().len(), 1);
729 assert_eq!(v["summary"]["total"], 1);
730 assert_eq!(v["findings"][0]["kind"], "test_reference");
731 }
732
733 #[test]
734 fn markdown_renders_severity_sections_and_checklist() {
735 let findings = vec![sample_finding("f-0001")];
736 let md = render_markdown(
737 &[PathBuf::from("src/lib.rs")],
738 &["login".into()],
739 &findings,
740 0,
741 );
742 assert!(md.contains("# cargo-impact"));
743 assert!(md.contains("🟡 MEDIUM"));
744 assert!(md.contains("## Verification checklist"));
745 assert!(md.contains("- [ ]"));
746 assert!(md.contains("f-0001"));
747 }
748
749 #[test]
750 fn markdown_budget_zero_matches_unlimited_behavior() {
751 let findings = vec![
752 sample_finding("a"),
753 sample_finding("b"),
754 sample_finding("c"),
755 ];
756 let changed = [PathBuf::from("src/lib.rs")];
757 let symbols = ["login".into()];
758 let unlimited = render_markdown(&changed, &symbols, &findings, 0);
759 let also_unlimited = render_markdown(&changed, &symbols, &findings, usize::MAX);
760 assert_eq!(unlimited, also_unlimited);
761 assert!(!unlimited.contains("Budget truncation"));
762 }
763
764 #[test]
765 fn markdown_budget_truncates_and_emits_footer_with_accurate_counts() {
766 let findings: Vec<Finding> = (0..20)
772 .map(|i| sample_finding(&format!("f-{i:02}")))
773 .collect();
774 let md = render_markdown(
775 &[PathBuf::from("src/lib.rs")],
776 &["login".into()],
777 &findings,
778 1200,
779 );
780
781 assert!(md.contains("# cargo-impact"));
784 assert!(md.contains("## Summary"));
785 assert!(md.contains("Budget truncation"));
787 assert!(md.contains("--budget=1200"));
788 assert!(
790 md.len() <= 1200 + 600, "rendered {} chars for budget 1200 — should stay close",
792 md.len()
793 );
794 }
795
796 #[test]
797 fn markdown_budget_preserves_highest_priority_findings() {
798 let mk = |id: &str, sev: crate::finding::SeverityClass| {
802 let mut f = sample_finding(id);
803 f.severity = sev;
804 f
805 };
806 use crate::finding::SeverityClass as S;
807 let findings = vec![
808 mk("high-1", S::High),
809 mk("high-2", S::High),
810 mk("med-1", S::Medium),
811 mk("med-2", S::Medium),
812 mk("low-1", S::Low),
813 ];
814 let md = render_markdown(
815 &[PathBuf::from("src/lib.rs")],
816 &["login".into()],
817 &findings,
818 900,
819 );
820 assert!(
821 md.contains("high-1"),
822 "highest-priority finding must survive truncation"
823 );
824 assert!(md.contains("Budget truncation"));
826 }
827
828 #[test]
829 fn markdown_budget_header_always_emitted_even_if_it_alone_exceeds() {
830 let findings = vec![sample_finding("f-0001")];
834 let md = render_markdown(
835 &[PathBuf::from("src/lib.rs")],
836 &["login".into()],
837 &findings,
838 10,
839 );
840 assert!(md.contains("# cargo-impact"));
841 assert!(md.contains("## Summary"));
842 assert!(md.contains("Budget truncation"));
844 }
845
846 #[test]
847 fn text_renders_all_severity_buckets_even_when_empty() {
848 let text = render_text(
849 &[PathBuf::from("src/lib.rs")],
850 &["login".into()],
851 &[sample_finding("f-0001")],
852 );
853 assert!(text.contains("HIGH (0)"));
856 assert!(text.contains("MEDIUM (1)"));
857 assert!(text.contains("LOW (0)"));
858 assert!(text.contains("UNKNOWN (0)"));
859 }
860
861 #[test]
862 fn empty_findings_produce_valid_json() {
863 let out = render_json(&[], &[], &[]).unwrap();
864 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
865 assert_eq!(v["summary"]["total"], 0);
866 assert_eq!(v["findings"].as_array().unwrap().len(), 0);
867 }
868
869 #[test]
872 fn sarif_emits_stable_envelope_and_schema() {
873 let findings = vec![sample_finding("f-0001")];
874 let out = render_sarif(&findings).unwrap();
875 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
876
877 assert_eq!(v["version"], "2.1.0");
878 assert_eq!(
879 v["$schema"],
880 "https://json.schemastore.org/sarif-2.1.0.json"
881 );
882 let driver = &v["runs"][0]["tool"]["driver"];
883 assert_eq!(driver["name"], "cargo-impact");
884 assert!(driver["rules"].as_array().unwrap().len() >= 12);
885
886 let result = &v["runs"][0]["results"][0];
887 assert_eq!(result["ruleId"], "test_reference");
888 assert_eq!(result["message"]["text"], "direct ref");
889 assert_eq!(
890 result["partialFingerprints"]["primaryLocationLineHash"],
891 "f-0001"
892 );
893 }
894
895 #[test]
896 fn sarif_maps_severity_to_sarif_level() {
897 assert_eq!(sarif_level(SeverityClass::High), "error");
898 assert_eq!(sarif_level(SeverityClass::Medium), "warning");
899 assert_eq!(sarif_level(SeverityClass::Low), "note");
900 assert_eq!(sarif_level(SeverityClass::Unknown), "none");
901 }
902
903 #[test]
904 fn sarif_rule_names_use_camel_case() {
905 assert_eq!(sarif_rule_name("test_reference"), "testReference");
906 assert_eq!(
907 sarif_rule_name("ffi_signature_change"),
908 "ffiSignatureChange"
909 );
910 assert_eq!(sarif_rule_name("trait_impl"), "traitImpl");
911 }
912
913 #[test]
914 fn sarif_rules_cover_every_finding_kind() {
915 let rules_tags: std::collections::BTreeSet<String> = FindingKind::all_tags()
918 .iter()
919 .map(|s| (*s).to_string())
920 .collect();
921 assert_eq!(rules_tags.len(), 12);
922 for tag in FindingKind::all_tags() {
923 assert!(
924 !sarif_rule_short(tag).is_empty(),
925 "missing rule description for {tag}"
926 );
927 }
928 }
929
930 #[test]
931 fn sarif_includes_region_only_when_line_available() {
932 let drift = FindingKind::DocDriftLink {
934 symbol: "Foo".into(),
935 doc: crate::finding::Location {
936 file: PathBuf::from("docs/arch.md"),
937 symbol: "Foo".into(),
938 },
939 line: 42,
940 };
941 let f = Finding::new("f-x", Tier::Likely, 0.9, drift, "intra-doc link");
942 let out = render_sarif(&[f]).unwrap();
943 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
944 assert_eq!(
945 v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"],
946 42
947 );
948
949 let test_ref = sample_finding("f-y");
951 let out2 = render_sarif(&[test_ref]).unwrap();
952 let v2: serde_json::Value = serde_json::from_str(&out2).unwrap();
953 assert!(
954 v2["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"].is_null(),
955 "region should be absent when no line is known"
956 );
957 }
958
959 #[test]
960 fn sarif_handles_empty_findings() {
961 let out = render_sarif(&[]).unwrap();
962 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
963 assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
964 assert!(
966 v["runs"][0]["tool"]["driver"]["rules"]
967 .as_array()
968 .unwrap()
969 .len()
970 >= 12
971 );
972 }
973
974 #[test]
977 fn pr_comment_shape_is_stable() {
978 let findings = vec![sample_finding("f-0001")];
979 let out = render_pr_comment(
980 &[PathBuf::from("src/lib.rs")],
981 &["login".into()],
982 &findings,
983 0,
984 );
985 assert!(out.starts_with("### 🎯 cargo-impact — "));
987 assert!(
989 out.contains("<details open><summary>🔴 HIGH")
990 || out.contains("<details><summary>🟡 MEDIUM")
991 );
992 assert!(out.contains("📁 1 changed file · 1 candidate symbol"));
994 assert!(out.contains("cargo-impact v"));
996 }
997
998 #[test]
999 fn pr_comment_expands_high_by_default_collapses_others() {
1000 let mk = |id: &str, sev: SeverityClass| {
1001 let mut f = sample_finding(id);
1002 f.severity = sev;
1003 f
1004 };
1005 let findings = vec![
1006 mk("high-1", SeverityClass::High),
1007 mk("med-1", SeverityClass::Medium),
1008 mk("low-1", SeverityClass::Low),
1009 ];
1010 let out = render_pr_comment(
1011 &[PathBuf::from("src/lib.rs")],
1012 &["login".into()],
1013 &findings,
1014 0,
1015 );
1016 assert!(out.contains("<details open><summary>🔴 HIGH (1)"));
1017 assert!(out.contains("<details><summary>🟡 MEDIUM (1)"));
1018 assert!(out.contains("<details><summary>🔵 LOW (1)"));
1019 }
1020
1021 #[test]
1022 fn pr_comment_empty_findings_still_emits_header() {
1023 let out = render_pr_comment(&[PathBuf::from("src/lib.rs")], &[], &[], 0);
1024 assert!(out.contains("0 findings"));
1025 assert!(out.contains("_No findings — nothing to verify._"));
1026 }
1027
1028 #[test]
1029 fn pr_comment_escapes_pipes_in_evidence() {
1030 let kind = FindingKind::TestReference {
1031 test: crate::finding::Location {
1032 file: PathBuf::from("tests/t.rs"),
1033 symbol: "t".into(),
1034 },
1035 matched_symbols: vec!["f".into()],
1036 };
1037 let f = Finding::new(
1038 "f-pipe",
1039 Tier::Likely,
1040 0.8,
1041 kind,
1042 "evidence with | pipe char",
1043 );
1044 let out = render_pr_comment(&[], &[], &[f], 0);
1045 assert!(out.contains("with \\| pipe"));
1046 }
1047
1048 #[test]
1049 fn pr_comment_budget_truncates_with_notice() {
1050 let findings: Vec<Finding> = (0..30)
1051 .map(|i| sample_finding(&format!("f-{i:02}")))
1052 .collect();
1053 let out = render_pr_comment(
1054 &[PathBuf::from("src/lib.rs")],
1055 &["login".into()],
1056 &findings,
1057 1000,
1058 );
1059 assert!(out.contains("findings omitted"));
1060 assert!(out.contains("--budget=1000"));
1061 }
1062}