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.
95///
96/// The direction is taken from the *displayed* (rounded) value, not the raw
97/// one: every caller prints [`fmt_num`] beside this emoji, and `fmt_num` rounds
98/// through [`round2`]. Deriving the arrow from the raw delta let a move in
99/// `(-0.005, 0)` render as a red arrow next to `0 pp` β€” an alarm colour beside a
100/// number that says nothing moved.
101fn 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
112/// Direction emoji for the *headline* total delta.
113///
114/// The per-file sections have suppressed cross-run measurement variance since
115/// #973 β€” delta-table rows need `|d| >= EPS`, and an untouched file needs a net
116/// move of `NOTABLE_UNCHANGED_LINES` covered lines β€” but the headline had no
117/// equivalent gate, so every flip those sections hid still accumulated here and
118/// was painted red. Applying the same `EPS` tolerance keeps the comment
119/// internally consistent: a sub-tolerance total move is neutral, while the
120/// number itself is still printed truthfully beside it.
121fn headline_arrow(d: f64) -> &'static str {
122    if d.abs() < EPS {
123        "βšͺ"
124    } else {
125        arrow(d)
126    }
127}
128
129/// Annotates the headline when the total moved but no per-file section can
130/// account for it β€” the move is then, by construction, not attributable to this
131/// diff. Without this a reader sees a total delta above a comment whose every
132/// other section says nothing changed.
133fn 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
147/// Renders a commit ref as a short, optionally-linked SHA.
148fn 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
156/// Collapses a sorted, de-duplicated line list into `5, 9-11` style ranges.
157fn 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
177// ---------------------------------------------------------------------------
178// Markdown
179// ---------------------------------------------------------------------------
180
181fn render_markdown(diff: &CoverageDiff, opts: &RenderOptions) -> String {
182    let mut out = String::new();
183    out.push_str("## Coverage\n\n");
184
185    // Total line.
186    if diff.has_baseline {
187        match (diff.total_after, diff.total_before) {
188            (after, Some(before)) => {
189                // The percentage displayed is always the real measured value;
190                // the movement is computed from the `tolerate`-masked one, so a
191                // silenced flip cannot move the arrow or the number beside it.
192                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    // Comparing line.
211    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    // Also without a baseline: `ignore` still shapes the total and the patch.
232    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    // Build rows as the original comment did: new files, or |delta| >= EPS.
246    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            // `delta()` reads the `tolerate`-masked coverage, so a silenced flip
257            // does not produce a row; `after` stays the real displayed value.
258            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    // New files (no delta) sort to the top, then largest decreases first.
269    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
299/// Renders the magnitude-gated note for unchanged files whose coverage moved
300/// substantially β€” flagged as *not* attributable to the PR (measurement variance
301/// or a cross-file effect like a removed test), kept collapsed so it does not
302/// crowd out the actionable sections.
303fn 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
333/// Renders the collapsed note listing every source-marker region that applied.
334///
335/// Silencing is never invisible: a reviewer can always see which regions were
336/// ignored or tolerated, where they are, and why their author silenced them.
337fn 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// ---------------------------------------------------------------------------
470// Structured (YAML / JSON) view
471// ---------------------------------------------------------------------------
472
473/// Serializable view of a [`CoverageDiff`] for YAML/JSON output, carrying the
474/// field-presence explanation block the project uses for structured output.
475#[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    /// Source-marker regions that applied. Empty when no marker was found.
486    #[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    /// Which revision the region was observed on: `both`, `head`, or `base`.
495    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    /// The real, measured head coverage.
523    total_after: Option<f64>,
524    /// Head coverage with `tolerate` masking applied β€” the value the reported
525    /// deltas are computed from. Present only when masking changed it.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    total_after_effective: Option<f64>,
528    files: Vec<FileDeltaView>,
529    /// Unchanged files (not touched by the diff) that nonetheless moved
530    /// substantially β€” flagged as not attributable to the PR.
531    #[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    /// The real, measured head coverage.
540    after: Option<f64>,
541    /// Head coverage with `tolerate` masking applied. Present only when masking
542    /// changed it, in which case `delta` is `after_effective - before` rather
543    /// than `after - before`.
544    #[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    /// Sets the `present` flag on each documented field based on the data.
656    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
681/// Builds the static field-explanation block for the coverage view.
682fn 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    /// #1591: the arrow must agree with the number printed beside it. A delta
820    /// inside the rounding interval prints `0 pp`, so it must be neutral rather
821    /// than raising a red alarm next to a number that says nothing moved.
822    #[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    /// The same pairing in the per-file table, which the EPS row filter happens
837    /// to protect, and in the notable-unchanged table, which it does not: that
838    /// section is gated on *covered lines* (>= 10), so a large file can reach it
839    /// with a sub-rounding percentage-point move.
840    #[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    /// #1592: the #2444 shape β€” a docs-only PR whose headline moved because one
858    /// CPU-conditional function flipped between two runner CPUs, while every
859    /// per-file section of the same comment reported nothing.
860    #[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    /// Above the tolerance the headline still reports the direction β€” but with
876    /// nothing below it to account for the move, it says so.
877    #[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    /// A move a per-file row *does* explain is left unannotated.
891    #[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    /// A notable-unchanged entry also counts as an explanation, even though it
899    /// is not attributed to the PR: the reader can see where the move came from.
900    #[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    /// #1593, the motivating case: the headline shows the *real* percentage but
927    /// takes its movement from the masked one, so the reported number stays
928    /// truthful while the silenced flip stops moving the needle.
929    #[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    /// Silencing is never invisible.
949    #[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    /// `ignore` shapes the total and the patch even with no baseline, so its
982    /// note must appear there too.
983    #[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    /// The structured views must agree with the markdown: `delta` is the masked
1003    /// value, and `after`/`total_after` stay real, with the effective value
1004    /// alongside so a consumer can see why they differ.
1005    #[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    /// An unmasked run must not grow the effective fields β€” they exist only to
1036    /// explain a discrepancy, so an absent one means "there was none".
1037    #[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        // Baseline-only sections absent without a baseline.
1061        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    /// A baseline diff exercising the delta table (new file, decrease, increase,
1073    /// below-EPS filtering, an em-dash `After`), the patch table with range
1074    /// collapsing, and the artifact footer.
1075    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), // equal β†’ βšͺ 0 pp
1098            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                // below EPS β†’ filtered out
1103                FileDelta::new("src/tiny.rs", Some(90.0), Some(90.02)),
1104                // `After` renders as an em dash
1105                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        // Patch table with collapsed ranges.
1128        assert!(md.contains("9-11, 15"));
1129        // Artifact footer with run link.
1130        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        // No file_deltas β†’ "no per-file coverage changes".
1162        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            // Absent from the baseline β†’ delta() is None β†’ renders as "πŸ†• new".
1205            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}