1use anyhow::Result;
12use serde::Serialize;
13
14use super::analysis::CoverageDiff;
15use crate::data::{FieldDocumentation, FieldExplanation};
16
17const EPS: f64 = 0.05;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum OutputFormat {
24 Markdown,
26 Yaml,
28 Json,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct RenderOptions {
35 pub artifact_url: Option<String>,
37 pub run_url: Option<String>,
39 pub base_sha: Option<String>,
41 pub head_sha: Option<String>,
43 pub commit_url: Option<String>,
45 pub collapse_ranges: bool,
47}
48
49pub fn render(diff: &CoverageDiff, opts: &RenderOptions, format: OutputFormat) -> Result<String> {
51 match format {
52 OutputFormat::Markdown => Ok(render_markdown(diff, opts)),
53 OutputFormat::Yaml => {
54 let mut view = CoverageDiffView::build(diff, opts);
55 view.update_field_presence();
56 crate::data::yaml::to_yaml(&view)
57 }
58 OutputFormat::Json => {
59 let mut view = CoverageDiffView::build(diff, opts);
60 view.update_field_presence();
61 Ok(serde_json::to_string_pretty(&view)?)
62 }
63 }
64}
65
66fn round2(x: f64) -> f64 {
72 let r = (x * 100.0).round() / 100.0;
73 if r == 0.0 {
74 0.0
75 } else {
76 r
77 }
78}
79
80fn fmt_num(x: f64) -> String {
82 let s = format!("{:.2}", round2(x));
83 s.trim_end_matches('0').trim_end_matches('.').to_string()
84}
85
86fn pct(x: Option<f64>) -> String {
88 match x {
89 Some(v) => format!("{}%", fmt_num(v)),
90 None => "β".to_string(),
91 }
92}
93
94fn arrow(d: f64) -> &'static str {
102 let d = round2(d);
103 if d > 0.0 {
104 "π’"
105 } else if d < 0.0 {
106 "π΄"
107 } else {
108 "βͺ"
109 }
110}
111
112fn headline_arrow(d: f64) -> &'static str {
122 if d.abs() < EPS {
123 "βͺ"
124 } else {
125 arrow(d)
126 }
127}
128
129fn unattributed_note(diff: &CoverageDiff, d: f64) -> &'static str {
134 let moved = round2(d) != 0.0;
135 let explained = diff
136 .file_deltas
137 .iter()
138 .any(|fd| fd.delta().is_none_or(|d| d.abs() >= EPS))
139 || !diff.notable_unchanged.is_empty();
140 if moved && !explained {
141 " _(not attributable to this diff)_"
142 } else {
143 ""
144 }
145}
146
147fn sha_ref(sha: &str, commit_url: Option<&str>) -> String {
149 let short: String = sha.chars().take(7).collect();
150 match commit_url {
151 Some(url) if !url.is_empty() => format!("[`{short}`]({url}/{sha})"),
152 _ => format!("`{short}`"),
153 }
154}
155
156fn collapse_ranges(lines: &[u32]) -> String {
158 let mut parts = Vec::new();
159 let mut i = 0;
160 while i < lines.len() {
161 let start = lines[i];
162 let mut end = start;
163 while i + 1 < lines.len() && lines[i + 1] == end + 1 {
164 end += 1;
165 i += 1;
166 }
167 if start == end {
168 parts.push(start.to_string());
169 } else {
170 parts.push(format!("{start}-{end}"));
171 }
172 i += 1;
173 }
174 parts.join(", ")
175}
176
177fn render_markdown(diff: &CoverageDiff, opts: &RenderOptions) -> String {
182 let mut out = String::new();
183 out.push_str("## Coverage\n\n");
184
185 if diff.has_baseline {
187 match (diff.total_after, diff.total_before) {
188 (after, Some(before)) => {
189 let effective = diff.total_after_effective.or(after);
193 let d = effective.unwrap_or(0.0) - before;
194 out.push_str(&format!(
195 "Total: **{}** {} {} pp vs `main`{}\n\n",
196 pct(after),
197 headline_arrow(d),
198 fmt_num(d),
199 unattributed_note(diff, d)
200 ));
201 }
202 (after, None) => {
203 out.push_str(&format!("Total: **{}**\n\n", pct(after)));
204 }
205 }
206 } else {
207 out.push_str(&format!("Total: **{}**\n\n", pct(diff.total_after)));
208 }
209
210 if let (Some(base), Some(head)) = (opts.base_sha.as_deref(), opts.head_sha.as_deref()) {
212 if !base.is_empty() && !head.is_empty() {
213 out.push_str(&format!(
214 "Comparing {}..{} _(merge-base β PR head)_\n\n",
215 sha_ref(base, opts.commit_url.as_deref()),
216 sha_ref(head, opts.commit_url.as_deref())
217 ));
218 }
219 }
220
221 if diff.has_baseline {
222 render_delta_table(diff, &mut out);
223 render_notable_unchanged(diff, &mut out);
224 } else {
225 out.push_str(
226 "_No baseline available yet (first run, or the `main` baseline artifact was \
227 missing). Per-file deltas will appear on PRs once a baseline has been published \
228 from `main`._\n\n",
229 );
230 }
231 render_markers(diff, &mut out);
233
234 render_patch_section(diff, opts, &mut out);
235
236 if diff.has_baseline && !diff.indirect.is_empty() {
237 render_indirect_section(diff, &mut out);
238 }
239
240 render_footer(opts, &mut out);
241 out
242}
243
244fn render_delta_table(diff: &CoverageDiff, out: &mut String) {
245 struct Row {
247 path: String,
248 before: Option<f64>,
249 after: Option<f64>,
250 delta: Option<f64>,
251 }
252 let mut rows: Vec<Row> = diff
253 .file_deltas
254 .iter()
255 .map(|fd| {
256 let delta = fd.delta();
259 Row {
260 path: fd.path.clone(),
261 before: fd.before,
262 after: fd.after,
263 delta,
264 }
265 })
266 .filter(|r| r.delta.is_none_or(|d| d.abs() >= EPS))
267 .collect();
268 rows.sort_by(|a, b| {
270 a.delta
271 .unwrap_or(-1e9)
272 .partial_cmp(&b.delta.unwrap_or(-1e9))
273 .unwrap_or(std::cmp::Ordering::Equal)
274 });
275
276 if rows.is_empty() {
277 out.push_str("_No per-file coverage changes vs `main`._\n\n");
278 return;
279 }
280
281 out.push_str("| File | Before | After | Ξ |\n");
282 out.push_str("|------|-------:|------:|---|\n");
283 for r in rows {
284 let change = match r.delta {
285 None => "π new".to_string(),
286 Some(d) => format!("{} {} pp", arrow(d), fmt_num(d)),
287 };
288 out.push_str(&format!(
289 "| `{}` | {} | {} | {} |\n",
290 r.path,
291 pct(r.before),
292 pct(r.after),
293 change
294 ));
295 }
296 out.push('\n');
297}
298
299fn render_notable_unchanged(diff: &CoverageDiff, out: &mut String) {
304 if diff.notable_unchanged.is_empty() {
305 return;
306 }
307 out.push_str(&format!(
308 "<details><summary>βΉοΈ {} unchanged file(s) also moved (not attributed to this PR)</summary>\n\n",
309 diff.notable_unchanged.len()
310 ));
311 out.push_str(
312 "These files were not modified by this diff; the shift is either measurement variance \
313 between the two runs or a cross-file effect (e.g. a removed test).\n\n",
314 );
315 out.push_str("| File | Before | After | Ξ |\n");
316 out.push_str("|------|-------:|------:|---|\n");
317 for fd in &diff.notable_unchanged {
318 let change = match fd.delta() {
319 None => "π new".to_string(),
320 Some(d) => format!("{} {} pp", arrow(d), fmt_num(d)),
321 };
322 out.push_str(&format!(
323 "| `{}` | {} | {} | {} |\n",
324 fd.path,
325 pct(fd.before),
326 pct(fd.after),
327 change
328 ));
329 }
330 out.push_str("\n</details>\n\n");
331}
332
333fn render_markers(diff: &CoverageDiff, out: &mut String) {
338 if diff.markers.is_empty() {
339 return;
340 }
341 let count =
342 |kind: crate::coverage::MarkerKind| diff.markers.iter().filter(|m| m.kind == kind).count();
343 out.push_str(&format!(
344 "<details><summary>π {} ignored region(s), {} tolerated region(s)</summary>\n\n",
345 count(crate::coverage::MarkerKind::Ignore),
346 count(crate::coverage::MarkerKind::Tolerate)
347 ));
348 out.push_str(
349 "`ignore` removes the lines from both reports; `tolerate` keeps them in the reported \
350 percentage but scores them against the baseline, so a cross-run flip cannot move a \
351 delta. Regions are read from each revision's own source.\n\n",
352 );
353 out.push_str("| File | Kind | Lines | Rev | Reason |\n");
354 out.push_str("|------|------|-------|-----|--------|\n");
355 for marker in &diff.markers {
356 let lines = if marker.start == marker.end {
357 marker.start.to_string()
358 } else {
359 format!("{}-{}", marker.start, marker.end)
360 };
361 out.push_str(&format!(
362 "| `{}` | `{}` | {} | {} | {} |\n",
363 marker.path,
364 marker.kind.as_str(),
365 lines,
366 marker.side.as_str(),
367 marker.reason
368 ));
369 }
370 out.push_str("\n</details>\n\n");
371}
372
373fn render_patch_section(diff: &CoverageDiff, opts: &RenderOptions, out: &mut String) {
374 out.push_str("### Patch coverage\n\n");
375
376 if diff.patch.total() == 0 {
377 out.push_str("_No new executable lines added by this diff._\n\n");
378 return;
379 }
380
381 out.push_str(&format!(
382 "Patch: **{}** ({}/{} new lines covered)\n\n",
383 pct(diff.patch.percent()),
384 diff.patch.covered,
385 diff.patch.total()
386 ));
387
388 if !diff.file_patches.is_empty() {
389 out.push_str("| File | Patch | Uncovered new lines |\n");
390 out.push_str("|------|------:|---------------------|\n");
391 for fp in &diff.file_patches {
392 let uncovered = if fp.uncovered_lines.is_empty() {
393 "β".to_string()
394 } else if opts.collapse_ranges {
395 collapse_ranges(&fp.uncovered_lines)
396 } else {
397 fp.uncovered_lines
398 .iter()
399 .map(u32::to_string)
400 .collect::<Vec<_>>()
401 .join(", ")
402 };
403 out.push_str(&format!(
404 "| `{}` | {} ({}/{}) | {} |\n",
405 fp.path,
406 pct(fp.patch.percent()),
407 fp.patch.covered,
408 fp.patch.total(),
409 uncovered
410 ));
411 }
412 out.push('\n');
413 }
414
415 if !diff.uncovered_new_lines.is_empty() {
416 out.push_str(&format!(
417 "<details><summary>Uncovered new lines ({})</summary>\n\n",
418 diff.uncovered_new_lines.len()
419 ));
420 for (path, line) in &diff.uncovered_new_lines {
421 out.push_str(&format!("- `{path}:{line}`\n"));
422 }
423 out.push_str("\n</details>\n\n");
424 }
425}
426
427fn render_indirect_section(diff: &CoverageDiff, out: &mut String) {
428 out.push_str("### Indirect coverage changes\n\n");
429 out.push_str(&format!(
430 "π΄ {} lines lost coverage, π’ {} lines gained coverage on unchanged code.\n\n",
431 diff.indirect_newly_uncovered(),
432 diff.indirect_newly_covered()
433 ));
434 out.push_str("<details><summary>Indirect changes</summary>\n\n");
435 for change in &diff.indirect {
436 let transition = if change.became_covered {
437 "π’ uncovered β covered"
438 } else {
439 "π΄ covered β uncovered"
440 };
441 out.push_str(&format!(
442 "- `{}:{}` {}\n",
443 change.path, change.head_line, transition
444 ));
445 }
446 out.push_str("\n</details>\n\n");
447}
448
449fn render_footer(opts: &RenderOptions, out: &mut String) {
450 match opts.artifact_url.as_deref().filter(|u| !u.is_empty()) {
451 Some(artifact) => {
452 out.push_str(&format!(
453 "<sub>π¦ [Full per-file coverage summary]({artifact})"
454 ));
455 if let Some(run) = opts.run_url.as_deref().filter(|u| !u.is_empty()) {
456 out.push_str(&format!(" Β· [run summary]({run})"));
457 }
458 out.push_str("</sub>\n");
459 }
460 None => {
461 out.push_str(
462 "<sub>Full per-file summary is attached as the **coverage-summary** build \
463 artifact.</sub>\n",
464 );
465 }
466 }
467}
468
469#[derive(Debug, Clone, Serialize)]
476struct CoverageDiffView {
477 explanation: FieldExplanation,
478 patch_coverage: PatchView,
479 #[serde(skip_serializing_if = "Vec::is_empty")]
480 uncovered_new_lines: Vec<String>,
481 #[serde(skip_serializing_if = "Option::is_none")]
482 project_delta: Option<ProjectDeltaView>,
483 #[serde(skip_serializing_if = "Option::is_none")]
484 indirect_changes: Option<IndirectView>,
485 #[serde(skip_serializing_if = "Vec::is_empty")]
487 markers: Vec<MarkerView>,
488}
489
490#[derive(Debug, Clone, Serialize)]
491struct MarkerView {
492 path: String,
493 kind: String,
494 side: String,
496 start: u32,
497 end: u32,
498 reason: String,
499}
500
501#[derive(Debug, Clone, Serialize)]
502struct PatchView {
503 percent: Option<f64>,
504 covered: u64,
505 total: u64,
506 files: Vec<FilePatchView>,
507}
508
509#[derive(Debug, Clone, Serialize)]
510struct FilePatchView {
511 path: String,
512 percent: Option<f64>,
513 covered: u64,
514 total: u64,
515 #[serde(skip_serializing_if = "Vec::is_empty")]
516 uncovered_lines: Vec<u32>,
517}
518
519#[derive(Debug, Clone, Serialize)]
520struct ProjectDeltaView {
521 total_before: Option<f64>,
522 total_after: Option<f64>,
524 #[serde(skip_serializing_if = "Option::is_none")]
527 total_after_effective: Option<f64>,
528 files: Vec<FileDeltaView>,
529 #[serde(skip_serializing_if = "Vec::is_empty")]
532 notable_unchanged: Vec<FileDeltaView>,
533}
534
535#[derive(Debug, Clone, Serialize)]
536struct FileDeltaView {
537 path: String,
538 before: Option<f64>,
539 after: Option<f64>,
541 #[serde(skip_serializing_if = "Option::is_none")]
545 after_effective: Option<f64>,
546 delta: Option<f64>,
547}
548
549#[derive(Debug, Clone, Serialize)]
550struct IndirectView {
551 newly_covered: usize,
552 newly_uncovered: usize,
553 lines: Vec<IndirectLineView>,
554}
555
556#[derive(Debug, Clone, Serialize)]
557struct IndirectLineView {
558 path: String,
559 head_line: u32,
560 base_line: u32,
561 transition: String,
562}
563
564impl CoverageDiffView {
565 fn build(diff: &CoverageDiff, _opts: &RenderOptions) -> Self {
566 let patch_coverage = PatchView {
567 percent: diff.patch.percent().map(round2),
568 covered: diff.patch.covered,
569 total: diff.patch.total(),
570 files: diff
571 .file_patches
572 .iter()
573 .map(|fp| FilePatchView {
574 path: fp.path.clone(),
575 percent: fp.patch.percent().map(round2),
576 covered: fp.patch.covered,
577 total: fp.patch.total(),
578 uncovered_lines: fp.uncovered_lines.clone(),
579 })
580 .collect(),
581 };
582
583 let uncovered_new_lines = diff
584 .uncovered_new_lines
585 .iter()
586 .map(|(path, line)| format!("{path}:{line}"))
587 .collect();
588
589 let (project_delta, indirect_changes) = if diff.has_baseline {
590 let file_delta_view = |fd: &crate::coverage::analysis::FileDelta| FileDeltaView {
591 path: fd.path.clone(),
592 before: fd.before.map(round2),
593 after: fd.after.map(round2),
594 after_effective: fd
595 .is_masked()
596 .then(|| fd.after_effective.map(round2))
597 .flatten(),
598 delta: fd.delta().map(round2),
599 };
600 let project_delta = ProjectDeltaView {
601 total_before: diff.total_before.map(round2),
602 total_after: diff.total_after.map(round2),
603 total_after_effective: (diff.total_after_effective != diff.total_after)
604 .then(|| diff.total_after_effective.map(round2))
605 .flatten(),
606 files: diff.file_deltas.iter().map(file_delta_view).collect(),
607 notable_unchanged: diff.notable_unchanged.iter().map(file_delta_view).collect(),
608 };
609 let indirect_changes = IndirectView {
610 newly_covered: diff.indirect_newly_covered(),
611 newly_uncovered: diff.indirect_newly_uncovered(),
612 lines: diff
613 .indirect
614 .iter()
615 .map(|c| IndirectLineView {
616 path: c.path.clone(),
617 head_line: c.head_line,
618 base_line: c.base_line,
619 transition: if c.became_covered {
620 "uncovered_to_covered".to_string()
621 } else {
622 "covered_to_uncovered".to_string()
623 },
624 })
625 .collect(),
626 };
627 (Some(project_delta), Some(indirect_changes))
628 } else {
629 (None, None)
630 };
631
632 let markers = diff
633 .markers
634 .iter()
635 .map(|m| MarkerView {
636 path: m.path.clone(),
637 kind: m.kind.as_str().to_string(),
638 side: m.side.as_str().to_string(),
639 start: m.start,
640 end: m.end,
641 reason: m.reason.clone(),
642 })
643 .collect();
644
645 Self {
646 explanation: explanation(),
647 patch_coverage,
648 uncovered_new_lines,
649 project_delta,
650 indirect_changes,
651 markers,
652 }
653 }
654
655 fn update_field_presence(&mut self) {
657 let has_patch_files = !self.patch_coverage.files.is_empty();
658 let has_uncovered = !self.uncovered_new_lines.is_empty();
659 let has_baseline = self.project_delta.is_some();
660 let has_indirect = self
661 .indirect_changes
662 .as_ref()
663 .is_some_and(|i| !i.lines.is_empty());
664 let has_markers = !self.markers.is_empty();
665 for field in &mut self.explanation.fields {
666 field.present = match field.name.as_str() {
667 "patch_coverage.percent" | "patch_coverage.covered" | "patch_coverage.total" => {
668 true
669 }
670 "patch_coverage.files[].path" => has_patch_files,
671 "uncovered_new_lines[]" => has_uncovered,
672 "project_delta.total_after" | "project_delta.files[].path" => has_baseline,
673 "indirect_changes.lines[].path" => has_indirect,
674 "markers[].path" => has_markers,
675 _ => false,
676 };
677 }
678 }
679}
680
681fn explanation() -> FieldExplanation {
683 fn field(name: &str, text: &str) -> FieldDocumentation {
684 FieldDocumentation {
685 name: name.to_string(),
686 text: text.to_string(),
687 command: None,
688 present: false,
689 }
690 }
691 FieldExplanation {
692 text: "Diff/patch coverage analysis. `patch_coverage` attributes coverage to the lines \
693 this diff added (needs only the head report + diff). `project_delta` and \
694 `indirect_changes` are present only when a baseline report was supplied."
695 .to_string(),
696 fields: vec![
697 field(
698 "patch_coverage.percent",
699 "Percentage of added, instrumented lines that are covered.",
700 ),
701 field("patch_coverage.covered", "Count of covered added lines."),
702 field(
703 "patch_coverage.total",
704 "Count of added, instrumented lines (the patch-coverage denominator).",
705 ),
706 field(
707 "patch_coverage.files[].path",
708 "Per-file patch coverage for files that added instrumented lines.",
709 ),
710 field(
711 "uncovered_new_lines[]",
712 "Actionable `file:line` list of added lines that are not covered.",
713 ),
714 field(
715 "project_delta.total_after",
716 "Project line coverage before/after; present only with a baseline report.",
717 ),
718 field(
719 "project_delta.files[].path",
720 "Per-file before/after coverage and delta; present only with a baseline report.",
721 ),
722 field(
723 "indirect_changes.lines[].path",
724 "Lines whose coverage flipped without their content changing; needs a baseline.",
725 ),
726 field(
727 "markers[].path",
728 "Source-marker regions that applied. `kind` is `ignore` (lines removed from both \
729 reports) or `tolerate` (lines kept in the percentages, but their coverage flips \
730 masked). Where a region was tolerated, `delta` is computed from \
731 `after_effective`, not from the displayed `after`.",
732 ),
733 ],
734 }
735}
736
737#[cfg(test)]
738#[allow(clippy::unwrap_used, clippy::expect_used)]
739mod tests {
740 use super::*;
741 use crate::coverage::analysis::{
742 AppliedMarker, FileDelta, FilePatch, IndirectChange, MarkerSide, PatchCoverage,
743 };
744
745 #[test]
746 fn fmt_num_trims_trailing_zeros() {
747 assert_eq!(fmt_num(100.0), "100");
748 assert_eq!(fmt_num(65.4), "65.4");
749 assert_eq!(fmt_num(65.432), "65.43");
750 assert_eq!(fmt_num(50.0), "50");
751 assert_eq!(fmt_num(-0.001), "0");
752 }
753
754 #[test]
755 fn collapse_ranges_groups_consecutive() {
756 assert_eq!(collapse_ranges(&[5]), "5");
757 assert_eq!(collapse_ranges(&[9, 10, 11]), "9-11");
758 assert_eq!(collapse_ranges(&[5, 9, 10, 11, 20]), "5, 9-11, 20");
759 }
760
761 #[test]
762 fn sha_ref_links_when_url_present() {
763 assert_eq!(sha_ref("abcdef1234", None), "`abcdef1`");
764 assert_eq!(
765 sha_ref("abcdef1234", Some("https://x/commit")),
766 "[`abcdef1`](https://x/commit/abcdef1234)"
767 );
768 }
769
770 fn sample_diff() -> CoverageDiff {
771 CoverageDiff {
772 patch: PatchCoverage {
773 covered: 4,
774 uncovered: 1,
775 },
776 file_patches: vec![FilePatch {
777 path: "src/a.rs".to_string(),
778 patch: PatchCoverage {
779 covered: 4,
780 uncovered: 1,
781 },
782 uncovered_lines: vec![9],
783 }],
784 uncovered_new_lines: vec![("src/a.rs".to_string(), 9)],
785 total_after: Some(80.0),
786 ..Default::default()
787 }
788 }
789
790 #[test]
791 fn markdown_without_baseline_has_patch_section() {
792 let diff = sample_diff();
793 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
794 assert!(md.contains("## Coverage"));
795 assert!(md.contains("Total: **80%**"));
796 assert!(md.contains("### Patch coverage"));
797 assert!(md.contains("Patch: **80%** (4/5 new lines covered)"));
798 assert!(md.contains("`src/a.rs:9`"));
799 assert!(md.contains("No baseline available yet"));
800 }
801
802 #[test]
803 fn markdown_with_baseline_shows_total_delta_and_indirect() {
804 let mut diff = sample_diff();
805 diff.has_baseline = true;
806 diff.total_before = Some(75.0);
807 diff.indirect = vec![IndirectChange {
808 path: "src/b.rs".to_string(),
809 base_line: 5,
810 head_line: 5,
811 became_covered: false,
812 }];
813 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
814 assert!(md.contains("π’ 5 pp vs `main`"));
815 assert!(md.contains("### Indirect coverage changes"));
816 assert!(md.contains("`src/b.rs:5`"));
817 }
818
819 #[test]
823 fn markdown_sub_rounding_delta_is_neutral() {
824 let mut diff = sample_diff();
825 diff.has_baseline = true;
826 diff.total_before = Some(80.0);
827 diff.total_after = Some(79.996);
828 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
829 assert!(md.contains("\u{26aa} 0 pp vs `main`"), "{md}");
830 assert!(
831 !md.contains("\u{1f534}"),
832 "sub-rounding move must not paint red: {md}"
833 );
834 }
835
836 #[test]
841 fn notable_unchanged_sub_rounding_delta_is_neutral() {
842 let mut diff = sample_diff();
843 diff.has_baseline = true;
844 diff.total_before = Some(80.0);
845 diff.notable_unchanged = vec![FileDelta::new(
846 "src/big.rs".to_string(),
847 Some(90.0),
848 Some(89.998),
849 )];
850 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
851 assert!(
852 md.contains("| `src/big.rs` | 90% | 90% | \u{26aa} 0 pp |"),
853 "{md}"
854 );
855 }
856
857 #[test]
861 fn markdown_sub_eps_total_move_is_neutral_and_annotated() {
862 let mut diff = sample_diff();
863 diff.has_baseline = true;
864 diff.total_before = Some(92.8012);
865 diff.total_after = Some(92.7924);
866 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
867 assert!(
868 !md.contains("\u{1f534}"),
869 "sub-EPS move must not paint red: {md}"
870 );
871 assert!(md.contains("Total: **92.79%** \u{26aa} -0.01 pp"), "{md}");
872 assert!(md.contains("_(not attributable to this diff)_"), "{md}");
873 }
874
875 #[test]
878 fn markdown_unexplained_total_move_is_annotated() {
879 let mut diff = sample_diff();
880 diff.has_baseline = true;
881 diff.total_before = Some(85.0);
882 diff.total_after = Some(80.0);
883 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
884 assert!(
885 md.contains("\u{1f534} -5 pp vs `main` _(not attributable to this diff)_"),
886 "{md}"
887 );
888 }
889
890 #[test]
892 fn markdown_explained_total_move_is_not_annotated() {
893 let diff = baseline_diff();
894 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
895 assert!(!md.contains("not attributable"), "{md}");
896 }
897
898 #[test]
901 fn markdown_notable_unchanged_explains_total_move() {
902 let mut diff = sample_diff();
903 diff.has_baseline = true;
904 diff.total_before = Some(85.0);
905 diff.total_after = Some(80.0);
906 diff.notable_unchanged = vec![FileDelta::new(
907 "src/big.rs".to_string(),
908 Some(90.0),
909 Some(60.0),
910 )];
911 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
912 assert!(!md.contains("not attributable to this diff"), "{md}");
913 }
914
915 fn tolerated_marker() -> AppliedMarker {
916 AppliedMarker {
917 path: "src/util/simd/x86.rs".to_string(),
918 kind: crate::coverage::MarkerKind::Tolerate,
919 side: MarkerSide::Both,
920 start: 41,
921 end: 52,
922 reason: "CPU-gated: the avx512f arm only runs on Zen 4+".to_string(),
923 }
924 }
925
926 #[test]
930 fn markdown_headline_uses_effective_coverage_for_the_delta_only() {
931 let mut diff = sample_diff();
932 diff.has_baseline = true;
933 diff.total_before = Some(92.8012);
934 diff.total_after = Some(92.7924);
935 diff.total_after_effective = Some(92.8012);
936 diff.markers = vec![tolerated_marker()];
937 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
938 assert!(
939 md.contains("Total: **92.79%** \u{26aa} 0 pp vs `main`"),
940 "{md}"
941 );
942 assert!(
943 !md.contains("not attributable"),
944 "a masked move is not a move: {md}"
945 );
946 }
947
948 #[test]
950 fn markdown_lists_every_applied_marker() {
951 let mut diff = sample_diff();
952 diff.has_baseline = true;
953 diff.total_before = Some(80.0);
954 diff.markers = vec![
955 tolerated_marker(),
956 AppliedMarker {
957 path: "src/generated.rs".to_string(),
958 kind: crate::coverage::MarkerKind::Ignore,
959 side: MarkerSide::Head,
960 start: 7,
961 end: 7,
962 reason: "generated".to_string(),
963 },
964 ];
965 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
966 assert!(
967 md.contains("\u{1f507} 1 ignored region(s), 1 tolerated region(s)"),
968 "{md}"
969 );
970 assert!(
971 md.contains("| `src/util/simd/x86.rs` | `tolerate` | 41-52 | both |"),
972 "{md}"
973 );
974 assert!(
975 md.contains("| `src/generated.rs` | `ignore` | 7 | head |"),
976 "{md}"
977 );
978 assert!(md.contains("the avx512f arm only runs on Zen 4+"), "{md}");
979 }
980
981 #[test]
984 fn markdown_lists_markers_without_a_baseline() {
985 let mut diff = sample_diff();
986 diff.markers = vec![tolerated_marker()];
987 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
988 assert!(md.contains("1 tolerated region(s)"), "{md}");
989 }
990
991 #[test]
992 fn markdown_omits_the_note_when_no_marker_applied() {
993 let md = render(
994 &baseline_diff(),
995 &RenderOptions::default(),
996 OutputFormat::Markdown,
997 )
998 .unwrap();
999 assert!(!md.contains("region(s)"), "{md}");
1000 }
1001
1002 #[test]
1006 fn json_reports_markers_and_effective_coverage() {
1007 let mut diff = sample_diff();
1008 diff.has_baseline = true;
1009 diff.total_before = Some(92.8012);
1010 diff.total_after = Some(92.7924);
1011 diff.total_after_effective = Some(92.8012);
1012 diff.file_deltas = vec![FileDelta {
1013 path: "src/util/simd/x86.rs".to_string(),
1014 before: Some(90.0),
1015 after: Some(80.0),
1016 after_effective: Some(90.0),
1017 }];
1018 diff.markers = vec![tolerated_marker()];
1019 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
1020 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1021 assert_eq!(value["project_delta"]["total_after"], 92.79);
1022 assert_eq!(value["project_delta"]["total_after_effective"], 92.8);
1023 let file = &value["project_delta"]["files"][0];
1024 assert_eq!(file["after"], 80.0);
1025 assert_eq!(file["after_effective"], 90.0);
1026 assert_eq!(
1027 file["delta"], 0.0,
1028 "delta is computed from the masked value"
1029 );
1030 assert_eq!(value["markers"][0]["kind"], "tolerate");
1031 assert_eq!(value["markers"][0]["side"], "both");
1032 assert_eq!(value["markers"][0]["start"], 41);
1033 }
1034
1035 #[test]
1038 fn json_omits_effective_fields_when_nothing_was_masked() {
1039 let mut diff = baseline_diff();
1040 diff.total_after_effective = diff.total_after;
1041 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
1042 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1043 assert!(value["project_delta"]
1044 .get("total_after_effective")
1045 .is_none());
1046 assert!(value["project_delta"]["files"][0]
1047 .get("after_effective")
1048 .is_none());
1049 assert!(value.get("markers").is_none());
1050 }
1051
1052 #[test]
1053 fn json_round_trips() {
1054 let diff = sample_diff();
1055 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
1056 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1057 assert_eq!(value["patch_coverage"]["covered"], 4);
1058 assert_eq!(value["patch_coverage"]["total"], 5);
1059 assert_eq!(value["uncovered_new_lines"][0], "src/a.rs:9");
1060 assert!(value.get("project_delta").is_none());
1062 }
1063
1064 #[test]
1065 fn yaml_renders() {
1066 let diff = sample_diff();
1067 let yaml = render(&diff, &RenderOptions::default(), OutputFormat::Yaml).unwrap();
1068 assert!(yaml.contains("patch_coverage:"));
1069 assert!(yaml.contains("explanation:"));
1070 }
1071
1072 fn baseline_diff() -> CoverageDiff {
1076 CoverageDiff {
1077 patch: PatchCoverage {
1078 covered: 2,
1079 uncovered: 4,
1080 },
1081 file_patches: vec![FilePatch {
1082 path: "src/a.rs".to_string(),
1083 patch: PatchCoverage {
1084 covered: 2,
1085 uncovered: 4,
1086 },
1087 uncovered_lines: vec![9, 10, 11, 15],
1088 }],
1089 uncovered_new_lines: vec![
1090 ("src/a.rs".to_string(), 9),
1091 ("src/a.rs".to_string(), 10),
1092 ("src/a.rs".to_string(), 11),
1093 ("src/a.rs".to_string(), 15),
1094 ],
1095 has_baseline: true,
1096 total_after: Some(80.0),
1097 total_before: Some(80.0), file_deltas: vec![
1099 FileDelta::new("src/new.rs".to_string(), None, Some(50.0)),
1100 FileDelta::new("src/down.rs".to_string(), Some(100.0), Some(70.0)),
1101 FileDelta::new("src/up.rs".to_string(), Some(70.0), Some(90.0)),
1102 FileDelta::new("src/tiny.rs", Some(90.0), Some(90.02)),
1104 FileDelta::new("src/gone.rs", Some(50.0), None),
1106 ],
1107 ..Default::default()
1108 }
1109 }
1110
1111 #[test]
1112 fn markdown_delta_table_and_footer() {
1113 let diff = baseline_diff();
1114 let opts = RenderOptions {
1115 artifact_url: Some("https://artifact".to_string()),
1116 run_url: Some("https://run".to_string()),
1117 collapse_ranges: true,
1118 ..Default::default()
1119 };
1120 let md = render(&diff, &opts, OutputFormat::Markdown).unwrap();
1121 assert!(md.contains("βͺ 0 pp vs `main`"));
1122 assert!(md.contains("| `src/new.rs` | β | 50% | π new |"));
1123 assert!(md.contains("π΄ -30 pp"));
1124 assert!(md.contains("π’ 20 pp"));
1125 assert!(md.contains("| `src/gone.rs` | 50% | β | π΄ -50 pp |"));
1126 assert!(!md.contains("tiny.rs"), "below-EPS row must be filtered");
1127 assert!(md.contains("9-11, 15"));
1129 assert!(md.contains("[Full per-file coverage summary](https://artifact)"));
1131 assert!(md.contains("[run summary](https://run)"));
1132 }
1133
1134 #[test]
1135 fn markdown_comparing_line_and_covered_indirect() {
1136 let mut diff = sample_diff();
1137 diff.has_baseline = true;
1138 diff.total_before = Some(80.0);
1139 diff.indirect = vec![IndirectChange {
1140 path: "src/b.rs".to_string(),
1141 base_line: 5,
1142 head_line: 5,
1143 became_covered: true,
1144 }];
1145 let opts = RenderOptions {
1146 base_sha: Some("abcdef123".to_string()),
1147 head_sha: Some("fedcba321".to_string()),
1148 commit_url: Some("https://x/commit".to_string()),
1149 ..Default::default()
1150 };
1151 let md = render(&diff, &opts, OutputFormat::Markdown).unwrap();
1152 assert!(md.contains("Comparing [`abcdef1`](https://x/commit/abcdef123)"));
1153 assert!(md.contains("π’ uncovered β covered"));
1154 }
1155
1156 #[test]
1157 fn markdown_no_per_file_changes() {
1158 let mut diff = sample_diff();
1159 diff.has_baseline = true;
1160 diff.total_before = Some(80.0);
1161 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
1163 assert!(md.contains("_No per-file coverage changes vs `main`._"));
1164 }
1165
1166 #[test]
1167 fn markdown_baseline_without_total_before() {
1168 let mut diff = sample_diff();
1169 diff.has_baseline = true;
1170 diff.total_before = None;
1171 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
1172 assert!(md.contains("Total: **80%**"));
1173 assert!(!md.contains("pp vs"));
1174 }
1175
1176 #[test]
1177 fn markdown_no_added_lines() {
1178 let diff = CoverageDiff {
1179 total_after: Some(50.0),
1180 ..Default::default()
1181 };
1182 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
1183 assert!(md.contains("_No new executable lines added by this diff._"));
1184 }
1185
1186 #[test]
1187 fn json_and_yaml_with_baseline_include_project_delta() {
1188 let diff = baseline_diff();
1189 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
1190 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1191 assert!(value.get("project_delta").is_some());
1192 assert_eq!(value["project_delta"]["total_after"], 80.0);
1193 assert!(value.get("indirect_changes").is_some());
1194
1195 let yaml = render(&diff, &RenderOptions::default(), OutputFormat::Yaml).unwrap();
1196 assert!(yaml.contains("project_delta:"));
1197 }
1198
1199 #[test]
1200 fn markdown_renders_notable_unchanged_note() {
1201 let mut diff = baseline_diff();
1202 diff.notable_unchanged = vec![
1203 FileDelta::new("src/other.rs".to_string(), Some(80.0), Some(60.0)),
1204 FileDelta::new("src/fresh.rs".to_string(), None, Some(55.0)),
1206 ];
1207 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
1208 assert!(md.contains("unchanged file(s) also moved (not attributed to this PR)"));
1209 assert!(md.contains("`src/other.rs`"));
1210 assert!(md.contains("π΄ -20 pp"));
1211 assert!(md.contains("| `src/fresh.rs` | β | 55% | π new |"));
1212 }
1213
1214 #[test]
1215 fn json_includes_notable_unchanged() {
1216 let mut diff = baseline_diff();
1217 diff.notable_unchanged = vec![FileDelta::new(
1218 "src/other.rs".to_string(),
1219 Some(80.0),
1220 Some(60.0),
1221 )];
1222 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
1223 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1224 assert_eq!(
1225 value["project_delta"]["notable_unchanged"][0]["path"],
1226 "src/other.rs"
1227 );
1228 }
1229}