use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::run_view::{Metric, RunView};
pub type ChangedLines = BTreeMap<String, BTreeSet<usize>>;
pub fn changed_lines(diff: &str) -> ChangedLines {
let mut changed = ChangedLines::new();
let mut file: Option<String> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
file = rest
.strip_prefix("b/")
.or(Some(rest))
.filter(|path| *path != "/dev/null")
.map(|path| path.trim_end().to_owned());
continue;
}
let Some(rest) = line.strip_prefix("@@ ") else {
continue;
};
let Some(file) = file.as_ref() else {
continue;
};
let Some(plus) = rest.split_whitespace().find(|part| part.starts_with('+')) else {
continue;
};
let mut numbers = plus[1..].split(',');
let Some(start) = numbers.next().and_then(|n| n.parse::<usize>().ok()) else {
continue;
};
let count = numbers
.next()
.map_or(Some(1), |n| n.parse::<usize>().ok())
.unwrap_or(1);
if count == 0 {
continue;
}
changed
.entry(file.clone())
.or_default()
.extend(start..start + count);
}
changed
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchFile {
pub file: String,
pub executable: Vec<usize>,
pub uncovered: Vec<usize>,
pub missing_from_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchView {
pub schema_version: u32,
pub run: String,
pub covered: usize,
pub eligible: usize,
pub files: Vec<PatchFile>,
pub missing_from_run: Vec<String>,
}
impl PatchView {
pub fn is_empty(&self) -> bool {
self.eligible == 0
}
pub fn meets(&self, floor_ppm: u64) -> bool {
u128::from(self.covered as u64) * 1_000_000
>= u128::from(floor_ppm) * u128::from(self.eligible as u64)
}
pub fn percentage(&self) -> Option<f64> {
(self.eligible > 0).then(|| self.covered as f64 * 100.0 / self.eligible as f64)
}
}
pub fn build(view: &RunView, changed: &ChangedLines) -> PatchView {
let measured = view
.files
.iter()
.map(|file| {
let uncovered = file
.uncovered_lines
.iter()
.copied()
.collect::<BTreeSet<_>>();
let eligible = file
.metric(Metric::Lines)
.map(|metric| metric.eligible)
.unwrap_or(0);
(file.file.as_str(), (uncovered, eligible))
})
.collect::<BTreeMap<_, _>>();
let mut files = Vec::new();
let mut missing = Vec::new();
let (mut covered, mut eligible) = (0_usize, 0_usize);
for (path, lines) in changed {
let Some((uncovered_lines, _)) = measured.get(path.as_str()) else {
if view.looks_like_source(path) {
missing.push(path.clone());
files.push(PatchFile {
file: path.clone(),
executable: Vec::new(),
uncovered: Vec::new(),
missing_from_run: true,
});
}
continue;
};
let executable = lines
.iter()
.copied()
.filter(|line| view.measured_line(path, *line))
.collect::<Vec<_>>();
let uncovered = executable
.iter()
.copied()
.filter(|line| uncovered_lines.contains(line))
.collect::<Vec<_>>();
covered += executable.len() - uncovered.len();
eligible += executable.len();
if !executable.is_empty() {
files.push(PatchFile {
file: path.clone(),
executable,
uncovered,
missing_from_run: false,
});
}
}
PatchView {
schema_version: crate::run_view::RUN_VIEW_SCHEMA_VERSION,
run: view.run.clone(),
covered,
eligible,
files,
missing_from_run: missing,
}
}
pub fn ranges(lines: &[usize]) -> Vec<(usize, usize)> {
let mut out: Vec<(usize, usize)> = Vec::new();
for line in lines {
match out.last_mut() {
Some(last) if last.1 + 1 == *line => last.1 = *line,
_ => out.push((*line, *line)),
}
}
out
}
pub fn escape_property(value: &str) -> String {
value
.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
.replace(':', "%3A")
.replace(',', "%2C")
}
pub fn escape_message(value: &str) -> String {
value
.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
pub fn annotations(view: &PatchView, cap: usize) -> Vec<String> {
let mut out = Vec::new();
for file in &view.files {
for (start, end) in ranges(&file.uncovered) {
if out.len() == cap {
return out;
}
let lines = if start == end {
format!("line {start}")
} else {
format!("lines {start} to {end}")
};
out.push(format!(
"::warning file={},line={},endLine={}::{}",
escape_property(&file.file),
start,
end,
escape_message(&format!(
"Changed {lines} not covered by the selected tests."
)),
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run_view::{Applicability, FileView, MetricView, RUN_VIEW_SCHEMA_VERSION};
fn file(name: &str, eligible: usize, measured: &[usize], uncovered: &[usize]) -> FileView {
FileView {
file: name.into(),
metrics: vec![MetricView {
metric: Metric::Lines,
covered: eligible - uncovered.len(),
eligible,
applicability: Applicability::Measured,
}],
measured_lines: measured.to_vec(),
uncovered_lines: uncovered.to_vec(),
missing_branches: Vec::new(),
missing_conditions: Vec::new(),
functions: Vec::new(),
branches: Vec::new(),
}
}
fn view(files: Vec<FileView>) -> RunView {
let source_neighbourhoods = files
.iter()
.filter_map(|file| {
let (directory, name) = file.file.rsplit_once('/').unwrap_or(("", &file.file));
let (_, extension) = name.rsplit_once('.')?;
Some((directory.to_owned(), extension.to_owned()))
})
.collect();
RunView {
source_neighbourhoods,
schema_version: RUN_VIEW_SCHEMA_VERSION,
run: "run_1".into(),
generated_at: "now".into(),
suite_passed: true,
stale: false,
stale_reasons: Vec::new(),
complete: true,
limitations: Vec::new(),
totals: Vec::new(),
files,
}
}
#[test]
fn only_the_post_image_of_a_diff_becomes_a_denominator() {
let diff = "\
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,2 +1,3 @@
@@ -20,4 +21,0 @@
--- a/src/gone.ts
+++ /dev/null
@@ -1,5 +0,0 @@
--- a/src/b.ts
+++ b/src/b.ts
@@ -7 +7 @@
";
let changed = changed_lines(diff);
assert_eq!(changed["src/a.ts"], BTreeSet::from([1, 2, 3]));
assert_eq!(changed["src/b.ts"], BTreeSet::from([7]));
assert!(!changed.contains_key("/dev/null"));
assert!(!changed.contains_key("src/gone.ts"));
}
#[test]
fn the_denominator_is_the_adapter_s_executable_lines_not_every_changed_line() {
let run = view(vec![file("src/a.ts", 3, &[2, 3, 4], &[3])]);
let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 2, 3, 5]))]);
let patch = build(&run, &changed);
assert_eq!(patch.files[0].executable, [2, 3]);
assert_eq!(patch.files[0].uncovered, [3]);
assert_eq!((patch.covered, patch.eligible), (1, 2));
assert!(!patch.meets(1_000_000));
assert!(patch.meets(500_000));
}
#[test]
fn a_comment_only_change_is_empty_rather_than_perfect() {
let run = view(vec![file("src/a.ts", 2, &[2, 3], &[])]);
let changed = ChangedLines::from([("src/a.ts".into(), BTreeSet::from([1, 9]))]);
let patch = build(&run, &changed);
assert!(patch.is_empty());
assert_eq!(patch.percentage(), None);
assert!(patch.files.is_empty());
}
#[test]
fn changed_source_the_run_never_measured_is_named_not_counted_as_covered() {
let run = view(vec![file("src/a.ts", 1, &[2], &[])]);
let changed = ChangedLines::from([("src/new.ts".into(), BTreeSet::from([1, 2]))]);
let patch = build(&run, &changed);
assert_eq!(patch.missing_from_run, ["src/new.ts"]);
assert!(patch.files[0].missing_from_run);
assert_eq!((patch.covered, patch.eligible), (0, 0));
let quiet = build(
&run,
&ChangedLines::from([
("README.md".into(), BTreeSet::from([1])),
("package-lock.json".into(), BTreeSet::from([2])),
("tests/a.test.ts".into(), BTreeSet::from([3])),
]),
);
assert!(
quiet.missing_from_run.is_empty(),
"{:?}",
quiet.missing_from_run
);
assert!(quiet.files.is_empty());
}
#[test]
fn adjacent_misses_become_one_annotation_and_the_transport_is_escaped() {
let run = view(vec![file("a,b:c.ts", 5, &[1, 2, 3, 4, 9], &[2, 3, 4, 9])]);
let changed = ChangedLines::from([("a,b:c.ts".into(), BTreeSet::from([1, 2, 3, 4, 9]))]);
let patch = build(&run, &changed);
assert_eq!(ranges(&patch.files[0].uncovered), [(2, 4), (9, 9)]);
let rendered = annotations(&patch, 10);
assert_eq!(rendered.len(), 2);
assert!(rendered[0].contains("file=a%2Cb%3Ac.ts"), "{}", rendered[0]);
assert!(rendered[0].contains("line=2,endLine=4"), "{}", rendered[0]);
assert!(!rendered[0].contains("\n"));
assert_eq!(annotations(&patch, 1).len(), 1, "the cap is honoured");
assert_eq!(escape_message("a\nb%c"), "a%0Ab%25c");
}
}