1use covguard_types::{CODE_REGISTRY, CodeInfo, Report, Severity, VerdictStatus};
21use serde::Serialize;
22
23pub const DEFAULT_MAX_LINES: usize = 10;
25
26pub const DEFAULT_MAX_ANNOTATIONS: usize = 25;
28
29pub fn status_emoji(status: &VerdictStatus) -> &'static str {
41 match status {
42 VerdictStatus::Pass => "\u{2705}",
43 VerdictStatus::Warn => "\u{26A0}\u{FE0F}",
44 VerdictStatus::Fail => "\u{274C}",
45 VerdictStatus::Skip => "\u{23ED}\u{FE0F}",
46 }
47}
48
49fn status_label(status: &VerdictStatus) -> &'static str {
51 match status {
52 VerdictStatus::Pass => "pass",
53 VerdictStatus::Warn => "warn",
54 VerdictStatus::Fail => "fail",
55 VerdictStatus::Skip => "skip",
56 }
57}
58
59pub fn render_markdown(report: &Report, max_lines: usize) -> String {
101 let mut output = String::new();
102
103 output.push_str("## covguard: Diff Coverage Report\n\n");
105
106 let emoji = status_emoji(&report.verdict.status);
108 let label = status_label(&report.verdict.status);
109 output.push_str(&format!("**Status**: {} {}\n\n", emoji, label));
110
111 output.push_str("### Summary\n");
113 output.push_str(&format!(
114 "- **Diff coverage**: {:.1}%\n",
115 report.data.diff_coverage_pct
116 ));
117 output.push_str(&format!(
118 "- **Changed lines**: {}\n",
119 report.data.changed_lines_total
120 ));
121 output.push_str(&format!("- **Covered**: {}\n", report.data.covered_lines));
122 output.push_str(&format!(
123 "- **Uncovered**: {}\n",
124 report.data.uncovered_lines
125 ));
126
127 let uncovered_findings: Vec<_> = report
129 .findings
130 .iter()
131 .filter(|f| f.location.is_some())
132 .collect();
133
134 if !uncovered_findings.is_empty() {
135 output.push_str("\n### Uncovered Lines\n\n");
136 output.push_str("| File | Line | Hits |\n");
137 output.push_str("|------|------|------|\n");
138
139 let total_findings = uncovered_findings.len();
140 let shown = total_findings.min(max_lines);
141
142 for finding in uncovered_findings.iter().take(max_lines) {
143 let location = finding
144 .location
145 .as_ref()
146 .expect("filtered to only findings with locations");
147 let line_str = location
148 .line
149 .map(|l| l.to_string())
150 .unwrap_or_else(|| "-".to_string());
151
152 let hits = finding
154 .data
155 .as_ref()
156 .and_then(|d| d.get("hits"))
157 .and_then(|v| v.as_u64())
158 .unwrap_or(0);
159
160 output.push_str(&format!(
161 "| {} | {} | {} |\n",
162 location.path, line_str, hits
163 ));
164 }
165
166 if total_findings > max_lines {
167 output.push('\n');
168 output.push_str(&format!(
169 "*Showing {} of {} uncovered lines*\n",
170 shown, total_findings
171 ));
172 }
173 }
174
175 output.push_str("\n<details>\n");
177 output.push_str("<summary>Reproduce locally</summary>\n\n");
178 output.push_str("```bash\n");
179
180 let inputs = &report.data.inputs;
182 let mut cmd_parts = vec!["covguard check".to_string()];
183
184 if let Some(diff_file) = &inputs.diff_file {
185 cmd_parts.push(format!("--diff-file {}", diff_file));
186 } else if inputs.base.is_some() || inputs.head.is_some() {
187 if let Some(base) = &inputs.base {
188 cmd_parts.push(format!("--base {}", base));
189 }
190 if let Some(head) = &inputs.head {
191 cmd_parts.push(format!("--head {}", head));
192 }
193 } else {
194 cmd_parts.push("--diff-file <file>".to_string());
195 }
196
197 if inputs.lcov_paths.is_empty() {
198 cmd_parts.push("--lcov <lcov>".to_string());
199 } else {
200 for lcov_path in &inputs.lcov_paths {
201 cmd_parts.push(format!("--lcov {}", lcov_path));
202 }
203 }
204
205 output.push_str(&cmd_parts.join(" \\\n "));
206 output.push_str("\n```\n\n");
207 output.push_str("</details>\n");
208
209 output
210}
211
212pub fn render_annotations(report: &Report, max_annotations: usize) -> String {
230 let mut output = String::new();
231
232 for finding in report.findings.iter().take(max_annotations) {
233 if let Some(location) = &finding.location {
234 let level = match finding.severity {
235 Severity::Error => "error",
236 Severity::Warn => "warning",
237 Severity::Info => "notice",
238 };
239
240 let mut params = vec![format!("file={}", location.path)];
242
243 if let Some(line) = location.line {
244 params.push(format!("line={}", line));
245 }
246
247 if let Some(col) = location.col {
248 params.push(format!("col={}", col));
249 }
250
251 let hits = finding
253 .data
254 .as_ref()
255 .and_then(|d| d.get("hits"))
256 .and_then(|v| v.as_u64())
257 .unwrap_or(0);
258
259 let message = format!("Uncovered changed line (hits={})", hits);
260
261 output.push_str(&format!("::{} {}::{}\n", level, params.join(","), message));
262 }
263 }
264
265 output
266}
267
268pub const DEFAULT_MAX_SARIF_RESULTS: usize = 1000;
274
275#[derive(Debug, Clone, Serialize)]
277pub struct SarifReport {
278 #[serde(rename = "$schema")]
279 pub schema: String,
280 pub version: String,
281 pub runs: Vec<SarifRun>,
282}
283
284#[derive(Debug, Clone, Serialize)]
286pub struct SarifRun {
287 pub tool: SarifTool,
288 pub results: Vec<SarifResult>,
289}
290
291#[derive(Debug, Clone, Serialize)]
293pub struct SarifTool {
294 pub driver: SarifDriver,
295}
296
297#[derive(Debug, Clone, Serialize)]
299pub struct SarifDriver {
300 pub name: String,
301 pub version: String,
302 #[serde(rename = "informationUri")]
303 pub information_uri: String,
304 pub rules: Vec<SarifRule>,
305}
306
307#[derive(Debug, Clone, Serialize)]
309pub struct SarifRule {
310 pub id: String,
311 pub name: String,
312 #[serde(rename = "shortDescription")]
313 pub short_description: SarifMessage,
314 #[serde(rename = "fullDescription")]
315 pub full_description: SarifMessage,
316 #[serde(rename = "helpUri")]
317 pub help_uri: String,
318 #[serde(rename = "defaultConfiguration")]
319 pub default_configuration: SarifRuleConfiguration,
320}
321
322#[derive(Debug, Clone, Serialize)]
324pub struct SarifRuleConfiguration {
325 pub level: String,
326}
327
328#[derive(Debug, Clone, Serialize)]
330pub struct SarifMessage {
331 pub text: String,
332}
333
334#[derive(Debug, Clone, Serialize)]
336pub struct SarifResult {
337 #[serde(rename = "ruleId")]
338 pub rule_id: String,
339 #[serde(rename = "ruleIndex")]
340 pub rule_index: usize,
341 pub level: String,
342 pub message: SarifMessage,
343 pub locations: Vec<SarifLocation>,
344}
345
346#[derive(Debug, Clone, Serialize)]
348pub struct SarifLocation {
349 #[serde(rename = "physicalLocation")]
350 pub physical_location: SarifPhysicalLocation,
351}
352
353#[derive(Debug, Clone, Serialize)]
355pub struct SarifPhysicalLocation {
356 #[serde(rename = "artifactLocation")]
357 pub artifact_location: SarifArtifactLocation,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub region: Option<SarifRegion>,
360}
361
362#[derive(Debug, Clone, Serialize)]
364pub struct SarifArtifactLocation {
365 pub uri: String,
366 #[serde(rename = "uriBaseId")]
367 pub uri_base_id: String,
368}
369
370#[derive(Debug, Clone, Serialize)]
372pub struct SarifRegion {
373 #[serde(rename = "startLine")]
374 pub start_line: u32,
375 #[serde(rename = "startColumn", skip_serializing_if = "Option::is_none")]
376 pub start_column: Option<u32>,
377}
378
379pub fn render_sarif(report: &Report, max_results: usize) -> String {
394 let sarif = build_sarif_report(report, max_results);
395 serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| "{}".to_string())
396}
397
398fn build_sarif_report(report: &Report, max_results: usize) -> SarifReport {
400 let rules: Vec<SarifRule> = CODE_REGISTRY.iter().map(codeinfo_to_rule).collect();
401
402 let rule_index_map: std::collections::HashMap<&str, usize> = rules
404 .iter()
405 .enumerate()
406 .map(|(i, r)| (r.id.as_str(), i))
407 .collect();
408
409 let results: Vec<SarifResult> = report
411 .findings
412 .iter()
413 .take(max_results)
414 .filter_map(|finding| {
415 let rule_index = rule_index_map.get(finding.code.as_str()).copied()?;
416
417 let level = match finding.severity {
418 Severity::Error => "error",
419 Severity::Warn => "warning",
420 Severity::Info => "note",
421 };
422
423 let locations = if let Some(ref loc) = finding.location {
424 vec![SarifLocation {
425 physical_location: SarifPhysicalLocation {
426 artifact_location: SarifArtifactLocation {
427 uri: loc.path.clone(),
428 uri_base_id: "%SRCROOT%".to_string(),
429 },
430 region: loc.line.map(|line| SarifRegion {
431 start_line: line,
432 start_column: loc.col,
433 }),
434 },
435 }]
436 } else {
437 vec![]
438 };
439
440 Some(SarifResult {
441 rule_id: finding.code.clone(),
442 rule_index,
443 level: level.to_string(),
444 message: SarifMessage {
445 text: finding.message.clone(),
446 },
447 locations,
448 })
449 })
450 .collect();
451
452 SarifReport {
453 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
454 version: "2.1.0".to_string(),
455 runs: vec![SarifRun {
456 tool: SarifTool {
457 driver: SarifDriver {
458 name: "covguard".to_string(),
459 version: report.tool.version.clone(),
460 information_uri: "https://github.com/covguard/covguard".to_string(),
461 rules,
462 },
463 },
464 results,
465 }],
466 }
467}
468
469fn codeinfo_to_rule(info: &CodeInfo) -> SarifRule {
470 SarifRule {
471 id: info.code.to_string(),
472 name: info.name.to_string(),
473 short_description: SarifMessage {
474 text: info.short_description.to_string(),
475 },
476 full_description: SarifMessage {
477 text: info.full_description.to_string(),
478 },
479 help_uri: info.help_uri.to_string(),
480 default_configuration: SarifRuleConfiguration {
481 level: "error".to_string(),
482 },
483 }
484}
485
486#[cfg(test)]
491mod tests {
492 use super::*;
493 use covguard_types::{
494 Finding, Inputs, ReportData, Run, Severity, Tool, Verdict, VerdictCounts, VerdictStatus,
495 };
496
497 fn make_test_report(
498 status: VerdictStatus,
499 findings: Vec<Finding>,
500 covered: u32,
501 uncovered: u32,
502 ) -> Report {
503 Report {
504 schema: "covguard.report.v1".to_string(),
505 tool: Tool {
506 name: "covguard".to_string(),
507 version: "0.1.0".to_string(),
508 commit: None,
509 },
510 run: Run {
511 started_at: "2026-02-02T00:00:00Z".to_string(),
512 ended_at: None,
513 duration_ms: None,
514 capabilities: None,
515 },
516 verdict: Verdict {
517 status,
518 counts: VerdictCounts {
519 info: 0,
520 warn: findings
521 .iter()
522 .filter(|f| f.severity == Severity::Warn)
523 .count() as u32,
524 error: findings
525 .iter()
526 .filter(|f| f.severity == Severity::Error)
527 .count() as u32,
528 },
529 reasons: if uncovered > 0 {
530 vec!["uncovered_lines".to_string()]
531 } else {
532 Vec::new()
533 },
534 },
535 findings,
536 data: ReportData {
537 scope: "added".to_string(),
538 threshold_pct: 80.0,
539 changed_lines_total: covered + uncovered,
540 covered_lines: covered,
541 uncovered_lines: uncovered,
542 missing_lines: 0,
543 ignored_lines_count: 0,
544 excluded_files_count: 0,
545 diff_coverage_pct: if covered + uncovered > 0 {
546 (covered as f64 / (covered + uncovered) as f64) * 100.0
547 } else {
548 100.0
549 },
550 inputs: Inputs {
551 diff_source: "diff-file".to_string(),
552 diff_file: Some("fixtures/diff/simple_added.patch".to_string()),
553 base: None,
554 head: None,
555 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
556 },
557 debug: None,
558 truncation: None,
559 },
560 }
561 }
562
563 #[test]
568 fn test_status_emoji_pass() {
569 assert_eq!(status_emoji(&VerdictStatus::Pass), "\u{2705}");
570 }
571
572 #[test]
573 fn test_status_emoji_warn() {
574 assert_eq!(status_emoji(&VerdictStatus::Warn), "\u{26A0}\u{FE0F}");
575 }
576
577 #[test]
578 fn test_status_emoji_fail() {
579 assert_eq!(status_emoji(&VerdictStatus::Fail), "\u{274C}");
580 }
581
582 #[test]
583 fn test_status_emoji_skip() {
584 assert_eq!(status_emoji(&VerdictStatus::Skip), "\u{23ED}\u{FE0F}");
585 }
586
587 #[test]
592 fn test_markdown_header_present() {
593 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
594 let md = render_markdown(&report, 10);
595 assert!(md.contains("## covguard: Diff Coverage Report"));
596 }
597
598 #[test]
599 fn test_markdown_status_pass() {
600 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
601 let md = render_markdown(&report, 10);
602 assert!(md.contains("**Status**: \u{2705} pass"));
603 }
604
605 #[test]
606 fn test_markdown_status_fail() {
607 let report = make_test_report(VerdictStatus::Fail, vec![], 0, 5);
608 let md = render_markdown(&report, 10);
609 assert!(md.contains("**Status**: \u{274C} fail"));
610 }
611
612 #[test]
613 fn test_markdown_status_warn() {
614 let report = make_test_report(VerdictStatus::Warn, vec![], 3, 2);
615 let md = render_markdown(&report, 10);
616 assert!(md.contains("**Status**: \u{26A0}\u{FE0F} warn"));
617 }
618
619 #[test]
620 fn test_markdown_status_skip() {
621 let report = make_test_report(VerdictStatus::Skip, vec![], 0, 0);
622 let md = render_markdown(&report, 10);
623 assert!(md.contains("**Status**: \u{23ED}\u{FE0F} skip"));
624 }
625
626 #[test]
627 fn test_markdown_summary_section() {
628 let report = make_test_report(VerdictStatus::Fail, vec![], 7, 3);
629 let md = render_markdown(&report, 10);
630
631 assert!(md.contains("### Summary"));
632 assert!(md.contains("- **Diff coverage**: 70.0%"));
633 assert!(md.contains("- **Changed lines**: 10"));
634 assert!(md.contains("- **Covered**: 7"));
635 assert!(md.contains("- **Uncovered**: 3"));
636 }
637
638 #[test]
639 fn test_markdown_uncovered_lines_table() {
640 let findings = vec![
641 Finding::uncovered_line("src/lib.rs", 1, 0),
642 Finding::uncovered_line("src/lib.rs", 2, 0),
643 ];
644 let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
645 let md = render_markdown(&report, 10);
646
647 assert!(md.contains("### Uncovered Lines"));
648 assert!(md.contains("| File | Line | Hits |"));
649 assert!(md.contains("| src/lib.rs | 1 | 0 |"));
650 assert!(md.contains("| src/lib.rs | 2 | 0 |"));
651 }
652
653 #[test]
654 fn test_markdown_no_uncovered_lines_table_when_empty() {
655 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
656 let md = render_markdown(&report, 10);
657
658 assert!(!md.contains("### Uncovered Lines"));
660 assert!(!md.contains("| File | Line | Hits |"));
661 }
662
663 #[test]
664 fn test_markdown_truncation() {
665 let findings: Vec<_> = (1..=15)
666 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
667 .collect();
668 let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
669 let md = render_markdown(&report, 10);
670
671 assert!(md.contains("| src/lib.rs | 10 | 0 |"));
673 assert!(!md.contains("| src/lib.rs | 11 | 0 |"));
674
675 assert!(md.contains("*Showing 10 of 15 uncovered lines*"));
677 }
678
679 #[test]
680 fn test_markdown_no_truncation_message_when_under_limit() {
681 let findings = vec![
682 Finding::uncovered_line("src/lib.rs", 1, 0),
683 Finding::uncovered_line("src/lib.rs", 2, 0),
684 ];
685 let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
686 let md = render_markdown(&report, 10);
687
688 assert!(!md.contains("*Showing"));
690 }
691
692 #[test]
693 fn test_markdown_reproduce_section_with_diff_file() {
694 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
695 let md = render_markdown(&report, 10);
696
697 assert!(md.contains("<details>"));
698 assert!(md.contains("<summary>Reproduce locally</summary>"));
699 assert!(md.contains("covguard check"));
700 assert!(md.contains("--diff-file fixtures/diff/simple_added.patch"));
701 assert!(md.contains("--lcov fixtures/lcov/uncovered.info"));
702 assert!(md.contains("</details>"));
703 }
704
705 #[test]
706 fn test_markdown_reproduce_section_with_git_refs() {
707 let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
708 report.data.inputs.diff_file = None;
709 report.data.inputs.diff_source = "git-refs".to_string();
710 report.data.inputs.base = Some("abc123".to_string());
711 report.data.inputs.head = Some("def456".to_string());
712
713 let md = render_markdown(&report, 10);
714
715 assert!(md.contains("--base abc123"));
716 assert!(md.contains("--head def456"));
717 }
718
719 #[test]
720 fn test_markdown_reproduce_section_placeholder_when_no_inputs() {
721 let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
722 report.data.inputs.diff_file = None;
723 report.data.inputs.lcov_paths = vec![];
724
725 let md = render_markdown(&report, 10);
726
727 assert!(md.contains("--diff-file <file>"));
728 assert!(md.contains("--lcov <lcov>"));
729 }
730
731 #[test]
732 fn test_markdown_output_format() {
733 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
734 let report = make_test_report(VerdictStatus::Fail, findings, 2, 1);
735 let md = render_markdown(&report, 10);
736
737 let expected_sections = [
739 "## covguard: Diff Coverage Report",
740 "**Status**:",
741 "### Summary",
742 "### Uncovered Lines",
743 "<details>",
744 "</details>",
745 ];
746
747 for section in expected_sections {
748 assert!(md.contains(section));
749 }
750 }
751
752 #[test]
757 fn test_annotations_error_level() {
758 let findings = vec![Finding::uncovered_line("src/lib.rs", 42, 0)];
759 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
760 let annotations = render_annotations(&report, 25);
761
762 assert!(
763 annotations
764 .contains("::error file=src/lib.rs,line=42::Uncovered changed line (hits=0)")
765 );
766 }
767
768 #[test]
769 fn test_annotations_warning_level() {
770 let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
771 finding.severity = Severity::Warn;
772
773 let report = make_test_report(VerdictStatus::Warn, vec![finding], 0, 1);
774 let annotations = render_annotations(&report, 25);
775
776 assert!(annotations.contains("::warning file=src/lib.rs,line=42::"));
777 }
778
779 #[test]
780 fn test_annotations_info_level() {
781 let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
782 finding.severity = Severity::Info;
783
784 let report = make_test_report(VerdictStatus::Pass, vec![finding], 0, 1);
785 let annotations = render_annotations(&report, 25);
786
787 assert!(annotations.contains("::notice file=src/lib.rs,line=42::"));
788 }
789
790 #[test]
791 fn test_annotations_multiple_findings() {
792 let findings = vec![
793 Finding::uncovered_line("src/lib.rs", 1, 0),
794 Finding::uncovered_line("src/lib.rs", 2, 0),
795 Finding::uncovered_line("src/main.rs", 10, 0),
796 ];
797 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
798 let annotations = render_annotations(&report, 25);
799
800 let lines: Vec<_> = annotations.lines().collect();
801 assert_eq!(lines.len(), 3);
802 assert!(lines[0].contains("src/lib.rs,line=1"));
803 assert!(lines[1].contains("src/lib.rs,line=2"));
804 assert!(lines[2].contains("src/main.rs,line=10"));
805 }
806
807 #[test]
808 fn test_annotations_truncation() {
809 let findings: Vec<_> = (1..=30)
810 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
811 .collect();
812 let report = make_test_report(VerdictStatus::Fail, findings, 0, 30);
813 let annotations = render_annotations(&report, 25);
814
815 let lines: Vec<_> = annotations.lines().collect();
816 assert_eq!(lines.len(), 25);
817 assert!(lines.last().unwrap().contains("line=25"));
818 }
819
820 #[test]
821 fn test_annotations_empty_when_no_findings() {
822 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
823 let annotations = render_annotations(&report, 25);
824
825 assert!(annotations.is_empty());
826 }
827
828 #[test]
829 fn test_annotations_skip_findings_without_location() {
830 let finding_without_location = Finding {
831 severity: Severity::Error,
832 check_id: "diff.coverage_below_threshold".to_string(),
833 code: "covguard.diff.coverage_below_threshold".to_string(),
834 message: "Coverage below threshold".to_string(),
835 location: None,
836 data: None,
837 fingerprint: None,
838 };
839
840 let findings = vec![
841 finding_without_location,
842 Finding::uncovered_line("src/lib.rs", 1, 0),
843 ];
844 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
845 let annotations = render_annotations(&report, 25);
846
847 let lines: Vec<_> = annotations.lines().collect();
848 assert_eq!(lines.len(), 1);
850 assert!(lines[0].contains("src/lib.rs,line=1"));
851 }
852
853 #[test]
854 fn test_annotations_with_column() {
855 let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
856 finding.location.as_mut().unwrap().col = Some(10);
857
858 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
859 let annotations = render_annotations(&report, 25);
860
861 assert!(annotations.contains("file=src/lib.rs,line=42,col=10"));
862 }
863
864 #[test]
865 fn test_annotations_format_correctness() {
866 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
867 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
868 let annotations = render_annotations(&report, 25);
869
870 let line = annotations.lines().next().unwrap();
872 assert!(line.starts_with("::error "));
873 assert!(line.contains("file="));
874 assert!(line.contains("line="));
875 assert!(line.contains("::Uncovered changed line"));
876 }
877
878 #[test]
883 fn test_full_report_rendering() {
884 let findings = vec![
885 Finding::uncovered_line("src/lib.rs", 1, 0),
886 Finding::uncovered_line("src/lib.rs", 2, 0),
887 Finding::uncovered_line("src/lib.rs", 3, 0),
888 ];
889
890 let report = Report {
891 schema: "covguard.report.v1".to_string(),
892 tool: Tool {
893 name: "covguard".to_string(),
894 version: "0.1.0".to_string(),
895 commit: None,
896 },
897 run: Run {
898 started_at: "2026-02-02T00:00:00Z".to_string(),
899 ended_at: None,
900 duration_ms: None,
901 capabilities: None,
902 },
903 verdict: Verdict {
904 status: VerdictStatus::Fail,
905 counts: VerdictCounts {
906 info: 0,
907 warn: 0,
908 error: 3,
909 },
910 reasons: vec!["uncovered_lines".to_string()],
911 },
912 findings,
913 data: ReportData {
914 scope: "added".to_string(),
915 threshold_pct: 80.0,
916 changed_lines_total: 3,
917 covered_lines: 0,
918 uncovered_lines: 3,
919 missing_lines: 0,
920 ignored_lines_count: 0,
921 excluded_files_count: 0,
922 diff_coverage_pct: 0.0,
923 inputs: Inputs {
924 diff_source: "diff-file".to_string(),
925 diff_file: Some("fixtures/diff/simple_added.patch".to_string()),
926 base: None,
927 head: None,
928 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
929 },
930 debug: None,
931 truncation: None,
932 },
933 };
934
935 let md = render_markdown(&report, 10);
936 let annotations = render_annotations(&report, 25);
937
938 assert!(md.contains("## covguard: Diff Coverage Report"));
940 assert!(md.contains("**Status**: \u{274C} fail"));
941 assert!(md.contains("- **Diff coverage**: 0.0%"));
942 assert!(md.contains("- **Changed lines**: 3"));
943 assert!(md.contains("- **Uncovered**: 3"));
944 assert!(md.contains("| src/lib.rs | 1 | 0 |"));
945 assert!(md.contains("| src/lib.rs | 2 | 0 |"));
946 assert!(md.contains("| src/lib.rs | 3 | 0 |"));
947
948 let ann_lines: Vec<_> = annotations.lines().collect();
950 assert_eq!(ann_lines.len(), 3);
951 assert!(ann_lines.iter().all(|l| l.starts_with("::error ")));
952 }
953
954 #[test]
955 fn test_default_constants() {
956 assert_eq!(DEFAULT_MAX_LINES, 10);
957 assert_eq!(DEFAULT_MAX_ANNOTATIONS, 25);
958 assert_eq!(DEFAULT_MAX_SARIF_RESULTS, 1000);
959 }
960
961 #[test]
966 fn test_sarif_basic_structure() {
967 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
968 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
969 let sarif = render_sarif(&report, 1000);
970
971 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
973
974 assert_eq!(parsed["version"], "2.1.0");
975 assert!(
976 parsed["$schema"]
977 .as_str()
978 .unwrap()
979 .contains("sarif-schema-2.1.0")
980 );
981 assert_eq!(parsed["runs"].as_array().unwrap().len(), 1);
982 }
983
984 #[test]
985 fn test_sarif_tool_info() {
986 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
987 let sarif = render_sarif(&report, 1000);
988
989 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
990 let driver = &parsed["runs"][0]["tool"]["driver"];
991
992 assert_eq!(driver["name"], "covguard");
993 assert_eq!(driver["version"], "0.1.0");
994 assert!(driver["informationUri"].as_str().is_some());
995 }
996
997 #[test]
998 fn test_sarif_rules() {
999 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1000 let sarif = render_sarif(&report, 1000);
1001
1002 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1003 let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1004 .as_array()
1005 .unwrap();
1006
1007 assert_eq!(rules.len(), CODE_REGISTRY.len());
1009
1010 let rule = &rules[0];
1012 assert_eq!(rule["id"], "covguard.diff.uncovered_line");
1013 assert_eq!(rule["name"], "UncoveredLine");
1014 assert!(rule["shortDescription"]["text"].as_str().is_some());
1015 assert!(rule["helpUri"].as_str().is_some());
1016 }
1017
1018 #[test]
1019 fn test_sarif_results_with_findings() {
1020 let findings = vec![
1021 Finding::uncovered_line("src/lib.rs", 1, 0),
1022 Finding::uncovered_line("src/lib.rs", 2, 0),
1023 Finding::uncovered_line("src/main.rs", 10, 0),
1024 ];
1025 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1026 let sarif = render_sarif(&report, 1000);
1027
1028 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1029 let results = parsed["runs"][0]["results"].as_array().unwrap();
1030
1031 assert_eq!(results.len(), 3);
1032
1033 let result = &results[0];
1035 assert_eq!(result["ruleId"], "covguard.diff.uncovered_line");
1036 assert_eq!(result["level"], "error");
1037 assert!(result["message"]["text"].as_str().is_some());
1038
1039 let loc = &result["locations"][0]["physicalLocation"];
1041 assert_eq!(loc["artifactLocation"]["uri"], "src/lib.rs");
1042 assert_eq!(loc["region"]["startLine"], 1);
1043 }
1044
1045 #[test]
1046 fn test_sarif_empty_when_no_findings() {
1047 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1048 let sarif = render_sarif(&report, 1000);
1049
1050 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1051 let results = parsed["runs"][0]["results"].as_array().unwrap();
1052
1053 assert!(results.is_empty());
1054 }
1055
1056 #[test]
1057 fn test_sarif_truncation() {
1058 let findings: Vec<_> = (1..=15)
1060 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1061 .collect();
1062 let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
1063
1064 let sarif = render_sarif(&report, 10);
1066
1067 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1068 let results = parsed["runs"][0]["results"].as_array().unwrap();
1069
1070 assert_eq!(results.len(), 10);
1072 }
1073
1074 #[test]
1075 fn test_sarif_severity_levels() {
1076 let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1077 error_finding.severity = Severity::Error;
1078
1079 let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1080 warn_finding.severity = Severity::Warn;
1081
1082 let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1083 info_finding.severity = Severity::Info;
1084
1085 let findings = vec![error_finding, warn_finding, info_finding];
1086 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1087 let sarif = render_sarif(&report, 1000);
1088
1089 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1090 let results = parsed["runs"][0]["results"].as_array().unwrap();
1091
1092 assert_eq!(results[0]["level"], "error");
1093 assert_eq!(results[1]["level"], "warning");
1094 assert_eq!(results[2]["level"], "note");
1095 }
1096
1097 #[test]
1098 fn test_sarif_finding_without_location() {
1099 let finding_without_location = Finding {
1100 severity: Severity::Error,
1101 check_id: "diff.coverage_below_threshold".to_string(),
1102 code: "covguard.diff.coverage_below_threshold".to_string(),
1103 message: "Coverage 50% is below threshold 80%".to_string(),
1104 location: None,
1105 data: None,
1106 fingerprint: None,
1107 };
1108
1109 let findings = vec![
1110 finding_without_location,
1111 Finding::uncovered_line("src/lib.rs", 1, 0),
1112 ];
1113 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1114 let sarif = render_sarif(&report, 1000);
1115
1116 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1117 let results = parsed["runs"][0]["results"].as_array().unwrap();
1118
1119 assert_eq!(results.len(), 2);
1121
1122 assert!(results[0]["locations"].as_array().unwrap().is_empty());
1124
1125 assert_eq!(results[1]["locations"].as_array().unwrap().len(), 1);
1127 }
1128
1129 #[test]
1134 fn test_snapshot_markdown_fail() {
1135 let findings = vec![
1136 Finding::uncovered_line("src/lib.rs", 1, 0),
1137 Finding::uncovered_line("src/lib.rs", 2, 0),
1138 Finding::uncovered_line("src/lib.rs", 3, 0),
1139 ];
1140 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1141 let md = render_markdown(&report, 10);
1142 insta::assert_snapshot!("markdown_fail", md);
1143 }
1144
1145 #[test]
1146 fn test_snapshot_markdown_pass() {
1147 let report = make_test_report(VerdictStatus::Pass, vec![], 3, 0);
1148 let md = render_markdown(&report, 10);
1149 insta::assert_snapshot!("markdown_pass", md);
1150 }
1151
1152 #[test]
1153 fn test_snapshot_markdown_warn() {
1154 let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1155 finding.severity = Severity::Warn;
1156 let report = make_test_report(VerdictStatus::Warn, vec![finding], 2, 1);
1157 let md = render_markdown(&report, 10);
1158 insta::assert_snapshot!("markdown_warn", md);
1159 }
1160
1161 #[test]
1162 fn test_snapshot_markdown_truncated() {
1163 let findings: Vec<_> = (1..=15)
1164 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1165 .collect();
1166 let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
1167 let md = render_markdown(&report, 10);
1168 insta::assert_snapshot!("markdown_truncated", md);
1169 }
1170
1171 #[test]
1172 fn test_snapshot_annotations_multiple() {
1173 let findings = vec![
1174 Finding::uncovered_line("src/lib.rs", 1, 0),
1175 Finding::uncovered_line("src/lib.rs", 2, 0),
1176 Finding::uncovered_line("src/main.rs", 10, 0),
1177 ];
1178 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1179 let annotations = render_annotations(&report, 25);
1180 insta::assert_snapshot!("annotations_multiple", annotations);
1181 }
1182
1183 #[test]
1184 fn test_snapshot_annotations_empty() {
1185 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1186 let annotations = render_annotations(&report, 25);
1187 insta::assert_snapshot!("annotations_empty", annotations);
1188 }
1189
1190 #[test]
1191 fn test_snapshot_sarif_with_findings() {
1192 let findings = vec![
1193 Finding::uncovered_line("src/lib.rs", 1, 0),
1194 Finding::uncovered_line("src/lib.rs", 2, 0),
1195 Finding::uncovered_line("src/main.rs", 10, 0),
1196 ];
1197 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1198 let sarif = render_sarif(&report, 1000);
1199 let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1200 insta::assert_json_snapshot!("sarif_with_findings", sarif_value);
1201 }
1202
1203 #[test]
1204 fn test_snapshot_sarif_empty() {
1205 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1206 let sarif = render_sarif(&report, 1000);
1207 let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1208 insta::assert_json_snapshot!("sarif_empty", sarif_value);
1209 }
1210
1211 #[test]
1212 fn test_snapshot_sarif_mixed_severity() {
1213 let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1214 error_finding.severity = Severity::Error;
1215
1216 let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1217 warn_finding.severity = Severity::Warn;
1218
1219 let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1220 info_finding.severity = Severity::Info;
1221
1222 let findings = vec![error_finding, warn_finding, info_finding];
1223 let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1224 let sarif = render_sarif(&report, 1000);
1225 let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1226 insta::assert_json_snapshot!("sarif_mixed_severity", sarif_value);
1227 }
1228
1229 #[test]
1234 fn test_markdown_table_row_count_matches_findings() {
1235 let findings: Vec<_> = (1..=5)
1236 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1237 .collect();
1238 let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1239 let md = render_markdown(&report, 100);
1240
1241 let table_rows: Vec<_> = md.lines().filter(|l| l.starts_with("| src/")).collect();
1243 assert_eq!(table_rows.len(), 5);
1244 }
1245
1246 #[test]
1247 fn test_markdown_coverage_percentage_precision() {
1248 let mut report = make_test_report(VerdictStatus::Fail, vec![], 1, 2);
1249 report.data.diff_coverage_pct = 33.333333333;
1250 let md = render_markdown(&report, 10);
1251
1252 assert!(md.contains("33.3%"));
1254 assert!(!md.contains("33.333333333"));
1255 }
1256
1257 #[test]
1258 fn test_markdown_100_percent_coverage_display() {
1259 let mut report = make_test_report(VerdictStatus::Pass, vec![], 10, 0);
1260 report.data.diff_coverage_pct = 100.0;
1261 let md = render_markdown(&report, 10);
1262
1263 assert!(md.contains("100.0%"));
1264 }
1265
1266 #[test]
1267 fn test_markdown_zero_percent_coverage_display() {
1268 let mut report = make_test_report(VerdictStatus::Fail, vec![], 0, 10);
1269 report.data.diff_coverage_pct = 0.0;
1270 let md = render_markdown(&report, 10);
1271
1272 assert!(md.contains("0.0%"));
1273 }
1274
1275 #[test]
1276 fn test_markdown_table_alignment() {
1277 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1278 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1279 let md = render_markdown(&report, 10);
1280
1281 assert!(md.contains("|------|------|------|"));
1283 }
1284
1285 #[test]
1286 fn test_markdown_hits_value_displayed() {
1287 let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1288 finding.data = Some(serde_json::json!({ "hits": 42 }));
1289 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1290 let md = render_markdown(&report, 10);
1291
1292 assert!(md.contains("| 42 |"));
1294 }
1295
1296 #[test]
1297 fn test_markdown_multiple_lcov_paths_in_reproduce() {
1298 let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1299 report.data.inputs.lcov_paths =
1300 vec!["unit.info".to_string(), "integration.info".to_string()];
1301 let md = render_markdown(&report, 10);
1302
1303 assert!(md.contains("--lcov unit.info"));
1304 assert!(md.contains("--lcov integration.info"));
1305 }
1306
1307 #[test]
1308 fn test_markdown_unicode_in_file_path() {
1309 let finding = Finding::uncovered_line("src/日本語/テスト.rs", 1, 0);
1310 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1311 let md = render_markdown(&report, 10);
1312
1313 assert!(md.contains("src/日本語/テスト.rs"));
1314 }
1315
1316 #[test]
1317 fn test_markdown_special_characters_in_path() {
1318 let finding = Finding::uncovered_line("src/path with spaces/file.rs", 1, 0);
1319 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1320 let md = render_markdown(&report, 10);
1321
1322 assert!(md.contains("path with spaces"));
1323 }
1324
1325 #[test]
1326 fn test_markdown_sections_in_order() {
1327 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1328 let report = make_test_report(VerdictStatus::Fail, findings, 2, 1);
1329 let md = render_markdown(&report, 10);
1330
1331 let header_pos = md.find("## covguard: Diff Coverage Report").unwrap();
1332 let status_pos = md.find("**Status**").unwrap();
1333 let summary_pos = md.find("### Summary").unwrap();
1334 let uncovered_pos = md.find("### Uncovered Lines").unwrap();
1335 let details_pos = md.find("<details>").unwrap();
1336
1337 assert!(header_pos < status_pos);
1338 assert!(status_pos < summary_pos);
1339 assert!(summary_pos < uncovered_pos);
1340 assert!(uncovered_pos < details_pos);
1341 }
1342
1343 #[test]
1348 fn test_annotations_format_github_valid() {
1349 let mut error_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1350 error_finding.severity = Severity::Error;
1351 let mut warn_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1352 warn_finding.severity = Severity::Warn;
1353 let mut info_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1354 info_finding.severity = Severity::Info;
1355
1356 for finding in [error_finding, warn_finding, info_finding] {
1357 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1358 let annotations = render_annotations(&report, 25);
1359
1360 let line = annotations.lines().next().unwrap();
1362
1363 assert!(line.starts_with("::"));
1365
1366 let has_valid_prefix = line.starts_with("::error ")
1368 || line.starts_with("::warning ")
1369 || line.starts_with("::notice ");
1370 assert!(has_valid_prefix);
1371
1372 assert!(line.contains("file="));
1374
1375 let parts: Vec<_> = line.split("::").collect();
1377 assert!(parts.len() >= 3);
1378 }
1379 }
1380
1381 #[test]
1382 fn test_annotations_each_on_separate_line() {
1383 let findings: Vec<_> = (1..=5)
1384 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1385 .collect();
1386 let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1387 let annotations = render_annotations(&report, 25);
1388
1389 let lines: Vec<_> = annotations.lines().collect();
1390 assert_eq!(lines.len(), 5);
1391
1392 for (i, line) in lines.iter().enumerate() {
1393 assert!(line.contains(&format!("line={}", i + 1)));
1394 }
1395 }
1396
1397 #[test]
1398 fn test_annotations_severity_mapping() {
1399 let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1400 error_finding.severity = Severity::Error;
1401
1402 let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1403 warn_finding.severity = Severity::Warn;
1404
1405 let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1406 info_finding.severity = Severity::Info;
1407
1408 let report = make_test_report(
1409 VerdictStatus::Fail,
1410 vec![error_finding, warn_finding, info_finding],
1411 0,
1412 3,
1413 );
1414 let annotations = render_annotations(&report, 25);
1415 let lines: Vec<_> = annotations.lines().collect();
1416
1417 assert!(lines[0].starts_with("::error "));
1418 assert!(lines[1].starts_with("::warning "));
1419 assert!(lines[2].starts_with("::notice "));
1420 }
1421
1422 #[test]
1423 fn test_annotations_no_trailing_whitespace() {
1424 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1425 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1426 let annotations = render_annotations(&report, 25);
1427
1428 for line in annotations.lines() {
1429 assert!(!line.ends_with(' '));
1430 }
1431 }
1432
1433 #[test]
1438 fn test_sarif_valid_json() {
1439 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1440 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1441 let sarif = render_sarif(&report, 1000);
1442
1443 let result: Result<serde_json::Value, _> = serde_json::from_str(&sarif);
1445 assert!(result.is_ok(), "SARIF output must be valid JSON");
1446 }
1447
1448 #[test]
1449 fn test_sarif_version_is_2_1_0() {
1450 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1451 let sarif = render_sarif(&report, 1000);
1452 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1453
1454 assert_eq!(parsed["version"], "2.1.0");
1455 }
1456
1457 #[test]
1458 fn test_sarif_schema_uri_valid() {
1459 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1460 let sarif = render_sarif(&report, 1000);
1461 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1462
1463 let schema = parsed["$schema"].as_str().unwrap();
1464 assert!(schema.contains("sarif-schema-2.1.0"));
1465 assert!(schema.starts_with("https://"));
1466 }
1467
1468 #[test]
1469 fn test_sarif_has_exactly_one_run() {
1470 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1471 let sarif = render_sarif(&report, 1000);
1472 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1473
1474 let runs = parsed["runs"].as_array().unwrap();
1475 assert_eq!(runs.len(), 1);
1476 }
1477
1478 #[test]
1479 fn test_sarif_rule_index_matches_findings() {
1480 let findings = vec![
1481 Finding::uncovered_line("src/lib.rs", 1, 0),
1482 Finding::uncovered_line("src/lib.rs", 2, 0),
1483 ];
1484 let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
1485 let sarif = render_sarif(&report, 1000);
1486 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1487
1488 let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1489 .as_array()
1490 .unwrap();
1491 let results = parsed["runs"][0]["results"].as_array().unwrap();
1492
1493 for result in results {
1494 let rule_id = result["ruleId"].as_str().unwrap();
1495 let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
1496
1497 assert!(rule_index < rules.len());
1499 assert_eq!(rules[rule_index]["id"], rule_id);
1500 }
1501 }
1502
1503 #[test]
1504 fn test_sarif_location_uri_is_relative() {
1505 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1506 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1507 let sarif = render_sarif(&report, 1000);
1508 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1509
1510 let uri =
1511 parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
1512 ["uri"]
1513 .as_str()
1514 .unwrap();
1515
1516 assert!(!uri.starts_with('/'));
1518 assert!(!uri.contains(":\\"));
1519 }
1520
1521 #[test]
1522 fn test_sarif_uri_base_id_is_srcroot() {
1523 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1524 let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1525 let sarif = render_sarif(&report, 1000);
1526 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1527
1528 let uri_base_id =
1529 parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
1530 ["uriBaseId"]
1531 .as_str()
1532 .unwrap();
1533
1534 assert_eq!(uri_base_id, "%SRCROOT%");
1535 }
1536
1537 #[test]
1538 fn test_sarif_start_line_is_positive() {
1539 let findings = vec![
1540 Finding::uncovered_line("src/lib.rs", 1, 0),
1541 Finding::uncovered_line("src/lib.rs", 100, 0),
1542 ];
1543 let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
1544 let sarif = render_sarif(&report, 1000);
1545 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1546
1547 let results = parsed["runs"][0]["results"].as_array().unwrap();
1548 for result in results {
1549 let locations = result["locations"]
1550 .as_array()
1551 .expect("locations should be an array");
1552 for loc in locations {
1553 let region = loc["physicalLocation"]
1554 .get("region")
1555 .expect("region should be present");
1556 let start_line = region["startLine"].as_u64().unwrap();
1557 assert!(start_line > 0);
1558 }
1559 }
1560 }
1561
1562 #[test]
1563 fn test_sarif_level_is_valid() {
1564 let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1565 error_finding.severity = Severity::Error;
1566
1567 let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1568 warn_finding.severity = Severity::Warn;
1569
1570 let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1571 info_finding.severity = Severity::Info;
1572
1573 let report = make_test_report(
1574 VerdictStatus::Fail,
1575 vec![error_finding, warn_finding, info_finding],
1576 0,
1577 3,
1578 );
1579 let sarif = render_sarif(&report, 1000);
1580 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1581
1582 let results = parsed["runs"][0]["results"].as_array().unwrap();
1583 let valid_levels = ["error", "warning", "note", "none"];
1584
1585 for result in results {
1586 let level = result["level"].as_str().unwrap();
1587 assert!(valid_levels.contains(&level));
1588 }
1589 }
1590
1591 #[test]
1592 fn test_sarif_rules_have_required_fields() {
1593 let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1594 let sarif = render_sarif(&report, 1000);
1595 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1596
1597 let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1598 .as_array()
1599 .unwrap();
1600
1601 for rule in rules {
1602 assert!(rule.get("id").is_some(), "Rule must have id");
1604 assert!(rule.get("shortDescription").is_some());
1605 assert!(rule["shortDescription"].get("text").is_some());
1606 }
1607 }
1608
1609 #[test]
1614 fn test_markdown_with_very_long_path() {
1615 let long_path = format!("src/{}/lib.rs", "very_long_directory_name".repeat(10));
1616 let finding = Finding::uncovered_line(&long_path, 1, 0);
1617 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1618 let md = render_markdown(&report, 10);
1619
1620 assert!(md.contains(&long_path));
1622 }
1623
1624 #[test]
1625 fn test_markdown_max_lines_zero() {
1626 let findings: Vec<_> = (1..=5)
1627 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1628 .collect();
1629 let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1630 let md = render_markdown(&report, 0);
1631
1632 assert!(md.contains("| File | Line | Hits |"));
1634 assert!(md.contains("*Showing 0 of 5 uncovered lines*"));
1635 }
1636
1637 #[test]
1638 fn test_annotations_max_annotations_zero() {
1639 let findings: Vec<_> = (1..=5)
1640 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1641 .collect();
1642 let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1643 let annotations = render_annotations(&report, 0);
1644
1645 assert!(annotations.is_empty());
1646 }
1647
1648 #[test]
1649 fn test_sarif_max_results_zero() {
1650 let findings: Vec<_> = (1..=5)
1651 .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1652 .collect();
1653 let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1654 let sarif = render_sarif(&report, 0);
1655
1656 let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1657 let results = parsed["runs"][0]["results"].as_array().unwrap();
1658 assert!(results.is_empty());
1659 }
1660
1661 #[test]
1662 fn test_markdown_line_without_number() {
1663 let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1664 finding.location.as_mut().unwrap().line = None;
1665
1666 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1667 let md = render_markdown(&report, 10);
1668
1669 assert!(md.contains("| - |"));
1671 }
1672
1673 #[test]
1674 fn test_markdown_finding_without_data() {
1675 let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1676 finding.data = None;
1677
1678 let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1679 let md = render_markdown(&report, 10);
1680
1681 assert!(md.contains("| 0 |"));
1683 }
1684}