Skip to main content

omni_dev/coverage/
render.rs

1//! Rendering of a [`CoverageDiff`] to markdown, YAML, or JSON.
2//!
3//! The markdown renderer reproduces the PR comment that the retired
4//! `scripts/coverage-comment.sh` shell renderer produced β€” same `## Coverage`
5//! header, total line with 🟒/πŸ”΄ direction, merge-baseβ†’head `Comparing` line, the
6//! EPS-filtered per-file before/after/Ξ” table, and the artifact footer β€” plus a
7//! `### Patch coverage` section (the headline metric the aggregate comment could
8//! never show) and an indirect-changes section. CI renders this comment via
9//! `omni-dev coverage diff --format markdown` (see `.github/workflows/ci.yml`).
10
11use anyhow::Result;
12use serde::Serialize;
13
14use super::analysis::CoverageDiff;
15use crate::data::{FieldDocumentation, FieldExplanation};
16
17/// Minimum per-file change (percentage points) for a row to be listed, matching
18/// the original coverage comment (suppresses floating-point noise).
19const EPS: f64 = 0.05;
20
21/// Output serialisation for `coverage diff`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum OutputFormat {
24    /// Markdown PR comment (default).
25    Markdown,
26    /// YAML following the project's structured-output conventions.
27    Yaml,
28    /// JSON for programmatic use.
29    Json,
30}
31
32/// Decoration inputs and options for rendering.
33#[derive(Debug, Clone, Default)]
34pub struct RenderOptions {
35    /// Link to the full coverage-summary artifact.
36    pub artifact_url: Option<String>,
37    /// Link to the CI run.
38    pub run_url: Option<String>,
39    /// Base (merge-base) commit SHA.
40    pub base_sha: Option<String>,
41    /// Head commit SHA.
42    pub head_sha: Option<String>,
43    /// Commit-URL prefix for linking SHAs (e.g. `https://…/<repo>/commit`).
44    pub commit_url: Option<String>,
45    /// Collapse consecutive uncovered new lines into ranges (e.g. `9-11`).
46    pub collapse_ranges: bool,
47}
48
49/// Renders `diff` in the requested `format`.
50pub 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
66// ---------------------------------------------------------------------------
67// Number formatting (mirrors the jq `rnd`/`pct` helpers of the original comment)
68// ---------------------------------------------------------------------------
69
70/// Rounds to two decimal places, normalising negative zero to `0.0`.
71fn 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
80/// Formats a number with up to two decimals, trailing zeros trimmed (`100`, `65.4`).
81fn 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
86/// Formats an optional percentage; `None` renders as an em dash.
87fn pct(x: Option<f64>) -> String {
88    match x {
89        Some(v) => format!("{}%", fmt_num(v)),
90        None => "β€”".to_string(),
91    }
92}
93
94/// Direction emoji for a percentage-point delta.
95fn arrow(d: f64) -> &'static str {
96    if d > 0.0 {
97        "🟒"
98    } else if d < 0.0 {
99        "πŸ”΄"
100    } else {
101        "βšͺ"
102    }
103}
104
105/// Renders a commit ref as a short, optionally-linked SHA.
106fn 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
114/// Collapses a sorted, de-duplicated line list into `5, 9-11` style ranges.
115fn 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
135// ---------------------------------------------------------------------------
136// Markdown
137// ---------------------------------------------------------------------------
138
139fn render_markdown(diff: &CoverageDiff, opts: &RenderOptions) -> String {
140    let mut out = String::new();
141    out.push_str("## Coverage\n\n");
142
143    // Total line.
144    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    // Comparing line.
164    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    // Build rows as the original comment did: new files, or |delta| >= EPS.
197    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    // New files (no delta) sort to the top, then largest decreases first.
218    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
248/// Renders the magnitude-gated note for unchanged files whose coverage moved
249/// substantially β€” flagged as *not* attributable to the PR (measurement variance
250/// or a cross-file effect like a removed test), kept collapsed so it does not
251/// crowd out the actionable sections.
252fn 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// ---------------------------------------------------------------------------
379// Structured (YAML / JSON) view
380// ---------------------------------------------------------------------------
381
382/// Serializable view of a [`CoverageDiff`] for YAML/JSON output, carrying the
383/// field-presence explanation block the project uses for structured output.
384#[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    /// Unchanged files (not touched by the diff) that nonetheless moved
420    /// substantially β€” flagged as not attributable to the PR.
421    #[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    /// Sets the `present` flag on each documented field based on the data.
519    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
542/// Builds the static field-explanation block for the coverage view.
543fn 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        // Baseline-only sections absent without a baseline.
680        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    /// A baseline diff exercising the delta table (new file, decrease, increase,
692    /// below-EPS filtering, an em-dash `After`), the patch table with range
693    /// collapsing, and the artifact footer.
694    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), // equal β†’ βšͺ 0 pp
717            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), // below EPS β†’ filtered out
737                },
738                FileDelta {
739                    path: "src/gone.rs".to_string(),
740                    before: Some(50.0),
741                    after: None, // After renders as em dash
742                },
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        // Patch table with collapsed ranges.
766        assert!(md.contains("9-11, 15"));
767        // Artifact footer with run link.
768        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        // No file_deltas β†’ "no per-file coverage changes".
800        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            // Absent from the baseline β†’ delta() is None β†’ renders as "πŸ†• new".
847            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}