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 {
96 if d > 0.0 {
97 "π’"
98 } else if d < 0.0 {
99 "π΄"
100 } else {
101 "βͺ"
102 }
103}
104
105fn sha_ref(sha: &str, commit_url: Option<&str>) -> String {
107 let short: String = sha.chars().take(7).collect();
108 match commit_url {
109 Some(url) if !url.is_empty() => format!("[`{short}`]({url}/{sha})"),
110 _ => format!("`{short}`"),
111 }
112}
113
114fn collapse_ranges(lines: &[u32]) -> String {
116 let mut parts = Vec::new();
117 let mut i = 0;
118 while i < lines.len() {
119 let start = lines[i];
120 let mut end = start;
121 while i + 1 < lines.len() && lines[i + 1] == end + 1 {
122 end += 1;
123 i += 1;
124 }
125 if start == end {
126 parts.push(start.to_string());
127 } else {
128 parts.push(format!("{start}-{end}"));
129 }
130 i += 1;
131 }
132 parts.join(", ")
133}
134
135fn render_markdown(diff: &CoverageDiff, opts: &RenderOptions) -> String {
140 let mut out = String::new();
141 out.push_str("## Coverage\n\n");
142
143 if diff.has_baseline {
145 match (diff.total_after, diff.total_before) {
146 (after, Some(before)) => {
147 let d = after.unwrap_or(0.0) - before;
148 out.push_str(&format!(
149 "Total: **{}** {} {} pp vs `main`\n\n",
150 pct(after),
151 arrow(d),
152 fmt_num(d)
153 ));
154 }
155 (after, None) => {
156 out.push_str(&format!("Total: **{}**\n\n", pct(after)));
157 }
158 }
159 } else {
160 out.push_str(&format!("Total: **{}**\n\n", pct(diff.total_after)));
161 }
162
163 if let (Some(base), Some(head)) = (opts.base_sha.as_deref(), opts.head_sha.as_deref()) {
165 if !base.is_empty() && !head.is_empty() {
166 out.push_str(&format!(
167 "Comparing {}..{} _(merge-base β PR head)_\n\n",
168 sha_ref(base, opts.commit_url.as_deref()),
169 sha_ref(head, opts.commit_url.as_deref())
170 ));
171 }
172 }
173
174 if diff.has_baseline {
175 render_delta_table(diff, &mut out);
176 render_notable_unchanged(diff, &mut out);
177 } else {
178 out.push_str(
179 "_No baseline available yet (first run, or the `main` baseline artifact was \
180 missing). Per-file deltas will appear on PRs once a baseline has been published \
181 from `main`._\n\n",
182 );
183 }
184
185 render_patch_section(diff, opts, &mut out);
186
187 if diff.has_baseline && !diff.indirect.is_empty() {
188 render_indirect_section(diff, &mut out);
189 }
190
191 render_footer(opts, &mut out);
192 out
193}
194
195fn render_delta_table(diff: &CoverageDiff, out: &mut String) {
196 struct Row {
198 path: String,
199 before: Option<f64>,
200 after: Option<f64>,
201 delta: Option<f64>,
202 }
203 let mut rows: Vec<Row> = diff
204 .file_deltas
205 .iter()
206 .map(|fd| {
207 let delta = fd.before.map(|before| fd.after.unwrap_or(0.0) - before);
208 Row {
209 path: fd.path.clone(),
210 before: fd.before,
211 after: fd.after,
212 delta,
213 }
214 })
215 .filter(|r| r.delta.is_none() || r.delta.is_some_and(|d| d.abs() >= EPS))
216 .collect();
217 rows.sort_by(|a, b| {
219 a.delta
220 .unwrap_or(-1e9)
221 .partial_cmp(&b.delta.unwrap_or(-1e9))
222 .unwrap_or(std::cmp::Ordering::Equal)
223 });
224
225 if rows.is_empty() {
226 out.push_str("_No per-file coverage changes vs `main`._\n\n");
227 return;
228 }
229
230 out.push_str("| File | Before | After | Ξ |\n");
231 out.push_str("|------|-------:|------:|---|\n");
232 for r in rows {
233 let change = match r.delta {
234 None => "π new".to_string(),
235 Some(d) => format!("{} {} pp", arrow(d), fmt_num(d)),
236 };
237 out.push_str(&format!(
238 "| `{}` | {} | {} | {} |\n",
239 r.path,
240 pct(r.before),
241 pct(r.after),
242 change
243 ));
244 }
245 out.push('\n');
246}
247
248fn render_notable_unchanged(diff: &CoverageDiff, out: &mut String) {
253 if diff.notable_unchanged.is_empty() {
254 return;
255 }
256 out.push_str(&format!(
257 "<details><summary>βΉοΈ {} unchanged file(s) also moved (not attributed to this PR)</summary>\n\n",
258 diff.notable_unchanged.len()
259 ));
260 out.push_str(
261 "These files were not modified by this diff; the shift is either measurement variance \
262 between the two runs or a cross-file effect (e.g. a removed test).\n\n",
263 );
264 out.push_str("| File | Before | After | Ξ |\n");
265 out.push_str("|------|-------:|------:|---|\n");
266 for fd in &diff.notable_unchanged {
267 let change = match fd.delta() {
268 None => "π new".to_string(),
269 Some(d) => format!("{} {} pp", arrow(d), fmt_num(d)),
270 };
271 out.push_str(&format!(
272 "| `{}` | {} | {} | {} |\n",
273 fd.path,
274 pct(fd.before),
275 pct(fd.after),
276 change
277 ));
278 }
279 out.push_str("\n</details>\n\n");
280}
281
282fn render_patch_section(diff: &CoverageDiff, opts: &RenderOptions, out: &mut String) {
283 out.push_str("### Patch coverage\n\n");
284
285 if diff.patch.total() == 0 {
286 out.push_str("_No new executable lines added by this diff._\n\n");
287 return;
288 }
289
290 out.push_str(&format!(
291 "Patch: **{}** ({}/{} new lines covered)\n\n",
292 pct(diff.patch.percent()),
293 diff.patch.covered,
294 diff.patch.total()
295 ));
296
297 if !diff.file_patches.is_empty() {
298 out.push_str("| File | Patch | Uncovered new lines |\n");
299 out.push_str("|------|------:|---------------------|\n");
300 for fp in &diff.file_patches {
301 let uncovered = if fp.uncovered_lines.is_empty() {
302 "β".to_string()
303 } else if opts.collapse_ranges {
304 collapse_ranges(&fp.uncovered_lines)
305 } else {
306 fp.uncovered_lines
307 .iter()
308 .map(u32::to_string)
309 .collect::<Vec<_>>()
310 .join(", ")
311 };
312 out.push_str(&format!(
313 "| `{}` | {} ({}/{}) | {} |\n",
314 fp.path,
315 pct(fp.patch.percent()),
316 fp.patch.covered,
317 fp.patch.total(),
318 uncovered
319 ));
320 }
321 out.push('\n');
322 }
323
324 if !diff.uncovered_new_lines.is_empty() {
325 out.push_str(&format!(
326 "<details><summary>Uncovered new lines ({})</summary>\n\n",
327 diff.uncovered_new_lines.len()
328 ));
329 for (path, line) in &diff.uncovered_new_lines {
330 out.push_str(&format!("- `{path}:{line}`\n"));
331 }
332 out.push_str("\n</details>\n\n");
333 }
334}
335
336fn render_indirect_section(diff: &CoverageDiff, out: &mut String) {
337 out.push_str("### Indirect coverage changes\n\n");
338 out.push_str(&format!(
339 "π΄ {} lines lost coverage, π’ {} lines gained coverage on unchanged code.\n\n",
340 diff.indirect_newly_uncovered(),
341 diff.indirect_newly_covered()
342 ));
343 out.push_str("<details><summary>Indirect changes</summary>\n\n");
344 for change in &diff.indirect {
345 let transition = if change.became_covered {
346 "π’ uncovered β covered"
347 } else {
348 "π΄ covered β uncovered"
349 };
350 out.push_str(&format!(
351 "- `{}:{}` {}\n",
352 change.path, change.head_line, transition
353 ));
354 }
355 out.push_str("\n</details>\n\n");
356}
357
358fn render_footer(opts: &RenderOptions, out: &mut String) {
359 match opts.artifact_url.as_deref().filter(|u| !u.is_empty()) {
360 Some(artifact) => {
361 out.push_str(&format!(
362 "<sub>π¦ [Full per-file coverage summary]({artifact})"
363 ));
364 if let Some(run) = opts.run_url.as_deref().filter(|u| !u.is_empty()) {
365 out.push_str(&format!(" Β· [run summary]({run})"));
366 }
367 out.push_str("</sub>\n");
368 }
369 None => {
370 out.push_str(
371 "<sub>Full per-file summary is attached as the **coverage-summary** build \
372 artifact.</sub>\n",
373 );
374 }
375 }
376}
377
378#[derive(Debug, Clone, Serialize)]
385struct CoverageDiffView {
386 explanation: FieldExplanation,
387 patch_coverage: PatchView,
388 #[serde(skip_serializing_if = "Vec::is_empty")]
389 uncovered_new_lines: Vec<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 project_delta: Option<ProjectDeltaView>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 indirect_changes: Option<IndirectView>,
394}
395
396#[derive(Debug, Clone, Serialize)]
397struct PatchView {
398 percent: Option<f64>,
399 covered: u64,
400 total: u64,
401 files: Vec<FilePatchView>,
402}
403
404#[derive(Debug, Clone, Serialize)]
405struct FilePatchView {
406 path: String,
407 percent: Option<f64>,
408 covered: u64,
409 total: u64,
410 #[serde(skip_serializing_if = "Vec::is_empty")]
411 uncovered_lines: Vec<u32>,
412}
413
414#[derive(Debug, Clone, Serialize)]
415struct ProjectDeltaView {
416 total_before: Option<f64>,
417 total_after: Option<f64>,
418 files: Vec<FileDeltaView>,
419 #[serde(skip_serializing_if = "Vec::is_empty")]
422 notable_unchanged: Vec<FileDeltaView>,
423}
424
425#[derive(Debug, Clone, Serialize)]
426struct FileDeltaView {
427 path: String,
428 before: Option<f64>,
429 after: Option<f64>,
430 delta: Option<f64>,
431}
432
433#[derive(Debug, Clone, Serialize)]
434struct IndirectView {
435 newly_covered: usize,
436 newly_uncovered: usize,
437 lines: Vec<IndirectLineView>,
438}
439
440#[derive(Debug, Clone, Serialize)]
441struct IndirectLineView {
442 path: String,
443 head_line: u32,
444 base_line: u32,
445 transition: String,
446}
447
448impl CoverageDiffView {
449 fn build(diff: &CoverageDiff, _opts: &RenderOptions) -> Self {
450 let patch_coverage = PatchView {
451 percent: diff.patch.percent().map(round2),
452 covered: diff.patch.covered,
453 total: diff.patch.total(),
454 files: diff
455 .file_patches
456 .iter()
457 .map(|fp| FilePatchView {
458 path: fp.path.clone(),
459 percent: fp.patch.percent().map(round2),
460 covered: fp.patch.covered,
461 total: fp.patch.total(),
462 uncovered_lines: fp.uncovered_lines.clone(),
463 })
464 .collect(),
465 };
466
467 let uncovered_new_lines = diff
468 .uncovered_new_lines
469 .iter()
470 .map(|(path, line)| format!("{path}:{line}"))
471 .collect();
472
473 let (project_delta, indirect_changes) = if diff.has_baseline {
474 let file_delta_view = |fd: &crate::coverage::analysis::FileDelta| FileDeltaView {
475 path: fd.path.clone(),
476 before: fd.before.map(round2),
477 after: fd.after.map(round2),
478 delta: fd.delta().map(round2),
479 };
480 let project_delta = ProjectDeltaView {
481 total_before: diff.total_before.map(round2),
482 total_after: diff.total_after.map(round2),
483 files: diff.file_deltas.iter().map(file_delta_view).collect(),
484 notable_unchanged: diff.notable_unchanged.iter().map(file_delta_view).collect(),
485 };
486 let indirect_changes = IndirectView {
487 newly_covered: diff.indirect_newly_covered(),
488 newly_uncovered: diff.indirect_newly_uncovered(),
489 lines: diff
490 .indirect
491 .iter()
492 .map(|c| IndirectLineView {
493 path: c.path.clone(),
494 head_line: c.head_line,
495 base_line: c.base_line,
496 transition: if c.became_covered {
497 "uncovered_to_covered".to_string()
498 } else {
499 "covered_to_uncovered".to_string()
500 },
501 })
502 .collect(),
503 };
504 (Some(project_delta), Some(indirect_changes))
505 } else {
506 (None, None)
507 };
508
509 Self {
510 explanation: explanation(),
511 patch_coverage,
512 uncovered_new_lines,
513 project_delta,
514 indirect_changes,
515 }
516 }
517
518 fn update_field_presence(&mut self) {
520 let has_patch_files = !self.patch_coverage.files.is_empty();
521 let has_uncovered = !self.uncovered_new_lines.is_empty();
522 let has_baseline = self.project_delta.is_some();
523 let has_indirect = self
524 .indirect_changes
525 .as_ref()
526 .is_some_and(|i| !i.lines.is_empty());
527 for field in &mut self.explanation.fields {
528 field.present = match field.name.as_str() {
529 "patch_coverage.percent" | "patch_coverage.covered" | "patch_coverage.total" => {
530 true
531 }
532 "patch_coverage.files[].path" => has_patch_files,
533 "uncovered_new_lines[]" => has_uncovered,
534 "project_delta.total_after" | "project_delta.files[].path" => has_baseline,
535 "indirect_changes.lines[].path" => has_indirect,
536 _ => false,
537 };
538 }
539 }
540}
541
542fn explanation() -> FieldExplanation {
544 fn field(name: &str, text: &str) -> FieldDocumentation {
545 FieldDocumentation {
546 name: name.to_string(),
547 text: text.to_string(),
548 command: None,
549 present: false,
550 }
551 }
552 FieldExplanation {
553 text: "Diff/patch coverage analysis. `patch_coverage` attributes coverage to the lines \
554 this diff added (needs only the head report + diff). `project_delta` and \
555 `indirect_changes` are present only when a baseline report was supplied."
556 .to_string(),
557 fields: vec![
558 field(
559 "patch_coverage.percent",
560 "Percentage of added, instrumented lines that are covered.",
561 ),
562 field("patch_coverage.covered", "Count of covered added lines."),
563 field(
564 "patch_coverage.total",
565 "Count of added, instrumented lines (the patch-coverage denominator).",
566 ),
567 field(
568 "patch_coverage.files[].path",
569 "Per-file patch coverage for files that added instrumented lines.",
570 ),
571 field(
572 "uncovered_new_lines[]",
573 "Actionable `file:line` list of added lines that are not covered.",
574 ),
575 field(
576 "project_delta.total_after",
577 "Project line coverage before/after; present only with a baseline report.",
578 ),
579 field(
580 "project_delta.files[].path",
581 "Per-file before/after coverage and delta; present only with a baseline report.",
582 ),
583 field(
584 "indirect_changes.lines[].path",
585 "Lines whose coverage flipped without their content changing; needs a baseline.",
586 ),
587 ],
588 }
589}
590
591#[cfg(test)]
592#[allow(clippy::unwrap_used, clippy::expect_used)]
593mod tests {
594 use super::*;
595 use crate::coverage::analysis::{FileDelta, FilePatch, IndirectChange, PatchCoverage};
596
597 #[test]
598 fn fmt_num_trims_trailing_zeros() {
599 assert_eq!(fmt_num(100.0), "100");
600 assert_eq!(fmt_num(65.4), "65.4");
601 assert_eq!(fmt_num(65.432), "65.43");
602 assert_eq!(fmt_num(50.0), "50");
603 assert_eq!(fmt_num(-0.001), "0");
604 }
605
606 #[test]
607 fn collapse_ranges_groups_consecutive() {
608 assert_eq!(collapse_ranges(&[5]), "5");
609 assert_eq!(collapse_ranges(&[9, 10, 11]), "9-11");
610 assert_eq!(collapse_ranges(&[5, 9, 10, 11, 20]), "5, 9-11, 20");
611 }
612
613 #[test]
614 fn sha_ref_links_when_url_present() {
615 assert_eq!(sha_ref("abcdef1234", None), "`abcdef1`");
616 assert_eq!(
617 sha_ref("abcdef1234", Some("https://x/commit")),
618 "[`abcdef1`](https://x/commit/abcdef1234)"
619 );
620 }
621
622 fn sample_diff() -> CoverageDiff {
623 CoverageDiff {
624 patch: PatchCoverage {
625 covered: 4,
626 uncovered: 1,
627 },
628 file_patches: vec![FilePatch {
629 path: "src/a.rs".to_string(),
630 patch: PatchCoverage {
631 covered: 4,
632 uncovered: 1,
633 },
634 uncovered_lines: vec![9],
635 }],
636 uncovered_new_lines: vec![("src/a.rs".to_string(), 9)],
637 total_after: Some(80.0),
638 ..Default::default()
639 }
640 }
641
642 #[test]
643 fn markdown_without_baseline_has_patch_section() {
644 let diff = sample_diff();
645 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
646 assert!(md.contains("## Coverage"));
647 assert!(md.contains("Total: **80%**"));
648 assert!(md.contains("### Patch coverage"));
649 assert!(md.contains("Patch: **80%** (4/5 new lines covered)"));
650 assert!(md.contains("`src/a.rs:9`"));
651 assert!(md.contains("No baseline available yet"));
652 }
653
654 #[test]
655 fn markdown_with_baseline_shows_total_delta_and_indirect() {
656 let mut diff = sample_diff();
657 diff.has_baseline = true;
658 diff.total_before = Some(75.0);
659 diff.indirect = vec![IndirectChange {
660 path: "src/b.rs".to_string(),
661 base_line: 5,
662 head_line: 5,
663 became_covered: false,
664 }];
665 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
666 assert!(md.contains("π’ 5 pp vs `main`"));
667 assert!(md.contains("### Indirect coverage changes"));
668 assert!(md.contains("`src/b.rs:5`"));
669 }
670
671 #[test]
672 fn json_round_trips() {
673 let diff = sample_diff();
674 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
675 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
676 assert_eq!(value["patch_coverage"]["covered"], 4);
677 assert_eq!(value["patch_coverage"]["total"], 5);
678 assert_eq!(value["uncovered_new_lines"][0], "src/a.rs:9");
679 assert!(value.get("project_delta").is_none());
681 }
682
683 #[test]
684 fn yaml_renders() {
685 let diff = sample_diff();
686 let yaml = render(&diff, &RenderOptions::default(), OutputFormat::Yaml).unwrap();
687 assert!(yaml.contains("patch_coverage:"));
688 assert!(yaml.contains("explanation:"));
689 }
690
691 fn baseline_diff() -> CoverageDiff {
695 CoverageDiff {
696 patch: PatchCoverage {
697 covered: 2,
698 uncovered: 4,
699 },
700 file_patches: vec![FilePatch {
701 path: "src/a.rs".to_string(),
702 patch: PatchCoverage {
703 covered: 2,
704 uncovered: 4,
705 },
706 uncovered_lines: vec![9, 10, 11, 15],
707 }],
708 uncovered_new_lines: vec![
709 ("src/a.rs".to_string(), 9),
710 ("src/a.rs".to_string(), 10),
711 ("src/a.rs".to_string(), 11),
712 ("src/a.rs".to_string(), 15),
713 ],
714 has_baseline: true,
715 total_after: Some(80.0),
716 total_before: Some(80.0), file_deltas: vec![
718 FileDelta {
719 path: "src/new.rs".to_string(),
720 before: None,
721 after: Some(50.0),
722 },
723 FileDelta {
724 path: "src/down.rs".to_string(),
725 before: Some(100.0),
726 after: Some(70.0),
727 },
728 FileDelta {
729 path: "src/up.rs".to_string(),
730 before: Some(70.0),
731 after: Some(90.0),
732 },
733 FileDelta {
734 path: "src/tiny.rs".to_string(),
735 before: Some(90.0),
736 after: Some(90.02), },
738 FileDelta {
739 path: "src/gone.rs".to_string(),
740 before: Some(50.0),
741 after: None, },
743 ],
744 notable_unchanged: Vec::new(),
745 indirect: Vec::new(),
746 }
747 }
748
749 #[test]
750 fn markdown_delta_table_and_footer() {
751 let diff = baseline_diff();
752 let opts = RenderOptions {
753 artifact_url: Some("https://artifact".to_string()),
754 run_url: Some("https://run".to_string()),
755 collapse_ranges: true,
756 ..Default::default()
757 };
758 let md = render(&diff, &opts, OutputFormat::Markdown).unwrap();
759 assert!(md.contains("βͺ 0 pp vs `main`"));
760 assert!(md.contains("| `src/new.rs` | β | 50% | π new |"));
761 assert!(md.contains("π΄ -30 pp"));
762 assert!(md.contains("π’ 20 pp"));
763 assert!(md.contains("| `src/gone.rs` | 50% | β | π΄ -50 pp |"));
764 assert!(!md.contains("tiny.rs"), "below-EPS row must be filtered");
765 assert!(md.contains("9-11, 15"));
767 assert!(md.contains("[Full per-file coverage summary](https://artifact)"));
769 assert!(md.contains("[run summary](https://run)"));
770 }
771
772 #[test]
773 fn markdown_comparing_line_and_covered_indirect() {
774 let mut diff = sample_diff();
775 diff.has_baseline = true;
776 diff.total_before = Some(80.0);
777 diff.indirect = vec![IndirectChange {
778 path: "src/b.rs".to_string(),
779 base_line: 5,
780 head_line: 5,
781 became_covered: true,
782 }];
783 let opts = RenderOptions {
784 base_sha: Some("abcdef123".to_string()),
785 head_sha: Some("fedcba321".to_string()),
786 commit_url: Some("https://x/commit".to_string()),
787 ..Default::default()
788 };
789 let md = render(&diff, &opts, OutputFormat::Markdown).unwrap();
790 assert!(md.contains("Comparing [`abcdef1`](https://x/commit/abcdef123)"));
791 assert!(md.contains("π’ uncovered β covered"));
792 }
793
794 #[test]
795 fn markdown_no_per_file_changes() {
796 let mut diff = sample_diff();
797 diff.has_baseline = true;
798 diff.total_before = Some(80.0);
799 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
801 assert!(md.contains("_No per-file coverage changes vs `main`._"));
802 }
803
804 #[test]
805 fn markdown_baseline_without_total_before() {
806 let mut diff = sample_diff();
807 diff.has_baseline = true;
808 diff.total_before = None;
809 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
810 assert!(md.contains("Total: **80%**"));
811 assert!(!md.contains("pp vs"));
812 }
813
814 #[test]
815 fn markdown_no_added_lines() {
816 let diff = CoverageDiff {
817 total_after: Some(50.0),
818 ..Default::default()
819 };
820 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
821 assert!(md.contains("_No new executable lines added by this diff._"));
822 }
823
824 #[test]
825 fn json_and_yaml_with_baseline_include_project_delta() {
826 let diff = baseline_diff();
827 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
828 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
829 assert!(value.get("project_delta").is_some());
830 assert_eq!(value["project_delta"]["total_after"], 80.0);
831 assert!(value.get("indirect_changes").is_some());
832
833 let yaml = render(&diff, &RenderOptions::default(), OutputFormat::Yaml).unwrap();
834 assert!(yaml.contains("project_delta:"));
835 }
836
837 #[test]
838 fn markdown_renders_notable_unchanged_note() {
839 let mut diff = baseline_diff();
840 diff.notable_unchanged = vec![
841 FileDelta {
842 path: "src/other.rs".to_string(),
843 before: Some(80.0),
844 after: Some(60.0),
845 },
846 FileDelta {
848 path: "src/fresh.rs".to_string(),
849 before: None,
850 after: Some(55.0),
851 },
852 ];
853 let md = render(&diff, &RenderOptions::default(), OutputFormat::Markdown).unwrap();
854 assert!(md.contains("unchanged file(s) also moved (not attributed to this PR)"));
855 assert!(md.contains("`src/other.rs`"));
856 assert!(md.contains("π΄ -20 pp"));
857 assert!(md.contains("| `src/fresh.rs` | β | 55% | π new |"));
858 }
859
860 #[test]
861 fn json_includes_notable_unchanged() {
862 let mut diff = baseline_diff();
863 diff.notable_unchanged = vec![FileDelta {
864 path: "src/other.rs".to_string(),
865 before: Some(80.0),
866 after: Some(60.0),
867 }];
868 let json = render(&diff, &RenderOptions::default(), OutputFormat::Json).unwrap();
869 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
870 assert_eq!(
871 value["project_delta"]["notable_unchanged"][0]["path"],
872 "src/other.rs"
873 );
874 }
875}