1use std::collections::{BTreeMap, BTreeSet};
16
17use serde::Serialize;
18
19use crate::run_view::{Metric, RunView};
20
21pub type ChangedLines = BTreeMap<String, BTreeSet<usize>>;
23
24pub 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 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 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 pub executable: Vec<usize>,
77 pub uncovered: Vec<usize>,
78 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 pub missing_from_run: Vec<String>,
95}
96
97impl PatchView {
98 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
114pub 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 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 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
188pub 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
201pub 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
222pub 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 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 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 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 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 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 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}