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.
223pub fn annotations(view: &PatchView, cap: usize) -> Vec<String> {
224    let mut out = Vec::new();
225    for file in &view.files {
226        for (start, end) in ranges(&file.uncovered) {
227            if out.len() == cap {
228                return out;
229            }
230            let lines = if start == end {
231                format!("line {start}")
232            } else {
233                format!("lines {start} to {end}")
234            };
235            out.push(format!(
236                "::warning file={},line={},endLine={}::{}",
237                escape_property(&file.file),
238                start,
239                end,
240                escape_message(&format!(
241                    "Changed {lines} not covered by the selected tests."
242                )),
243            ));
244        }
245    }
246    out
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::run_view::{Applicability, FileView, MetricView, RUN_VIEW_SCHEMA_VERSION};
253
254    fn file(name: &str, eligible: usize, measured: &[usize], uncovered: &[usize]) -> FileView {
255        FileView {
256            file: name.into(),
257            metrics: vec![MetricView {
258                metric: Metric::Lines,
259                covered: eligible - uncovered.len(),
260                eligible,
261                applicability: Applicability::Measured,
262            }],
263            measured_lines: measured.to_vec(),
264            uncovered_lines: uncovered.to_vec(),
265            missing_branches: Vec::new(),
266            missing_conditions: Vec::new(),
267            functions: Vec::new(),
268            branches: Vec::new(),
269        }
270    }
271
272    fn view(files: Vec<FileView>) -> RunView {
273        let source_neighbourhoods = files
274            .iter()
275            .filter_map(|file| {
276                let (directory, name) = file.file.rsplit_once('/').unwrap_or(("", &file.file));
277                let (_, extension) = name.rsplit_once('.')?;
278                Some((directory.to_owned(), extension.to_owned()))
279            })
280            .collect();
281        RunView {
282            source_neighbourhoods,
283            schema_version: RUN_VIEW_SCHEMA_VERSION,
284            run: "run_1".into(),
285            generated_at: "now".into(),
286            suite_passed: true,
287            stale: false,
288            stale_reasons: Vec::new(),
289            complete: true,
290            limitations: Vec::new(),
291            totals: Vec::new(),
292            files,
293        }
294    }
295
296    #[test]
297    fn only_the_post_image_of_a_diff_becomes_a_denominator() {
298        // A deleted line has nothing left to cover, and a pure deletion hunk
299        // (`+13,0`) must not claim line 13 of the new file.
300        let diff = "\
301--- a/src/a.ts
302+++ b/src/a.ts
303@@ -1,2 +1,3 @@
304@@ -20,4 +21,0 @@
305--- a/src/gone.ts
306+++ /dev/null
307@@ -1,5 +0,0 @@
308--- a/src/b.ts
309+++ b/src/b.ts
310@@ -7 +7 @@
311";
312        let changed = changed_lines(diff);
313        assert_eq!(changed["src/a.ts"], BTreeSet::from([1, 2, 3]));
314        assert_eq!(changed["src/b.ts"], BTreeSet::from([7]));
315        assert!(!changed.contains_key("/dev/null"));
316        assert!(!changed.contains_key("src/gone.ts"));
317    }
318
319    #[test]
320    fn the_denominator_is_the_adapter_s_executable_lines_not_every_changed_line() {
321        // Lines 1 and 5 are comments as far as the adapter is concerned: it
322        // never measured them, so they are not obligations this patch failed.
323        let run = view(vec![file("src/a.ts", 3, &[2, 3, 4], &[3])]);
324        let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 2, 3, 5]))]);
325        let patch = build(&run, &changed);
326        assert_eq!(patch.files[0].executable, [2, 3]);
327        assert_eq!(patch.files[0].uncovered, [3]);
328        assert_eq!((patch.covered, patch.eligible), (1, 2));
329        assert!(!patch.meets(1_000_000));
330        assert!(patch.meets(500_000));
331    }
332
333    #[test]
334    fn a_comment_only_change_is_empty_rather_than_perfect() {
335        // Reporting 100% here would claim a patch was tested when nothing
336        // testable changed. The distinction is the point.
337        let run = view(vec![file("src/a.ts", 2, &[2, 3], &[])]);
338        let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 9]))]);
339        let patch = build(&run, &changed);
340        assert!(patch.is_empty());
341        assert_eq!(patch.percentage(), None);
342        assert!(patch.files.is_empty());
343    }
344
345    #[test]
346    fn changed_source_the_run_never_measured_is_named_not_counted_as_covered() {
347        // Silently treating it as zero uncovered lines would report success
348        // for code that nothing ran.
349        let run = view(vec![file("src/a.ts", 1, &[2], &[])]);
350        let changed = ChangedLines::from([("src/new.ts".into(), BTreeSet::from([1, 2]))]);
351        let patch = build(&run, &changed);
352        assert_eq!(patch.missing_from_run, ["src/new.ts"]);
353        assert!(patch.files[0].missing_from_run);
354        assert_eq!((patch.covered, patch.eligible), (0, 0));
355
356        // A document, a lockfile and a test in a directory nothing is measured
357        // in are not coverage gaps. Announcing them would bury the one file
358        // that is, which is how a useful list becomes an ignored one.
359        let quiet = build(
360            &run,
361            &ChangedLines::from([
362                ("README.md".into(), BTreeSet::from([1])),
363                ("package-lock.json".into(), BTreeSet::from([2])),
364                ("tests/a.test.ts".into(), BTreeSet::from([3])),
365            ]),
366        );
367        assert!(
368            quiet.missing_from_run.is_empty(),
369            "{:?}",
370            quiet.missing_from_run
371        );
372        assert!(quiet.files.is_empty());
373    }
374
375    #[test]
376    fn adjacent_misses_become_one_annotation_and_the_transport_is_escaped() {
377        // A file name carrying a comma, colon or newline would otherwise end
378        // the workflow command early and let the rest be read as a new one.
379        let run = view(vec![file("a,b:c.ts", 5, &[1, 2, 3, 4, 9], &[2, 3, 4, 9])]);
380        let changed = ChangedLines::from([("a,b:c.ts".into(), BTreeSet::from([1, 2, 3, 4, 9]))]);
381        let patch = build(&run, &changed);
382        assert_eq!(ranges(&patch.files[0].uncovered), [(2, 4), (9, 9)]);
383        let rendered = annotations(&patch, 10);
384        assert_eq!(rendered.len(), 2);
385        assert!(rendered[0].contains("file=a%2Cb%3Ac.ts"), "{}", rendered[0]);
386        assert!(rendered[0].contains("line=2,endLine=4"), "{}", rendered[0]);
387        assert!(!rendered[0].contains("\n"));
388        assert_eq!(annotations(&patch, 1).len(), 1, "the cap is honoured");
389        assert_eq!(escape_message("a\nb%c"), "a%0Ab%25c");
390    }
391}