OXYGENHUB
# 36e5a9c15a84 oxygenhub / crates / hub / src / web / coverage.rs
18.5 KiB · Rust · last changed Tests tab: a bookmark's test results over its runs (part 2) 29 days ago source blame history raw
1
//! Coverage pages (oxygenhub#113): a repository's coverage, its trend,
2
//! every file, and each file's lines marked run or missed; and a badge.
3
4
use super::{filters, lookup, Error, Page, RepoShell, Shell};
5
use crate::auth::Viewer;
6
use crate::coverage::{self, FileRow, Report};
7
use crate::repos::view::{self, FileBody, RefKind, RefTarget};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Path, Query, State};
11
use axum::response::{IntoResponse, Response};
12
use serde::Deserialize;
13
use std::collections::BTreeMap;
14
use std::sync::{Arc, Mutex};
15
16
/// Reports the charts and the recent list draw on.
17
const HISTORY: i64 = 200;
18
/// Runs listed under Recent runs.
19
const RECENT: usize = 10;
20
/// Files listed as changed against the run before.
21
const CHANGED: usize = 12;
22
23
#[derive(Deserialize, Default)]
24
pub struct CoverageQuery {
25
    /// A bookmark other than the default.
26
    bookmark: Option<String>,
27
    /// One report, by id.
28
    report: Option<i64>,
29
    /// `missed` (default), `percent` or `path`.
30
    sort: Option<String>,
31
}
32
33
/// A run in the Recent runs list.
34
pub struct RecentRow {
35
    pub report: Report,
36
    /// Against the report before it, in percentage points.
37
    pub delta: Option<f64>,
38
}
39
40
/// A file whose coverage moved against the run before.
41
pub struct Moved {
42
    pub path: String,
43
    pub before: Option<f64>,
44
    pub after: Option<f64>,
45
    pub delta: f64,
46
}
47
48
/// One band of the file distribution.
49
pub struct Band {
50
    pub label: &'static str,
51
    pub class: &'static str,
52
    pub files: usize,
53
    /// Of the widest band, for the bar's width.
54
    pub width: f64,
55
}
56
57
#[derive(Template)]
58
#[template(path = "coverage.html")]
59
pub struct CoveragePage {
60
    shell: Shell,
61
    repo: RepoShell,
62
    bookmark: String,
63
    bookmarks: Vec<String>,
64
    report: Option<Report>,
65
    /// The report before this one on the bookmark.
66
    previous: Option<Report>,
67
    delta: Option<f64>,
68
    files: Vec<FileRow>,
69
    sort: String,
70
    coverage_chart: String,
71
    lines_chart: String,
72
    runs: usize,
73
    first_ms: Option<i64>,
74
    recent: Vec<RecentRow>,
75
    moved: Vec<Moved>,
76
    /// Files that are new against the run before.
77
    added: usize,
78
    bands: Vec<Band>,
79
}
80
81
impl CoveragePage {
82
    pub fn pct(&self, p: Option<f64>) -> String {
83
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
84
    }
85
86
    pub fn pct_of(&self, p: &Option<f64>) -> String {
87
        self.pct(*p)
88
    }
89
90
    pub fn signed(&self, d: &f64) -> String {
91
        if d.abs() < 0.05 {
92
            "±0.0".to_string()
93
        } else {
94
            format!("{d:+.1}")
95
        }
96
    }
97
98
    pub fn delta_class(&self, d: &f64) -> &'static str {
99
        if *d >= 0.05 {
100
            "up"
101
        } else if *d <= -0.05 {
102
            "down"
103
        } else {
104
            "same"
105
        }
106
    }
107
108
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
109
        level(p)
110
    }
111
112
    pub fn hits(&self, r: &Report) -> String {
113
        r.hits_per_line().map(|h| if h >= 100.0 { compact(h) } else { format!("{h:.1}") }).unwrap_or_else(|| "–".into())
114
    }
115
116
    pub fn lines_delta(&self) -> Option<(i64, i64)> {
117
        let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?);
118
        Some((r.lines_found - p.lines_found, r.lines_hit - p.lines_hit))
119
    }
120
}
121
122
/// A count, short: 9.4k, 147.7k, 2.3M.
123
pub(super) fn compact(v: f64) -> String {
124
    match v {
125
        v if v >= 1e9 => format!("{:.1}G", v / 1e9),
126
        v if v >= 1e6 => format!("{:.1}M", v / 1e6),
127
        v if v >= 1e3 => format!("{:.1}k", v / 1e3),
128
        v => format!("{v:.0}"),
129
    }
130
}
131
132
/// A colour class for a percentage.
133
pub fn level(p: Option<f64>) -> &'static str {
134
    match p {
135
        Some(p) if p >= 90.0 => "cov-good",
136
        Some(p) if p >= 75.0 => "cov-fair",
137
        Some(p) if p >= 50.0 => "cov-low",
138
        Some(_) => "cov-poor",
139
        None => "cov-none",
140
    }
141
}
142
143
/// A line of values across runs, for [`chart`].
144
pub(super) struct Series<'a> {
145
    pub values: Vec<f64>,
146
    pub class: &'a str,
147
    /// Shade down to these values instead of to the axis: a band.
148
    pub floor: Option<Vec<f64>>,
149
}
150
151
/// A run on a chart: its number, where a click goes, what a hover says.
152
pub(super) struct Point {
153
    pub run: i64,
154
    pub href: String,
155
    pub tip: String,
156
}
157
158
/// An SVG chart of one or more series over runs, oldest on the left, with
159
/// a light grid and labels, and a column per run that names it on hover
160
/// and links to its report. `fmt` labels the y axis.
161
pub(super) fn chart(label: &str, runs: &[Point], series: &[Series], lo: f64, hi: f64, fmt: &dyn Fn(f64) -> String) -> String {
162
    let (w, h) = (800.0, 180.0);
163
    let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0);
164
    let (pw, ph) = (w - left - right, h - top - bottom);
165
    let n = runs.len();
166
    if n == 0 {
167
        return String::new();
168
    }
169
    let x = |i: usize| if n == 1 { left + pw / 2.0 } else { left + pw * i as f64 / (n - 1) as f64 };
170
    let span = if hi > lo { hi - lo } else { 1.0 };
171
    let y = |v: f64| top + ph * (hi - v) / span;
172
    let mut svg = format!(r#"<svg class="cov-chart" viewBox="0 0 {w} {h}" role="img" aria-label="{label}">"#);
173
    for i in 0..=4 {
174
        let v = lo + span * i as f64 / 4.0;
175
        svg.push_str(&format!(
176
            r#"<line class="grid" x1="{left}" x2="{x2}" y1="{yy:.1}" y2="{yy:.1}"/><text class="axis" x="{tx}" y="{ty:.1}" text-anchor="end">{t}</text>"#,
177
            x2 = w - right,
178
            yy = y(v),
179
            tx = left - 6.0,
180
            ty = y(v) + 3.5,
181
            t = fmt(v),
182
        ));
183
    }
184
    for s in series {
185
        let pts: Vec<String> = s.values.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect();
186
        if n > 1 {
187
            let area = match &s.floor {
188
                Some(floor) => {
189
                    let back: Vec<String> = floor.iter().enumerate().rev().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect();
190
                    format!("M{} L{} Z", pts.join(" L"), back.join(" L"))
191
                }
192
                None => format!("M{:.1},{:.1} L{} L{:.1},{:.1} Z", x(0), top + ph, pts.join(" L"), x(n - 1), top + ph),
193
            };
194
            svg.push_str(&format!(
195
                r#"<path class="area {c}" d="{area}"/><polyline class="line {c}" points="{line}"/>"#,
196
                c = s.class,
197
                line = pts.join(" ")
198
            ));
199
        }
200
        if let Some(last) = s.values.last() {
201
            svg.push_str(&format!(r#"<circle class="dot {}" cx="{:.1}" cy="{:.1}" r="4"/>"#, s.class, x(n - 1), y(*last)));
202
        }
203
    }
204
    // A column per run: its tip on hover, its report on a click.
205
    let col = if n == 1 { pw } else { pw / (n - 1) as f64 };
206
    for (i, r) in runs.iter().enumerate() {
207
        svg.push_str(&format!(
208
            r#"<a href="{href}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#,
209
            href = r.href,
210
            rx = x(i) - col / 2.0,
211
            t = r.tip,
212
        ));
213
    }
214
    if n > 1 {
215
        svg.push_str(&format!(
216
            r#"<text class="axis" x="{left}" y="{yb}">run #{first}</text><text class="axis" x="{xr}" y="{yb}" text-anchor="end">run #{last}</text>"#,
217
            yb = h - 6.0,
218
            xr = w - right,
219
            first = runs[0].run,
220
            last = runs[n - 1].run,
221
        ));
222
    }
223
    svg.push_str("</svg>");
224
    svg
225
}
226
227
/// `GET /{owner}/{name}/coverage`.
228
pub async fn overview(
229
    State(app): State<Shared>,
230
    viewer: Viewer,
231
    Path((owner, name)): Path<(String, String)>,
232
    Query(q): Query<CoverageQuery>,
233
) -> Result<Page<CoveragePage>, Error> {
234
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
235
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
236
    let e = |e| Error::from_db(shell.clone(), e);
237
    let picked = match q.report {
238
        Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?),
239
        None => None,
240
    };
241
    let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone());
242
    let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?;
243
    let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?;
244
    let report = picked.or_else(|| history.first().cloned());
245
    // The report before the one shown, on the same bookmark.
246
    let previous = report
247
        .as_ref()
248
        .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned());
249
    let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?));
250
    let mut files = match &report {
251
        Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?,
252
        None => vec![],
253
    };
254
    // Files that moved against the run before, the largest moves first.
255
    let (mut moved, mut added) = (Vec::new(), 0);
256
    if let Some(p) = &previous {
257
        let before: std::collections::HashMap<String, Option<f64>> =
258
            coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect();
259
        for f in &files {
260
            match before.get(&f.path) {
261
                None => added += 1,
262
                Some(b) => {
263
                    let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0);
264
                    if d.abs() >= 0.05 {
265
                        moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d });
266
                    }
267
                }
268
            }
269
        }
270
        moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path)));
271
        moved.truncate(CHANGED);
272
    }
273
    // How the files spread over coverage bands.
274
    let bounds: [(&str, &str, f64, f64); 5] = [
275
        ("100%", "cov-good", 100.0, 100.1),
276
        ("90 – 99%", "cov-good", 90.0, 100.0),
277
        ("75 – 89%", "cov-fair", 75.0, 90.0),
278
        ("50 – 74%", "cov-low", 50.0, 75.0),
279
        ("under 50%", "cov-poor", -1.0, 50.0),
280
    ];
281
    let counts: Vec<usize> =
282
        bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect();
283
    let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64;
284
    let bands = bounds
285
        .iter()
286
        .zip(&counts)
287
        .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest })
288
        .collect();
289
    let sort = match q.sort.as_deref() {
290
        Some("percent") => "percent",
291
        Some("path") => "path",
292
        _ => "missed",
293
    };
294
    match sort {
295
        "percent" => files.sort_by(|a, b| a.percent().unwrap_or(101.0).total_cmp(&b.percent().unwrap_or(101.0)).then(a.path.cmp(&b.path))),
296
        "path" => {}
297
        _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))),
298
    }
299
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
300
    // Charts: oldest on the left.
301
    let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect();
302
    let pcts: Vec<f64> = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect();
303
    let (lo, hi) = (
304
        (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0),
305
        (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0),
306
    );
307
    let tip = |r: &Report| {
308
        format!(
309
            "Run #{} · {} · {} of {} lines",
310
            r.run_number,
311
            r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(),
312
            r.lines_hit,
313
            r.lines_found
314
        )
315
    };
316
    let points: Vec<Point> = oldest_first
317
        .iter()
318
        .map(|r| Point { run: r.run_number, href: format!("{}/coverage?report={}", repo.url, r.id), tip: tip(r) })
319
        .collect();
320
    let coverage_chart = chart(
321
        &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()),
322
        &points,
323
        &[Series { values: pcts, class: "cov", floor: None }],
324
        lo,
325
        hi,
326
        &|v| format!("{v:.0}%"),
327
    );
328
    let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64;
329
    let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect();
330
    let lines_chart = chart(
331
        &format!("Relevant and covered lines of {bookmark}"),
332
        &points,
333
        &[
334
            Series { values: covered.clone(), class: "covered", floor: None },
335
            Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) },
336
        ],
337
        0.0,
338
        (top * 1.08).max(1.0),
339
        &compact,
340
    );
341
    let recent = history
342
        .iter()
343
        .enumerate()
344
        .take(RECENT)
345
        .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) })
346
        .collect();
347
    let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?;
348
    Ok(Page(CoveragePage {
349
        shell,
350
        repo,
351
        bookmark,
352
        bookmarks,
353
        report,
354
        previous,
355
        delta,
356
        files,
357
        sort: sort.into(),
358
        coverage_chart,
359
        lines_chart,
360
        runs: oldest_first.len(),
361
        first_ms,
362
        recent,
363
        moved,
364
        added,
365
        bands,
366
    }))
367
}
368
369
/// One line of a file, as the file view shows it.
370
pub struct CovLine {
371
    pub number: usize,
372
    pub html: String,
373
    /// None: not an instrumented line.
374
    pub hits: Option<u64>,
375
}
376
377
#[derive(Template)]
378
#[template(path = "coverage_file.html")]
379
pub struct CoverageFilePage {
380
    shell: Shell,
381
    repo: RepoShell,
382
    report: Report,
383
    file: FileRow,
384
    lines: Vec<CovLine>,
385
    note: Option<String>,
386
}
387
388
impl CoverageFilePage {
389
    pub fn pct(&self, p: Option<f64>) -> String {
390
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
391
    }
392
393
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
394
        level(p)
395
    }
396
}
397
398
/// The line counts of the report last read, by file: the file view reads
399
/// the report's artifact again, so that browsing one report's files
400
/// parses it once.
401
type Parsed = Arc<BTreeMap<String, BTreeMap<u32, u64>>>;
402
static LAST: Mutex<Option<(String, Parsed)>> = Mutex::new(None);
403
404
async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> {
405
    if let Some((b, p)) = LAST.lock().ok()?.as_ref() {
406
        if b == blob {
407
            return Some(p.clone());
408
        }
409
    }
410
    let r = oxy_core::hash::ObjectRef::from_hex(blob)?;
411
    let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?;
412
    let parsed = tokio::task::spawn_blocking(move || {
413
        let text = String::from_utf8_lossy(&bytes);
414
        coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
415
    })
416
    .await
417
    .ok()??;
418
    let parsed = Arc::new(parsed);
419
    if let Ok(mut last) = LAST.lock() {
420
        *last = Some((blob.to_string(), parsed.clone()));
421
    }
422
    Some(parsed)
423
}
424
425
/// `GET /{owner}/{name}/coverage/{report}/file/{*path}`.
426
pub async fn file(
427
    State(app): State<Shared>,
428
    viewer: Viewer,
429
    Path((owner, name, id, path)): Path<(String, String, i64, String)>,
430
) -> Result<Page<CoverageFilePage>, Error> {
431
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer);
432
    let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
433
    let e = |e| Error::from_db(shell.clone(), e);
434
    let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
435
    let file = coverage::files(&app.db, report.id)
436
        .await
437
        .map_err(e)?
438
        .into_iter()
439
        .find(|f| f.path == path)
440
        .ok_or_else(|| Error::not_found(shell.clone()))?;
441
    let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default();
442
    let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
443
    let limit = app.config.limits.render_limit.0;
444
    let p = path.clone();
445
    let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads };
446
    let fv = app
447
        .repos
448
        .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit))
449
        .await
450
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
451
    let (lines, note) = match &fv.body {
452
        FileBody::Text(text) => {
453
            let html = crate::render::highlight::lines(&path, text);
454
            let lines = html
455
                .into_iter()
456
                .enumerate()
457
                .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() })
458
                .collect();
459
            (lines, None)
460
        }
461
        FileBody::Binary => (vec![], Some("This file is binary.".to_string())),
462
        FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())),
463
    };
464
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
465
    Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note }))
466
}
467
468
/// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage.
469
pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> {
470
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
471
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
472
    let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?;
473
    let pct = latest.first().and_then(|r| r.percent());
474
    let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into());
475
    let colour = match level(pct) {
476
        "cov-good" => "#2da44e",
477
        "cov-fair" => "#97ca00",
478
        "cov-low" => "#dfb317",
479
        "cov-poor" => "#e05d44",
480
        _ => "#6e7781",
481
    };
482
    let label = "coverage";
483
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
484
    let svg = format!(
485
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{label}: {text}"><title>{label}: {text}</title><rect width="{lw}" height="20" rx="3" fill="#555"/><rect x="{lw}" width="{tw}" height="20" rx="3" fill="{colour}"/><rect x="{lw}" width="4" height="20" fill="{colour}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="{lx}" y="14">{label}</text><text x="{tx}" y="14">{text}</text></g></svg>"##,
486
        w = lw + tw,
487
        lx = lw / 2,
488
        tx = lw + tw / 2,
489
    );
490
    Ok((
491
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
492
        svg,
493
    )
494
        .into_response())
495
}