OXYGENHUB

Coverage / crates/hub/src/coverage.rs 96.6%

516 of 534 lines run, 99 of 165 functions · run #464 on main · run missed

1
//! Coverage reports (oxygenhub#113): Coveralls built into the hub. An
2
//! OxygenFlow job that declares an artifact named `coverage` in LCOV
3
//! format (what `cargo llvm-cov --lcov`, Istanbul, coverage.py and
4
//! gcov/lcov write) or Cobertura XML has it parsed when it is uploaded. The totals and each
5
//! file's counts are kept per run; the per-line counts are read again from
6
//! the artifact's blob for the file view. Paths are taken relative to the
7
//! job's workspace (`/src`), and only files of the run's state are kept.
8
9
use crate::db::{now_ms, Pool};
10
use crate::Shared;
11
use std::collections::{BTreeMap, HashSet};
12
use std::sync::Mutex;
13
14
/// The artifact name a pipeline gives its report.
15
pub const ARTIFACT: &str = "coverage";
16
/// The largest report parsed.
17
pub const MAX_REPORT: usize = 64 << 20;
18
19
/// One file's coverage, from a report.
20
#[derive(Clone, Debug, Default, PartialEq, Eq)]
21
pub struct FileCoverage {
22
    /// Relative to the repository's root.
23
    pub path: String,
24
    /// Each instrumented line and how often it ran, by line number.
25
    pub lines: BTreeMap<u32, u64>,
26
    pub functions_found: u64,
27
    pub functions_hit: u64,
28
    pub branches_found: u64,
29
    pub branches_hit: u64,
30
    /// The file's own `LF`/`LH` totals, when it had one record: the tool's
31
    /// own count, which can name lines the `DA` lines leave out (llvm-cov
32
    /// does), so the hub agrees with what the tool printed.
33
    pub declared_lines: Option<(u64, u64)>,
34
}
35
36
impl FileCoverage {
3710×
    pub fn lines_found(&self) -> u64 {
3810×
        self.declared_lines.map(|d| d.0).unwrap_or(self.lines.len() as u64)
3910×
    }
40
4110×
    pub fn lines_hit(&self) -> u64 {
4234×
        self.declared_lines.map(|d| d.1).unwrap_or_else(|| self.lines.values().filter(|n| **n > 0).count() as u64)
4310×
    }
44
45
    /// Every run of every line, added up.
464×
    pub fn hits(&self) -> u64 {
4715×
        self.lines.values().fold(0u64, |a, n| a.saturating_add(*n))
484×
    }
49
}
50
51
/// A path as a report names it, relative to the repository: the Flow
52
/// workspace's `/src/` and a leading `./` taken off. `None` for a path
53
/// outside the repository.
5429×
pub fn relative(path: &str) -> Option<String> {
5529×
    let p = path.trim();
5629×
    let p = p.strip_prefix("/src/").unwrap_or(p);
5729×
    let p = p.strip_prefix("./").unwrap_or(p);
5841×
    if p.is_empty() || p.starts_with('/') || p.contains('\\') || p.split('/').any(|s| s.is_empty() || s == "." || s == "..") {
5911×
        return None;
6018×
    }
6118×
    Some(p.to_string())
6229×
}
63
64
#[derive(Default)]
65
struct Acc {
66
    lines: BTreeMap<u32, u64>,
67
    functions: BTreeMap<String, u64>,
68
    branches: BTreeMap<(u32, String, String), u64>,
69
    declared_fn: (u64, u64),
70
    declared_br: (u64, u64),
71
    declared_lines: (Option<u64>, Option<u64>),
72
    records: u32,
73
}
74
75
/// Parse an LCOV report into each file's coverage, records for the same
76
/// file (from several test binaries) added together. Files whose paths
77
/// are outside the repository are counted in the second value.
789×
pub fn parse_lcov(text: &str) -> Result<(Vec<FileCoverage>, usize), String> {
799×
    let mut files: BTreeMap<String, Acc> = BTreeMap::new();
809×
    let mut current: Option<Option<String>> = None;
819×
    let mut outside = HashSet::new();
829×
    let mut records = 0usize;
83
    // A new SF: record has begun and not yet been counted for its file.
849×
    let mut started = false;
8546×
    let num = |s: &str| s.trim().parse::<u64>().unwrap_or(0);
8699×
    for line in text.lines() {
8799×
        let line = line.trim();
8899×
        if let Some(path) = line.strip_prefix("SF:") {
8917×
            records += 1;
9017×
            let rel = relative(path);
9117×
            if rel.is_none() {
924×
                outside.insert(path.to_string());
9313×
            }
9417×
            current = Some(rel);
9517×
            started = true;
9617×
            continue;
9782×
        }
9882×
        if line == "end_of_record" {
9917×
            current = None;
10017×
            continue;
10165×
        }
10259×
        let Some(Some(path)) = &current else { continue };
10355×
        let acc = files.entry(path.clone()).or_default();
10455×
        if line == "TN:" || line.starts_with("TN:") {
1050×
            continue;
10655×
        }
10755×
        if std::mem::take(&mut started) {
10813×
            acc.records += 1;
10942×
        }
11055×
        if let Some(n) = line.strip_prefix("LF:") {
1112×
            acc.declared_lines.0 = Some(num(n));
11253×
        } else if let Some(n) = line.strip_prefix("LH:") {
1132×
            acc.declared_lines.1 = Some(num(n));
11451×
        } else if let Some(rest) = line.strip_prefix("DA:") {
11529×
            let mut parts = rest.split(',');
11629×
            if let (Some(l), Some(n)) = (parts.next(), parts.next()) {
11729×
                if let Ok(l) = l.trim().parse::<u32>() {
11829×
                    *acc.lines.entry(l).or_default() += num(n);
11929×
                }
1200×
            }
12122×
        } else if let Some(rest) = line.strip_prefix("FN:") {
1228×
            if let Some((_, name)) = rest.split_once(',') {
1238×
                acc.functions.entry(name.to_string()).or_default();
1248×
            }
12514×
        } else if let Some(rest) = line.strip_prefix("FNDA:") {
1268×
            if let Some((n, name)) = rest.split_once(',') {
1278×
                *acc.functions.entry(name.to_string()).or_default() += num(n);
1288×
            }
1296×
        } else if let Some(rest) = line.strip_prefix("BRDA:") {
1302×
            let parts: Vec<&str> = rest.split(',').collect();
1312×
            if let [l, block, branch, taken] = parts[..] {
1322×
                if let Ok(l) = l.trim().parse::<u32>() {
1332×
                    let taken = if taken.trim() == "-" { 0 } else { num(taken) };
1342×
                    *acc.branches.entry((l, block.to_string(), branch.to_string())).or_default() += taken;
1350×
                }
1360×
            }
1374×
        } else if let Some(n) = line.strip_prefix("FNF:") {
1381×
            acc.declared_fn.0 = acc.declared_fn.0.max(num(n));
1393×
        } else if let Some(n) = line.strip_prefix("FNH:") {
1401×
            acc.declared_fn.1 = acc.declared_fn.1.max(num(n));
1412×
        } else if let Some(n) = line.strip_prefix("BRF:") {
1421×
            acc.declared_br.0 = acc.declared_br.0.max(num(n));
1431×
        } else if let Some(n) = line.strip_prefix("BRH:") {
1441×
            acc.declared_br.1 = acc.declared_br.1.max(num(n));
1451×
        }
146
    }
1479×
    if records == 0 {
1482×
        return Err("this is not an LCOV report: it names no source file (no SF: line)".into());
1497×
    }
1507×
    let out = files
1517×
        .into_iter()
15212×
        .map(|(path, a)| {
15312×
            let (functions_found, functions_hit) = if a.functions.is_empty() {
1548×
                a.declared_fn
155
            } else {
1568×
                (a.functions.len() as u64, a.functions.values().filter(|n| **n > 0).count() as u64)
157
            };
15812×
            let (branches_found, branches_hit) = if a.branches.is_empty() {
15911×
                a.declared_br
160
            } else {
1612×
                (a.branches.len() as u64, a.branches.values().filter(|n| **n > 0).count() as u64)
162
            };
16312×
            let declared_lines = match (a.records, a.declared_lines) {
1641×
                (1, (Some(f), Some(h))) if h <= f && f >= a.lines.len() as u64 => Some((f, h)),
16511×
                _ => None,
166
            };
16712×
            FileCoverage { path, lines: a.lines, functions_found, functions_hit, branches_found, branches_hit, declared_lines }
16812×
        })
1697×
        .collect();
1707×
    Ok((out, outside.len()))
1719×
}
172
173
/// Parse a report in either format the hub reads: Cobertura XML (it
174
/// starts with `<`) or LCOV. Returns the files, those outside the
175
/// repository, and the format's name.
1769×
pub fn parse(text: &str) -> Result<(Vec<FileCoverage>, usize, &'static str), String> {
1779×
    if text.trim_start().starts_with('<') {
1783×
        parse_cobertura(text).map(|(f, o)| (f, o, "cobertura"))
179
    } else {
1806×
        parse_lcov(text).map(|(f, o)| (f, o, "lcov"))
181
    }
1829×
}
183
18434×
fn xml_attr(e: &quick_xml::events::BytesStart, name: &str) -> String {
18534×
    e.try_get_attribute(name)
18634×
        .ok()
18734×
        .flatten()
18834×
        .and_then(|a| a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok().map(|v| v.into_owned()))
18934×
        .unwrap_or_default()
19034×
}
191
192
/// Parse a Cobertura XML report (coverage.py `coverage xml`, JaCoCo via
193
/// cover2cover, .NET Coverlet, `cargo llvm-cov --cobertura`): each
194
/// `<class>`'s `<line number hits>` by file, with `branch` lines'
195
/// `condition-coverage="50% (1/2)"` as branches and `<method>`s as
196
/// functions (one is hit when any of its lines ran). A file named by
197
/// several classes is added together. A `<source>` is tried as the base of
198
/// a relative filename, the workspace's `/src` among them.
1993×
pub fn parse_cobertura(text: &str) -> Result<(Vec<FileCoverage>, usize), String> {
200
    use quick_xml::events::Event;
2013×
    let mut r = quick_xml::Reader::from_str(text);
2023×
    let mut sources: Vec<String> = Vec::new();
2033×
    let mut in_source = false;
2043×
    let mut source_text = String::new();
205
    // Files as the classes name them, resolved once the sources are known.
2063×
    let mut raw: BTreeMap<String, Acc> = BTreeMap::new();
2073×
    let mut file: Option<String> = None;
2083×
    let mut method: Option<(String, bool)> = None;
2093×
    let mut seen_root = false;
210
    loop {
21186×
        let ev = r.read_event().map_err(|e| format!("this is not a Cobertura report: {e}"))?;
21220×
        match ev {
21325×
            Event::Start(ref e) | Event::Empty(ref e) => {
21437×
                let tag = e.local_name().as_ref().to_string();
21537×
                match tag.as_str() {
21637×
                    "coverage" => seen_root = true,
21735×
                    "source" if matches!(ev, Event::Start(_)) => {
2182×
                        in_source = true;
2192×
                        source_text.clear();
2202×
                    }
22133×
                    "class" => {
2224×
                        let f = xml_attr(e, "filename");
2234×
                        raw.entry(f.clone()).or_default().records += 1;
2244×
                        file = Some(f);
2254×
                    }
22629×
                    "method" => method = Some((xml_attr(e, "name") + &xml_attr(e, "signature"), false)),
22727×
                    "line" => {
2289×
                        let Some(f) = &file else { continue };
2299×
                        let Ok(n) = xml_attr(e, "number").trim().parse::<u32>() else { continue };
2309×
                        let hits = xml_attr(e, "hits").trim().parse::<u64>().unwrap_or(0);
2319×
                        if let Some((_, hit)) = &mut method {
2322×
                            *hit |= hits > 0;
233
                            // A method's lines repeat the class's; count them once.
2342×
                            continue;
2357×
                        }
2367×
                        let acc = raw.entry(f.clone()).or_default();
2377×
                        *acc.lines.entry(n).or_default() += hits;
2387×
                        if xml_attr(e, "branch") == "true" {
239
                            // "50% (1/2)": covered of total conditions.
2401×
                            let cc = xml_attr(e, "condition-coverage");
2411×
                            if let Some((hit, total)) = cc
2421×
                                .split_once('(')
2431×
                                .and_then(|(_, rest)| rest.trim_end_matches(')').split_once('/'))
2441×
                                .and_then(|(h, t)| Some((h.trim().parse::<u64>().ok()?, t.trim().parse::<u64>().ok()?)))
245
                            {
2462×
                                for i in 0..total {
2472×
                                    let b = acc.branches.entry((n, "0".into(), i.to_string())).or_default();
2482×
                                    *b = (*b).max(u64::from(i < hit));
2492×
                                }
2500×
                            }
2516×
                        }
252
                    }
25318×
                    _ => {}
254
                }
25535×
                if matches!(ev, Event::Empty(_)) && tag == "method" {
2560×
                    if let (Some(f), Some((name, hit))) = (&file, method.take()) {
2570×
                        *raw.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit);
2580×
                    }
25935×
                }
260
            }
2612×
            Event::Text(t) if in_source => source_text.push_str(&t.xml10_content()),
26225×
            Event::End(e) => match e.local_name().as_ref() {
26325×
                "source" => {
2642×
                    in_source = false;
2652×
                    sources.push(source_text.trim().trim_end_matches('/').to_string());
2662×
                }
26723×
                "class" => file = None,
26819×
                "method" => {
2692×
                    if let (Some(f), Some((name, hit))) = (&file, method.take()) {
2702×
                        *raw.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit);
2712×
                    }
272
                }
27317×
                _ => {}
274
            },
2753×
            Event::Eof => break,
27619×
            _ => {}
277
        }
278
    }
2793×
    if !seen_root {
2801×
        return Err("this is not a Cobertura report: no <coverage> element".into());
2812×
    }
2822×
    if raw.is_empty() {
2830×
        return Err("this Cobertura report names no source file (no <class filename>)".into());
2842×
    }
285
    // A filename relative to one of the sources, or to the workspace.
2863×
    let resolve = |f: &str| -> Option<String> {
2873×
        if f.starts_with('/') {
2881×
            return relative(f);
2892×
        }
2902×
        sources.iter().filter(|s| !s.is_empty() && *s != ".").find_map(|s| relative(&format!("{s}/{f}"))).or_else(|| relative(f))
2913×
    };
2922×
    let mut files: BTreeMap<String, Acc> = BTreeMap::new();
2932×
    let mut outside = HashSet::new();
2943×
    for (f, a) in raw {
2953×
        let Some(path) = resolve(&f) else {
2961×
            outside.insert(f);
2971×
            continue;
298
        };
2992×
        let into = files.entry(path).or_default();
3006×
        for (l, n) in a.lines {
3016×
            *into.lines.entry(l).or_default() += n;
3026×
        }
3032×
        for (k, n) in a.functions {
3042×
            *into.functions.entry(k).or_default() += n;
3052×
        }
3062×
        for (k, n) in a.branches {
3072×
            let b = into.branches.entry(k).or_default();
3082×
            *b = (*b).max(n);
3092×
        }
310
    }
3112×
    let out = files
3122×
        .into_iter()
3132×
        .map(|(path, a)| FileCoverage {
3142×
            path,
3152×
            functions_found: a.functions.len() as u64,
3162×
            functions_hit: a.functions.values().filter(|n| **n > 0).count() as u64,
3172×
            branches_found: a.branches.len() as u64,
3182×
            branches_hit: a.branches.values().filter(|n| **n > 0).count() as u64,
3192×
            lines: a.lines,
3202×
            declared_lines: None,
3212×
        })
3222×
        .collect();
3232×
    Ok((out, outside.len()))
3243×
}
325
326
/// A percentage to one decimal place, or none when nothing was counted.
32760×
pub fn percent(hit: i64, found: i64) -> Option<f64> {
32860×
    (found > 0).then(|| (hit as f64 * 1000.0 / found as f64).round() / 10.0)
32960×
}
330
331
#[derive(Clone, Debug, sqlx::FromRow)]
332
pub struct Report {
333
    pub id: i64,
334
    pub repo_id: i64,
335
    /// The Flow run and job it came from; none for an upload (#136).
336
    pub run_id: Option<i64>,
337
    pub job_id: Option<i64>,
338
    pub job: String,
339
    pub run_number: i64,
340
    pub bookmark: String,
341
    pub state: String,
342
    pub heads: String,
343
    pub blob: String,
344
    pub format: String,
345
    pub lines_found: i64,
346
    pub lines_hit: i64,
347
    pub functions_found: i64,
348
    pub functions_hit: i64,
349
    pub branches_found: i64,
350
    pub branches_hit: i64,
351
    pub hits: i64,
352
    pub files: i64,
353
    pub dropped: i64,
354
    pub created_ms: i64,
355
    /// Who uploaded it from a workstation (#136); empty for a job's report.
356
    pub uploader: String,
357
}
358
359
impl Report {
360
    /// Whether it was uploaded rather than measured by a Flow job.
36112×
    pub fn uploaded(&self) -> bool {
36212×
        !self.uploader.is_empty()
36312×
    }
364
365
    /// Where its figure came from: "run #61", or "uploaded by NAME".
3664×
    pub fn source(&self) -> String {
3674×
        if self.uploaded() {
3683×
            format!("uploaded by {}", self.uploader)
369
        } else {
3701×
            format!("run #{}", self.run_number)
371
        }
3724×
    }
373
374
    /// How often an instrumented line ran, on average.
3754×
    pub fn hits_per_line(&self) -> Option<f64> {
3764×
        (self.lines_found > 0).then(|| self.hits as f64 / self.lines_found as f64)
3774×
    }
378
37936×
    pub fn percent(&self) -> Option<f64> {
38036×
        percent(self.lines_hit, self.lines_found)
38136×
    }
382
3831×
    pub fn functions_percent(&self) -> Option<f64> {
3841×
        percent(self.functions_hit, self.functions_found)
3851×
    }
386
3870×
    pub fn branches_percent(&self) -> Option<f64> {
3880×
        percent(self.branches_hit, self.branches_found)
3890×
    }
390
3911×
    pub fn head_list(&self) -> Vec<&str> {
3921×
        self.heads.split(',').filter(|h| !h.is_empty()).collect()
3931×
    }
394
}
395
396
#[derive(Clone, Debug, sqlx::FromRow)]
397
pub struct FileRow {
398
    pub report_id: i64,
399
    pub path: String,
400
    pub lines_found: i64,
401
    pub lines_hit: i64,
402
    pub functions_found: i64,
403
    pub functions_hit: i64,
404
    pub branches_found: i64,
405
    pub branches_hit: i64,
406
}
407
408
impl FileRow {
40921×
    pub fn percent(&self) -> Option<f64> {
41021×
        percent(self.lines_hit, self.lines_found)
41121×
    }
412
4132×
    pub fn missed(&self) -> i64 {
4142×
        self.lines_found - self.lines_hit
4152×
    }
416
}
417
418
/// Repositories with a report, so their pages show the Coverage tab:
419
/// keyed by the hub's repository root and the id, unique even between
420
/// hubs in one process.
421
static HAS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
422
4238×
fn key(app: &Shared, repo_id: i64) -> String {
4248×
    format!("{}#{repo_id}", app.repos.root().display())
4258×
}
426
427517×
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
428517×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
429517×
}
430
4313×
fn mark(app: &Shared, repo_id: i64) {
4323×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(HashSet::new).insert(key(app, repo_id));
4333×
}
434
435
/// At start: which repositories have reports.
436189×
pub async fn load(app: &Shared) -> sqlx::Result<()> {
437189×
    let ids: Vec<(i64,)> = sqlx::query_as("SELECT DISTINCT repo_id FROM coverage_reports").fetch_all(&app.db).await?;
438189×
    for (id,) in ids {
4390×
        mark(app, id);
4400×
    }
441189×
    Ok(())
442189×
}
443
444
/// What a run's report is kept with.
445
pub struct Origin<'a> {
446
    pub repo_id: i64,
447
    /// The Flow run and job; none for an upload, which names its uploader.
448
    pub run_id: Option<i64>,
449
    pub run_number: i64,
450
    pub job_id: Option<i64>,
451
    pub uploader: &'a str,
452
    pub job: &'a str,
453
    pub bookmark: &'a str,
454
    pub state: &'a str,
455
    pub heads: &'a [String],
456
    pub blob: &'a str,
457
    /// `lcov` or `cobertura`.
458
    pub format: &'a str,
459
}
460
461
/// Keep a parsed report: the files outside `in_state` are dropped and
462
/// counted. Replaces an earlier report of the same job.
4633×
pub async fn keep(app: &Shared, o: &Origin<'_>, files: Vec<FileCoverage>, outside: usize, in_state: &HashSet<String>) -> sqlx::Result<i64> {
4646×
    let (kept, missing): (Vec<FileCoverage>, Vec<FileCoverage>) = files.into_iter().partition(|f| in_state.contains(&f.path));
46518×
    let sum = |f: &dyn Fn(&FileCoverage) -> u64| kept.iter().map(f).sum::<u64>() as i64;
4663×
    let mut tx = crate::db::begin_write(&app.db).await?;
4673×
    if let Some(job) = o.job_id {
4682×
        sqlx::query("DELETE FROM coverage_reports WHERE job_id = $1").bind(job).execute(&mut *tx).await?;
4691×
    }
4703×
    let id: i64 = sqlx::query_scalar(
4713×
        "INSERT INTO coverage_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format, \
4723×
         lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms, uploader) \
4733×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id",
474
    )
4753×
    .bind(o.repo_id)
4763×
    .bind(o.run_id)
4773×
    .bind(o.job_id)
4783×
    .bind(o.job)
4793×
    .bind(o.run_number)
4803×
    .bind(o.bookmark)
4813×
    .bind(o.state)
4823×
    .bind(o.heads.join(","))
4833×
    .bind(o.blob)
4843×
    .bind(o.format)
4853×
    .bind(sum(&|f| f.lines_found()))
4863×
    .bind(sum(&|f| f.lines_hit()))
4873×
    .bind(sum(&|f| f.functions_found))
4883×
    .bind(sum(&|f| f.functions_hit))
4893×
    .bind(sum(&|f| f.branches_found))
4903×
    .bind(sum(&|f| f.branches_hit))
4913×
    .bind(kept.iter().fold(0u64, |a, f| a.saturating_add(f.hits())).min(i64::MAX as u64) as i64)
4923×
    .bind(kept.len() as i64)
4933×
    .bind((missing.len() + outside) as i64)
4943×
    .bind(now_ms())
4953×
    .bind(o.uploader)
4963×
    .fetch_one(&mut *tx)
4973×
    .await?;
4983×
    for f in &kept {
4993×
        sqlx::query(
5003×
            "INSERT INTO coverage_files (report_id, path, lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit) \
5013×
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
5023×
        )
5033×
        .bind(id)
5043×
        .bind(&f.path)
5053×
        .bind(f.lines_found() as i64)
5063×
        .bind(f.lines_hit() as i64)
5073×
        .bind(f.functions_found as i64)
5083×
        .bind(f.functions_hit as i64)
5093×
        .bind(f.branches_found as i64)
5103×
        .bind(f.branches_hit as i64)
5113×
        .execute(&mut *tx)
5123×
        .await?;
513
    }
5143×
    tx.commit().await?;
5153×
    mark(app, o.repo_id);
5163×
    Ok(id)
5173×
}
518
5196×
pub async fn report(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
5206×
    sqlx::query_as("SELECT * FROM coverage_reports WHERE id = $1 AND repo_id = $2").bind(id).bind(repo_id).fetch_optional(pool).await
5216×
}
522
523
/// The newest reports on a bookmark, newest first.
5245×
pub async fn on_bookmark(pool: &Pool, repo_id: i64, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
5255×
    sqlx::query_as("SELECT * FROM coverage_reports WHERE repo_id = $1 AND bookmark = $2 ORDER BY created_ms DESC, id DESC LIMIT $3")
5265×
        .bind(repo_id)
5275×
        .bind(bookmark)
5285×
        .bind(limit)
5295×
        .fetch_all(pool)
5305×
        .await
5315×
}
532
533
/// When the repository's first report on any bookmark was kept.
5342×
pub async fn first_ms(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<i64>> {
5352×
    sqlx::query_scalar("SELECT MIN(created_ms) FROM coverage_reports WHERE repo_id = $1").bind(repo_id).fetch_one(pool).await
5362×
}
537
538
/// The bookmarks with reports, the most recent first.
5394×
pub async fn bookmarks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
5404×
    sqlx::query_scalar("SELECT bookmark FROM coverage_reports WHERE repo_id = $1 GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
5414×
        .bind(repo_id)
5424×
        .fetch_all(pool)
5434×
        .await
5444×
}
545
546
/// A repository's coverage rules.
547
#[derive(Clone, Copy, Debug, Default, PartialEq)]
548
pub struct Rules {
549
    /// The check fails below this percentage.
550
    pub min: Option<f64>,
551
    /// The check fails when coverage falls more than this many points.
552
    pub max_drop: Option<f64>,
553
}
554
555135×
pub async fn rules(pool: &Pool, repo_id: i64) -> sqlx::Result<Rules> {
556135×
    let row: Option<(Option<f64>, Option<f64>)> =
557135×
        sqlx::query_as("SELECT min, max_drop FROM coverage_settings WHERE repo_id = $1").bind(repo_id).fetch_optional(pool).await?;
558135×
    Ok(row.map(|(min, max_drop)| Rules { min, max_drop }).unwrap_or_default())
559135×
}
560
5612×
pub async fn set_rules(pool: &Pool, repo_id: i64, r: Rules) -> sqlx::Result<()> {
5622×
    sqlx::query(
5632×
        "INSERT INTO coverage_settings (repo_id, min, max_drop) VALUES ($1, $2, $3) \
5642×
         ON CONFLICT (repo_id) DO UPDATE SET min = excluded.min, max_drop = excluded.max_drop",
5652×
    )
5662×
    .bind(repo_id)
5672×
    .bind(r.min)
5682×
    .bind(r.max_drop)
5692×
    .execute(pool)
5702×
    .await?;
5712×
    Ok(())
5722×
}
573
574
/// What a report is measured against: on the default bookmark, the report
575
/// before it there; on any other, the default bookmark's latest before it.
5762×
pub async fn baseline(pool: &Pool, r: &Report, default_bookmark: &str) -> sqlx::Result<Option<Report>> {
5772×
    sqlx::query_as(
5782×
        "SELECT * FROM coverage_reports WHERE repo_id = $1 AND bookmark = $2 AND id <> $3 AND (created_ms < $4 OR (created_ms = $5 AND id < $6)) \
5792×
         ORDER BY created_ms DESC, id DESC LIMIT 1",
5802×
    )
5812×
    .bind(r.repo_id)
5822×
    .bind(default_bookmark)
5832×
    .bind(r.id)
5842×
    .bind(r.created_ms)
5852×
    .bind(r.created_ms)
5862×
    .bind(r.id)
5872×
    .fetch_optional(pool)
5882×
    .await
5892×
}
590
591
/// The `coverage` check for a report: passed or failed, and what to say.
5928×
pub fn verdict(r: &Report, base: Option<&Report>, base_name: &str, rules: Rules) -> (&'static str, String) {
5938×
    let Some(p) = r.percent() else { return ("passed", "no instrumented lines".into()) };
5948×
    let delta = base.and_then(|b| Some(p - b.percent()?));
5958×
    let against = match delta {
5965×
        Some(d) if d.abs() < 0.05 => format!(", unchanged against {base_name}"),
5974×
        Some(d) => format!(" ({d:+.1} against {base_name})"),
5983×
        None => String::new(),
599
    };
6008×
    if let Some(min) = rules.min.filter(|m| p < *m) {
6012×
        return ("failed", format!("{p:.1}%{against}: below the minimum of {min}%"));
6026×
    }
6036×
    if let (Some(max), Some(d)) = (rules.max_drop, delta) {
6042×
        if d < -max {
6051×
            return ("failed", format!("{p:.1}%{against}: falls more than the {max} points allowed"));
6061×
        }
6074×
    }
6085×
    ("passed", format!("{p:.1}%{against}"))
6098×
}
610
61123×
pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> {
61223×
    sqlx::query_as("SELECT * FROM coverage_reports WHERE run_id = $1 ORDER BY job").bind(run_id).fetch_all(pool).await
61323×
}
614
6155×
pub async fn files(pool: &Pool, report_id: i64) -> sqlx::Result<Vec<FileRow>> {
6165×
    sqlx::query_as("SELECT * FROM coverage_files WHERE report_id = $1 ORDER BY path").bind(report_id).fetch_all(pool).await
6175×
}
618
619
#[cfg(test)]
620
mod tests {
621
    use super::*;
622
623
    const LCOV: &str = "TN:\nSF:/src/crates/a/src/lib.rs\nFN:3,alpha\nFN:9,beta\nFNDA:4,alpha\nFNDA:0,beta\nFNF:2\nFNH:1\n\
624
DA:3,4\nDA:4,4\nDA:9,0\nDA:10,0\nBRDA:4,0,0,3\nBRDA:4,0,1,-\nBRF:2\nBRH:1\nLF:4\nLH:2\nend_of_record\n\
625
SF:/src/crates/a/src/lib.rs\nDA:9,1\nend_of_record\n\
626
SF:./README.md\nDA:1,1\nend_of_record\n\
627
SF:/home/runner/.cargo/registry/src/dep/lib.rs\nDA:1,1\nend_of_record\n";
628
629
    #[test]
6301×
    fn lcov_reports_are_read_and_merged() {
6311×
        let (files, outside) = parse_lcov(LCOV).unwrap();
6321×
        assert_eq!(outside, 1, "the dependency outside the workspace");
6331×
        assert_eq!(files.len(), 2);
6341×
        let lib = &files[1];
6351×
        assert_eq!(lib.path, "crates/a/src/lib.rs");
6361×
        assert_eq!(lib.lines, [(3, 4), (4, 4), (9, 1), (10, 0)].into_iter().collect());
6371×
        assert_eq!((lib.lines_found(), lib.lines_hit()), (4, 3), "the second record ran line 9");
6381×
        assert_eq!((lib.functions_found, lib.functions_hit), (2, 1));
6391×
        assert_eq!((lib.branches_found, lib.branches_hit), (2, 1));
6401×
        assert_eq!(files[0].path, "README.md");
6411×
        assert_eq!(lib.declared_lines, None, "two records: counted from the lines");
642
        // One record whose totals name lines its DA lines leave out, as llvm-cov writes.
6431×
        let (one, _) = parse_lcov("SF:/src/a.rs\nDA:1,1\nDA:2,0\nLF:5\nLH:3\nend_of_record\n").unwrap();
6441×
        assert_eq!((one[0].lines_found(), one[0].lines_hit()), (5, 3));
6451×
        assert!(parse_lcov("not a report\n").unwrap_err().contains("not an LCOV report"));
6461×
    }
647
648
    #[test]
6491×
    fn the_check_follows_the_rules() {
6501×
        let report = |pct_hit: i64| Report {
651
            id: 2,
652
            repo_id: 1,
6532×
            run_id: Some(1),
6542×
            job_id: Some(1),
6552×
            job: "test".into(),
656
            run_number: 1,
6572×
            bookmark: "feature".into(),
6582×
            state: String::new(),
6592×
            heads: String::new(),
6602×
            blob: String::new(),
6612×
            format: "lcov".into(),
662
            lines_found: 1000,
6632×
            lines_hit: pct_hit,
664
            functions_found: 0,
665
            functions_hit: 0,
666
            branches_found: 0,
667
            branches_hit: 0,
668
            hits: 0,
669
            files: 1,
670
            dropped: 0,
671
            created_ms: 0,
6722×
            uploader: String::new(),
6732×
        };
6741×
        let (now, before) = (report(819), report(822));
6751×
        let none = Rules::default();
6761×
        assert_eq!(verdict(&now, Some(&before), "main", none), ("passed", "81.9% (-0.3 against main)".to_string()));
6771×
        assert_eq!(verdict(&now, None, "main", none), ("passed", "81.9%".to_string()));
6781×
        assert_eq!(verdict(&now, Some(&now), "main", none).1, "81.9%, unchanged against main");
6791×
        let strict = Rules { min: Some(85.0), max_drop: None };
6801×
        assert_eq!(
6811×
            verdict(&now, Some(&before), "main", strict),
6821×
            ("failed", "81.9% (-0.3 against main): below the minimum of 85%".to_string())
683
        );
6841×
        let drop = Rules { min: None, max_drop: Some(0.2) };
6851×
        assert_eq!(verdict(&now, Some(&before), "main", drop).0, "failed");
6861×
        assert_eq!(verdict(&now, Some(&before), "main", Rules { min: None, max_drop: Some(0.5) }).0, "passed");
6871×
    }
688
689
    #[test]
6901×
    fn paths_are_taken_relative_to_the_workspace() {
6911×
        assert_eq!(relative("/src/a/b.rs").as_deref(), Some("a/b.rs"));
6921×
        assert_eq!(relative("./a/b.rs").as_deref(), Some("a/b.rs"));
6931×
        assert_eq!(relative("a/b.rs").as_deref(), Some("a/b.rs"));
6946×
        for bad in ["/etc/passwd", "a/../b", "", "/src/", "a//b", "C:\\x\\y.rs"] {
6956×
            assert_eq!(relative(bad), None, "{bad}");
696
        }
6971×
        assert_eq!(percent(1, 3), Some(33.3));
6981×
        assert_eq!(percent(0, 0), None);
6991×
    }
700
701
    #[test]
7021×
    fn cobertura_from_coverage_py_and_coverlet() {
703
        // coverage.py: a source to resolve against, branches by condition.
7041×
        let py = r#"<?xml version="1.0" ?>
7051×
<coverage version="7.4" line-rate="0.6" branch-rate="0.5">
7061×
  <sources><source>/src/app</source></sources>
7071×
  <packages><package name="app"><classes>
7081×
    <class name="main.py" filename="main.py" line-rate="0.66">
7091×
      <methods/>
7101×
      <lines>
7111×
        <line number="1" hits="1"/>
7121×
        <line number="2" hits="0"/>
7131×
        <line number="3" hits="4" branch="true" condition-coverage="50% (1/2)"/>
7141×
      </lines>
7151×
    </class>
7161×
    <class name="gone.py" filename="/usr/lib/python3/x.py"><lines><line number="1" hits="1"/></lines></class>
7171×
  </classes></package></packages>
7181×
</coverage>"#;
7191×
        let (files, outside, format) = parse(py).unwrap();
7201×
        assert_eq!((format, outside), ("cobertura", 1));
7211×
        let f = &files[0];
7221×
        assert_eq!(f.path, "app/main.py");
7231×
        assert_eq!((f.lines_found(), f.lines_hit(), f.hits()), (3, 2, 5));
7241×
        assert_eq!((f.branches_found, f.branches_hit), (2, 1));
725
726
        // Coverlet: methods with their own lines, two classes in one file.
7271×
        let net = r#"<coverage><sources><source>/src/</source></sources><packages><package name="Lib"><classes>
7281×
<class name="Lib.A" filename="Lib/A.cs"><methods><method name="Run" signature="()"><lines><line number="5" hits="2"/></lines></method><method name="Idle" signature="()"><lines><line number="9" hits="0"/></lines></method></methods>
7291×
<lines><line number="5" hits="2"/><line number="9" hits="0"/></lines></class>
7301×
<class name="Lib.A/Inner" filename="Lib/A.cs"><methods/><lines><line number="12" hits="1"/></lines></class>
7311×
</classes></package></packages></coverage>"#;
7321×
        let (files, _, _) = parse(net).unwrap();
7331×
        let f = &files[0];
7341×
        assert_eq!(f.path, "Lib/A.cs");
7351×
        assert_eq!((f.lines_found(), f.lines_hit()), (3, 2));
7361×
        assert_eq!((f.functions_found, f.functions_hit), (2, 1));
737
7381×
        assert!(parse("<html/>").unwrap_err().contains("Cobertura"));
7391×
        assert_eq!(parse("SF:/src/a.rs\nDA:1,1\nend_of_record\n").unwrap().2, "lcov");
7401×
    }
741
}