OXYGENHUB
# c987c86513df oxygenhub / crates / hub / src / web / coverage.rs
18.4 KiB · Rust · last changed Coverage page: charts and a livelier layout 1 month ago source blame history raw
554ccacf Andy McCall ↶ 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
c987c865 Andy McCall ↶ 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;
554ccacf Andy McCall ↶ 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
c987c865 Andy McCall ↶ 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
554ccacf Andy McCall ↶ 57
#[derive(Template)]
58
#[template(path = "coverage.html")]
59
pub struct CoveragePage {
60
    shell: Shell,
61
    repo: RepoShell,
62
    bookmark: String,
c987c865 Andy McCall ↶ 63
    bookmarks: Vec<String>,
554ccacf Andy McCall ↶ 64
    report: Option<Report>,
c987c865 Andy McCall ↶ 65
    /// The report before this one on the bookmark.
66
    previous: Option<Report>,
554ccacf Andy McCall ↶ 67
    delta: Option<f64>,
68
    files: Vec<FileRow>,
69
    sort: String,
c987c865 Andy McCall ↶ 70
    coverage_chart: String,
71
    lines_chart: String,
554ccacf Andy McCall ↶ 72
    runs: usize,
c987c865 Andy McCall ↶ 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>,
554ccacf Andy McCall ↶ 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
c987c865 Andy McCall ↶ 86
    pub fn pct_of(&self, p: &Option<f64>) -> String {
87
        self.pct(*p)
554ccacf Andy McCall ↶ 88
    }
89
c987c865 Andy McCall ↶ 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
554ccacf Andy McCall ↶ 108
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
109
        level(p)
c987c865 Andy McCall ↶ 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
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}"),
554ccacf Andy McCall ↶ 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
c987c865 Andy McCall ↶ 143
/// A line of values across runs, for [`chart`].
144
struct Series<'a> {
145
    values: Vec<f64>,
146
    class: &'a str,
147
    /// Shade down to these values instead of to the axis: a band.
148
    floor: Option<Vec<f64>>,
149
}
150
151
/// An SVG chart of one or more series over runs, oldest on the left, with
152
/// a light grid and labels, and a column per run that names it on hover
153
/// and links to its report. `fmt` labels the y axis.
154
#[allow(clippy::too_many_arguments)] // one chart, described in full by its caller
155
fn chart(
156
    label: &str,
157
    runs: &[&Report],
158
    series: &[Series],
159
    lo: f64,
160
    hi: f64,
161
    fmt: &dyn Fn(f64) -> String,
162
    url: &str,
163
    tip: &dyn Fn(&Report) -> String,
164
) -> String {
165
    let (w, h) = (800.0, 180.0);
166
    let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0);
167
    let (pw, ph) = (w - left - right, h - top - bottom);
168
    let n = runs.len();
169
    if n == 0 {
554ccacf Andy McCall ↶ 170
        return String::new();
c987c865 Andy McCall ↶ 171
    }
172
    let x = |i: usize| if n == 1 { left + pw / 2.0 } else { left + pw * i as f64 / (n - 1) as f64 };
173
    let span = if hi > lo { hi - lo } else { 1.0 };
174
    let y = |v: f64| top + ph * (hi - v) / span;
175
    let mut svg = format!(r#"<svg class="cov-chart" viewBox="0 0 {w} {h}" role="img" aria-label="{label}">"#);
176
    for i in 0..=4 {
177
        let v = lo + span * i as f64 / 4.0;
178
        svg.push_str(&format!(
179
            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>"#,
180
            x2 = w - right,
181
            yy = y(v),
182
            tx = left - 6.0,
183
            ty = y(v) + 3.5,
184
            t = fmt(v),
185
        ));
554ccacf Andy McCall ↶ 186
    }
c987c865 Andy McCall ↶ 187
    for s in series {
188
        let pts: Vec<String> = s.values.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect();
189
        if n > 1 {
190
            let area = match &s.floor {
191
                Some(floor) => {
192
                    let back: Vec<String> = floor.iter().enumerate().rev().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect();
193
                    format!("M{} L{} Z", pts.join(" L"), back.join(" L"))
194
                }
195
                None => format!("M{:.1},{:.1} L{} L{:.1},{:.1} Z", x(0), top + ph, pts.join(" L"), x(n - 1), top + ph),
196
            };
197
            svg.push_str(&format!(
198
                r#"<path class="area {c}" d="{area}"/><polyline class="line {c}" points="{line}"/>"#,
199
                c = s.class,
200
                line = pts.join(" ")
201
            ));
202
        }
203
        if let Some(last) = s.values.last() {
204
            svg.push_str(&format!(r#"<circle class="dot {}" cx="{:.1}" cy="{:.1}" r="4"/>"#, s.class, x(n - 1), y(*last)));
205
        }
206
    }
207
    // A column per run: its tip on hover, its report on a click.
208
    let col = if n == 1 { pw } else { pw / (n - 1) as f64 };
209
    for (i, r) in runs.iter().enumerate() {
554ccacf Andy McCall ↶ 210
        svg.push_str(&format!(
c987c865 Andy McCall ↶ 211
            r#"<a href="{url}/coverage?report={id}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#,
554ccacf Andy McCall ↶ 212
            id = r.id,
c987c865 Andy McCall ↶ 213
            rx = x(i) - col / 2.0,
214
            t = tip(r),
554ccacf Andy McCall ↶ 215
        ));
216
    }
c987c865 Andy McCall ↶ 217
    if n > 1 {
218
        svg.push_str(&format!(
219
            r#"<text class="axis" x="{left}" y="{yb}">run #{first}</text><text class="axis" x="{xr}" y="{yb}" text-anchor="end">run #{last}</text>"#,
220
            yb = h - 6.0,
221
            xr = w - right,
222
            first = runs[0].run_number,
223
            last = runs[n - 1].run_number,
224
        ));
225
    }
554ccacf Andy McCall ↶ 226
    svg.push_str("</svg>");
227
    svg
228
}
229
230
/// `GET /{owner}/{name}/coverage`.
231
pub async fn overview(
232
    State(app): State<Shared>,
233
    viewer: Viewer,
234
    Path((owner, name)): Path<(String, String)>,
235
    Query(q): Query<CoverageQuery>,
236
) -> Result<Page<CoveragePage>, Error> {
237
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
238
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
239
    let e = |e| Error::from_db(shell.clone(), e);
c987c865 Andy McCall ↶ 240
    let picked = match q.report {
554ccacf Andy McCall ↶ 241
        Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?),
242
        None => None,
243
    };
c987c865 Andy McCall ↶ 244
    let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone());
245
    let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?;
246
    let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?;
247
    let report = picked.or_else(|| history.first().cloned());
248
    // The report before the one shown, on the same bookmark.
249
    let previous = report
250
        .as_ref()
251
        .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned());
252
    let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?));
554ccacf Andy McCall ↶ 253
    let mut files = match &report {
254
        Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?,
255
        None => vec![],
256
    };
c987c865 Andy McCall ↶ 257
    // Files that moved against the run before, the largest moves first.
258
    let (mut moved, mut added) = (Vec::new(), 0);
259
    if let Some(p) = &previous {
260
        let before: std::collections::HashMap<String, Option<f64>> =
261
            coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect();
262
        for f in &files {
263
            match before.get(&f.path) {
264
                None => added += 1,
265
                Some(b) => {
266
                    let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0);
267
                    if d.abs() >= 0.05 {
268
                        moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d });
269
                    }
270
                }
271
            }
272
        }
273
        moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path)));
274
        moved.truncate(CHANGED);
275
    }
276
    // How the files spread over coverage bands.
277
    let bounds: [(&str, &str, f64, f64); 5] = [
278
        ("100%", "cov-good", 100.0, 100.1),
279
        ("90 – 99%", "cov-good", 90.0, 100.0),
280
        ("75 – 89%", "cov-fair", 75.0, 90.0),
281
        ("50 – 74%", "cov-low", 50.0, 75.0),
282
        ("under 50%", "cov-poor", -1.0, 50.0),
283
    ];
284
    let counts: Vec<usize> =
285
        bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect();
286
    let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64;
287
    let bands = bounds
288
        .iter()
289
        .zip(&counts)
290
        .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest })
291
        .collect();
554ccacf Andy McCall ↶ 292
    let sort = match q.sort.as_deref() {
293
        Some("percent") => "percent",
294
        Some("path") => "path",
295
        _ => "missed",
296
    };
297
    match sort {
298
        "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))),
299
        "path" => {}
300
        _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))),
301
    }
302
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
c987c865 Andy McCall ↶ 303
    // Charts: oldest on the left.
304
    let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect();
305
    let pcts: Vec<f64> = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect();
306
    let (lo, hi) = (
307
        (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0),
308
        (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0),
309
    );
310
    let tip = |r: &Report| {
311
        format!(
312
            "Run #{} · {} · {} of {} lines",
313
            r.run_number,
314
            r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(),
315
            r.lines_hit,
316
            r.lines_found
317
        )
318
    };
319
    let coverage_chart = chart(
320
        &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()),
321
        &oldest_first,
322
        &[Series { values: pcts, class: "cov", floor: None }],
323
        lo,
324
        hi,
325
        &|v| format!("{v:.0}%"),
326
        &repo.url,
327
        &tip,
328
    );
329
    let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64;
330
    let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect();
331
    let lines_chart = chart(
332
        &format!("Relevant and covered lines of {bookmark}"),
333
        &oldest_first,
334
        &[
335
            Series { values: covered.clone(), class: "covered", floor: None },
336
            Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) },
337
        ],
338
        0.0,
339
        (top * 1.08).max(1.0),
340
        &compact,
341
        &repo.url,
342
        &tip,
343
    );
344
    let recent = history
345
        .iter()
346
        .enumerate()
347
        .take(RECENT)
348
        .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) })
349
        .collect();
350
    let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?;
351
    Ok(Page(CoveragePage {
352
        shell,
353
        repo,
354
        bookmark,
355
        bookmarks,
356
        report,
357
        previous,
358
        delta,
359
        files,
360
        sort: sort.into(),
361
        coverage_chart,
362
        lines_chart,
363
        runs: oldest_first.len(),
364
        first_ms,
365
        recent,
366
        moved,
367
        added,
368
        bands,
369
    }))
554ccacf Andy McCall ↶ 370
}
371
372
/// One line of a file, as the file view shows it.
373
pub struct CovLine {
374
    pub number: usize,
375
    pub html: String,
376
    /// None: not an instrumented line.
377
    pub hits: Option<u64>,
378
}
379
380
#[derive(Template)]
381
#[template(path = "coverage_file.html")]
382
pub struct CoverageFilePage {
383
    shell: Shell,
384
    repo: RepoShell,
385
    report: Report,
386
    file: FileRow,
387
    lines: Vec<CovLine>,
388
    note: Option<String>,
389
}
390
391
impl CoverageFilePage {
392
    pub fn pct(&self, p: Option<f64>) -> String {
393
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
394
    }
395
396
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
397
        level(p)
398
    }
399
}
400
401
/// The line counts of the report last read, by file: the file view reads
402
/// the report's artifact again, so that browsing one report's files
403
/// parses it once.
404
type Parsed = Arc<BTreeMap<String, BTreeMap<u32, u64>>>;
405
static LAST: Mutex<Option<(String, Parsed)>> = Mutex::new(None);
406
407
async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> {
408
    if let Some((b, p)) = LAST.lock().ok()?.as_ref() {
409
        if b == blob {
410
            return Some(p.clone());
411
        }
412
    }
413
    let r = oxy_core::hash::ObjectRef::from_hex(blob)?;
414
    let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?;
415
    let parsed = tokio::task::spawn_blocking(move || {
416
        let text = String::from_utf8_lossy(&bytes);
417
        coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
418
    })
419
    .await
420
    .ok()??;
421
    let parsed = Arc::new(parsed);
422
    if let Ok(mut last) = LAST.lock() {
423
        *last = Some((blob.to_string(), parsed.clone()));
424
    }
425
    Some(parsed)
426
}
427
428
/// `GET /{owner}/{name}/coverage/{report}/file/{*path}`.
429
pub async fn file(
430
    State(app): State<Shared>,
431
    viewer: Viewer,
432
    Path((owner, name, id, path)): Path<(String, String, i64, String)>,
433
) -> Result<Page<CoverageFilePage>, Error> {
434
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer);
435
    let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
436
    let e = |e| Error::from_db(shell.clone(), e);
437
    let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
438
    let file = coverage::files(&app.db, report.id)
439
        .await
440
        .map_err(e)?
441
        .into_iter()
442
        .find(|f| f.path == path)
443
        .ok_or_else(|| Error::not_found(shell.clone()))?;
444
    let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default();
445
    let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
446
    let limit = app.config.limits.render_limit.0;
447
    let p = path.clone();
448
    let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads };
449
    let fv = app
450
        .repos
451
        .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit))
452
        .await
453
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
454
    let (lines, note) = match &fv.body {
455
        FileBody::Text(text) => {
456
            let html = crate::render::highlight::lines(&path, text);
457
            let lines = html
458
                .into_iter()
459
                .enumerate()
460
                .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() })
461
                .collect();
462
            (lines, None)
463
        }
464
        FileBody::Binary => (vec![], Some("This file is binary.".to_string())),
465
        FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())),
466
    };
467
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
468
    Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note }))
469
}
470
471
/// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage.
472
pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> {
473
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
474
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
475
    let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?;
476
    let pct = latest.first().and_then(|r| r.percent());
477
    let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into());
478
    let colour = match level(pct) {
479
        "cov-good" => "#2da44e",
480
        "cov-fair" => "#97ca00",
481
        "cov-low" => "#dfb317",
482
        "cov-poor" => "#e05d44",
483
        _ => "#6e7781",
484
    };
485
    let label = "coverage";
486
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
487
    let svg = format!(
488
        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>"##,
489
        w = lw + tw,
490
        lx = lw / 2,
491
        tx = lw + tw / 2,
492
    );
493
    Ok((
494
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
495
        svg,
496
    )
497
        .into_response())
498
}