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, 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 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 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 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 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 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 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}