Skip to main content

supercov_engine/
patch_view.rs

1//! Changed-line coverage: are the lines this change touches tested?
2//!
3//! The denominator is the part that has to be right. It is the intersection of
4//! two facts already established elsewhere: the lines a patch added or
5//! modified, and the lines the run's language adapter decided were executable.
6//! Taking the intersection means comments, blank lines and declarations are
7//! excluded because the adapter already excluded them, not because this module
8//! guessed at syntax it does not parse.
9//!
10//! Git invocation lives at the edge, in the CLI. Everything here is a pure
11//! function of a diff and a run view, so the cases that matter -- renames,
12//! deletions, comment-only changes, files absent from the recording -- are
13//! testable without a repository.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::Serialize;
18
19use crate::run_view::{Metric, RunView};
20
21/// Added and modified lines per file, as a unified diff describes them.
22pub type ChangedLines = BTreeMap<String, BTreeSet<usize>>;
23
24/// Parse `git diff --unified=0` output into the lines each file gained.
25///
26/// Only the post-image side is read. A deleted line has no line in the new
27/// file to cover, and counting it would ask a patch to test code it removed.
28pub fn changed_lines(diff: &str) -> ChangedLines {
29    let mut changed = ChangedLines::new();
30    let mut file: Option<String> = None;
31    for line in diff.lines() {
32        if let Some(rest) = line.strip_prefix("+++ ") {
33            // `/dev/null` is a deletion; it has no post-image to attribute to.
34            file = rest
35                .strip_prefix("b/")
36                .or(Some(rest))
37                .filter(|path| *path != "/dev/null")
38                .map(|path| path.trim_end().to_owned());
39            continue;
40        }
41        let Some(rest) = line.strip_prefix("@@ ") else {
42            continue;
43        };
44        let Some(file) = file.as_ref() else {
45            continue;
46        };
47        // "@@ -12,0 +13,4 @@" -- the "+" side is start[,count], count
48        // defaulting to 1 and 0 meaning a pure deletion.
49        let Some(plus) = rest.split_whitespace().find(|part| part.starts_with('+')) else {
50            continue;
51        };
52        let mut numbers = plus[1..].split(',');
53        let Some(start) = numbers.next().and_then(|n| n.parse::<usize>().ok()) else {
54            continue;
55        };
56        let count = numbers
57            .next()
58            .map_or(Some(1), |n| n.parse::<usize>().ok())
59            .unwrap_or(1);
60        if count == 0 {
61            continue;
62        }
63        changed
64            .entry(file.clone())
65            .or_default()
66            .extend(start..start + count);
67    }
68    changed
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct PatchFile {
74    pub file: String,
75    /// Changed lines the run measured, in source order.
76    pub executable: Vec<usize>,
77    pub uncovered: Vec<usize>,
78    /// The file changed but the run never measured it. That is not the same as
79    /// a file with no executable change, and calling it zero uncovered lines
80    /// would report success for code nothing ran.
81    pub missing_from_run: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct PatchView {
87    pub schema_version: u32,
88    pub run: String,
89    pub covered: usize,
90    pub eligible: usize,
91    pub files: Vec<PatchFile>,
92    /// Files that changed and are absent from the run, named so the reader can
93    /// tell an untested change from an unmeasured one.
94    pub missing_from_run: Vec<String>,
95}
96
97impl PatchView {
98    /// No executable line changed. Honest absence, not a perfect score: a
99    /// comment-only patch should say so rather than claim full coverage.
100    pub fn is_empty(&self) -> bool {
101        self.eligible == 0
102    }
103
104    pub fn meets(&self, floor_ppm: u64) -> bool {
105        u128::from(self.covered as u64) * 1_000_000
106            >= u128::from(floor_ppm) * u128::from(self.eligible as u64)
107    }
108
109    pub fn percentage(&self) -> Option<f64> {
110        (self.eligible > 0).then(|| self.covered as f64 * 100.0 / self.eligible as f64)
111    }
112}
113
114/// Intersect a patch with what the run measured.
115pub fn build(view: &RunView, changed: &ChangedLines) -> PatchView {
116    let measured = view
117        .files
118        .iter()
119        .map(|file| {
120            let uncovered = file
121                .uncovered_lines
122                .iter()
123                .copied()
124                .collect::<BTreeSet<_>>();
125            let eligible = file
126                .metric(Metric::Lines)
127                .map(|metric| metric.eligible)
128                .unwrap_or(0);
129            (file.file.as_str(), (uncovered, eligible))
130        })
131        .collect::<BTreeMap<_, _>>();
132
133    let mut files = Vec::new();
134    let mut missing = Vec::new();
135    let (mut covered, mut eligible) = (0_usize, 0_usize);
136    for (path, lines) in changed {
137        let Some((uncovered_lines, _)) = measured.get(path.as_str()) else {
138            // Only product source is worth reporting as unmeasured. A changed
139            // README, lockfile or test is not a coverage gap, and a list that
140            // is mostly those stops being read -- which costs more than the
141            // occasional new file it would have caught.
142            if view.looks_like_source(path) {
143                missing.push(path.clone());
144                files.push(PatchFile {
145                    file: path.clone(),
146                    executable: Vec::new(),
147                    uncovered: Vec::new(),
148                    missing_from_run: true,
149                });
150            }
151            continue;
152        };
153        // A changed line is executable exactly when the run measured it. The
154        // run view lists a file's uncovered measured lines; every other
155        // measured line in it was covered, so membership of the file plus the
156        // adapter's own line set is what decides this.
157        let executable = lines
158            .iter()
159            .copied()
160            .filter(|line| view.measured_line(path, *line))
161            .collect::<Vec<_>>();
162        let uncovered = executable
163            .iter()
164            .copied()
165            .filter(|line| uncovered_lines.contains(line))
166            .collect::<Vec<_>>();
167        covered += executable.len() - uncovered.len();
168        eligible += executable.len();
169        if !executable.is_empty() {
170            files.push(PatchFile {
171                file: path.clone(),
172                executable,
173                uncovered,
174                missing_from_run: false,
175            });
176        }
177    }
178    PatchView {
179        schema_version: crate::run_view::RUN_VIEW_SCHEMA_VERSION,
180        run: view.run.clone(),
181        covered,
182        eligible,
183        files,
184        missing_from_run: missing,
185    }
186}
187
188/// Collapse consecutive uncovered lines into inclusive ranges, so a block of
189/// twenty untested lines is one annotation rather than twenty.
190pub fn ranges(lines: &[usize]) -> Vec<(usize, usize)> {
191    let mut out: Vec<(usize, usize)> = Vec::new();
192    for line in lines {
193        match out.last_mut() {
194            Some(last) if last.1 + 1 == *line => last.1 = *line,
195            _ => out.push((*line, *line)),
196        }
197    }
198    out
199}
200
201/// Escape a value for a GitHub Actions workflow command.
202///
203/// The transport is line based and delimits properties with `,` and `::`, so
204/// an unescaped newline or colon in a path does not merely look wrong -- it
205/// ends the command early and lets the remainder be read as a new one.
206pub fn escape_property(value: &str) -> String {
207    value
208        .replace('%', "%25")
209        .replace('\r', "%0D")
210        .replace('\n', "%0A")
211        .replace(':', "%3A")
212        .replace(',', "%2C")
213}
214
215pub fn escape_message(value: &str) -> String {
216    value
217        .replace('%', "%25")
218        .replace('\r', "%0D")
219        .replace('\n', "%0A")
220}
221
222/// Render GitHub Actions annotations for the uncovered ranges, capped.
223///
224/// `prefix` is where the run stood below the top of the repository (`web/`, or
225/// nothing): a workflow finds a file by its name from the top.
226pub fn annotations(view: &PatchView, cap: usize, prefix: &str) -> Vec<String> {
227    let mut out = Vec::new();
228    for file in &view.files {
229        for (start, end) in ranges(&file.uncovered) {
230            if out.len() == cap {
231                return out;
232            }
233            let lines = if start == end {
234                format!("line {start}")
235            } else {
236                format!("lines {start} to {end}")
237            };
238            out.push(format!(
239                "::warning file={},line={},endLine={}::{}",
240                escape_property(&format!("{prefix}{}", file.file)),
241                start,
242                end,
243                escape_message(&format!(
244                    "Changed {lines} not covered by the selected tests."
245                )),
246            ));
247        }
248    }
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::run_view::{Applicability, FileView, MetricView, RUN_VIEW_SCHEMA_VERSION};
256
257    fn file(name: &str, eligible: usize, measured: &[usize], uncovered: &[usize]) -> FileView {
258        FileView {
259            file: name.into(),
260            metrics: vec![MetricView {
261                metric: Metric::Lines,
262                covered: eligible - uncovered.len(),
263                eligible,
264                applicability: Applicability::Measured,
265            }],
266            measured_lines: measured.to_vec(),
267            uncovered_lines: uncovered.to_vec(),
268            missing_branches: Vec::new(),
269            missing_conditions: Vec::new(),
270            functions: Vec::new(),
271            branches: Vec::new(),
272            conditions: Vec::new(),
273        }
274    }
275
276    fn view(files: Vec<FileView>) -> RunView {
277        let source_neighbourhoods = files
278            .iter()
279            .filter_map(|file| {
280                let (directory, name) = file.file.rsplit_once('/').unwrap_or(("", &file.file));
281                let (_, extension) = name.rsplit_once('.')?;
282                Some((directory.to_owned(), extension.to_owned()))
283            })
284            .collect();
285        RunView {
286            source_neighbourhoods,
287            schema_version: RUN_VIEW_SCHEMA_VERSION,
288            run: "run_1".into(),
289            generated_at: "now".into(),
290            suite_passed: true,
291            stale: false,
292            stale_reasons: Vec::new(),
293            complete: true,
294            limitations: Vec::new(),
295            totals: Vec::new(),
296            files,
297        }
298    }
299
300    #[test]
301    fn only_the_post_image_of_a_diff_becomes_a_denominator() {
302        // A deleted line has nothing left to cover, and a pure deletion hunk
303        // (`+13,0`) must not claim line 13 of the new file.
304        let diff = "\
305--- a/src/a.ts
306+++ b/src/a.ts
307@@ -1,2 +1,3 @@
308@@ -20,4 +21,0 @@
309--- a/src/gone.ts
310+++ /dev/null
311@@ -1,5 +0,0 @@
312--- a/src/b.ts
313+++ b/src/b.ts
314@@ -7 +7 @@
315";
316        let changed = changed_lines(diff);
317        assert_eq!(changed["src/a.ts"], BTreeSet::from([1, 2, 3]));
318        assert_eq!(changed["src/b.ts"], BTreeSet::from([7]));
319        assert!(!changed.contains_key("/dev/null"));
320        assert!(!changed.contains_key("src/gone.ts"));
321    }
322
323    #[test]
324    fn the_denominator_is_the_adapter_s_executable_lines_not_every_changed_line() {
325        // Lines 1 and 5 are comments as far as the adapter is concerned: it
326        // never measured them, so they are not obligations this patch failed.
327        let run = view(vec![file("src/a.ts", 3, &[2, 3, 4], &[3])]);
328        let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 2, 3, 5]))]);
329        let patch = build(&run, &changed);
330        assert_eq!(patch.files[0].executable, [2, 3]);
331        assert_eq!(patch.files[0].uncovered, [3]);
332        assert_eq!((patch.covered, patch.eligible), (1, 2));
333        assert!(!patch.meets(1_000_000));
334        assert!(patch.meets(500_000));
335    }
336
337    #[test]
338    fn a_comment_only_change_is_empty_rather_than_perfect() {
339        // Reporting 100% here would claim a patch was tested when nothing
340        // testable changed. The distinction is the point.
341        let run = view(vec![file("src/a.ts", 2, &[2, 3], &[])]);
342        let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 9]))]);
343        let patch = build(&run, &changed);
344        assert!(patch.is_empty());
345        assert_eq!(patch.percentage(), None);
346        assert!(patch.files.is_empty());
347    }
348
349    #[test]
350    fn changed_source_the_run_never_measured_is_named_not_counted_as_covered() {
351        // Silently treating it as zero uncovered lines would report success
352        // for code that nothing ran.
353        let run = view(vec![file("src/a.ts", 1, &[2], &[])]);
354        let changed = ChangedLines::from([("src/new.ts".into(), BTreeSet::from([1, 2]))]);
355        let patch = build(&run, &changed);
356        assert_eq!(patch.missing_from_run, ["src/new.ts"]);
357        assert!(patch.files[0].missing_from_run);
358        assert_eq!((patch.covered, patch.eligible), (0, 0));
359
360        // A document, a lockfile and a test in a directory nothing is measured
361        // in are not coverage gaps. Announcing them would bury the one file
362        // that is, which is how a useful list becomes an ignored one.
363        let quiet = build(
364            &run,
365            &ChangedLines::from([
366                ("README.md".into(), BTreeSet::from([1])),
367                ("package-lock.json".into(), BTreeSet::from([2])),
368                ("tests/a.test.ts".into(), BTreeSet::from([3])),
369            ]),
370        );
371        assert!(
372            quiet.missing_from_run.is_empty(),
373            "{:?}",
374            quiet.missing_from_run
375        );
376        assert!(quiet.files.is_empty());
377    }
378
379    #[test]
380    fn adjacent_misses_become_one_annotation_and_the_transport_is_escaped() {
381        // A file name carrying a comma, colon or newline would otherwise end
382        // the workflow command early and let the rest be read as a new one.
383        let run = view(vec![file("a,b:c.ts", 5, &[1, 2, 3, 4, 9], &[2, 3, 4, 9])]);
384        let changed = ChangedLines::from([("a,b:c.ts".into(), BTreeSet::from([1, 2, 3, 4, 9]))]);
385        let patch = build(&run, &changed);
386        assert_eq!(ranges(&patch.files[0].uncovered), [(2, 4), (9, 9)]);
387        let rendered = annotations(&patch, 10, "");
388        assert_eq!(rendered.len(), 2);
389        assert!(rendered[0].contains("file=a%2Cb%3Ac.ts"), "{}", rendered[0]);
390        assert!(rendered[0].contains("line=2,endLine=4"), "{}", rendered[0]);
391        assert!(!rendered[0].contains("\n"));
392        assert_eq!(annotations(&patch, 1, "").len(), 1, "the cap is honoured");
393        assert_eq!(escape_message("a\nb%c"), "a%0Ab%25c");
394    }
395}