OXYGENHUB
# c2efc0b4674d oxygenhub / crates / hub / src / web / coverage.rs
18.9 KiB · Rust · last changed Coverage from a workstation: oh coverage upload, shown as uploaded 24 days 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.
36e5a9c1 Andy McCall ↶ 123
pub(super) fn compact(v: f64) -> String {
c987c865 Andy McCall ↶ 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`].
36e5a9c1 Andy McCall ↶ 144
pub(super) struct Series<'a> {
145
    pub values: Vec<f64>,
146
    pub class: &'a str,
c987c865 Andy McCall ↶ 147
    /// Shade down to these values instead of to the axis: a band.
36e5a9c1 Andy McCall ↶ 148
    pub floor: Option<Vec<f64>>,
c987c865 Andy McCall ↶ 149
}
150
36e5a9c1 Andy McCall ↶ 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
c987c865 Andy McCall ↶ 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.
36e5a9c1 Andy McCall ↶ 161
pub(super) fn chart(label: &str, runs: &[Point], series: &[Series], lo: f64, hi: f64, fmt: &dyn Fn(f64) -> String) -> String {
c987c865 Andy McCall ↶ 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 {
554ccacf Andy McCall ↶ 167
        return String::new();
c987c865 Andy McCall ↶ 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
        ));
554ccacf Andy McCall ↶ 183
    }
c987c865 Andy McCall ↶ 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() {
554ccacf Andy McCall ↶ 207
        svg.push_str(&format!(
36e5a9c1 Andy McCall ↶ 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,
c987c865 Andy McCall ↶ 210
            rx = x(i) - col / 2.0,
36e5a9c1 Andy McCall ↶ 211
            t = r.tip,
554ccacf Andy McCall ↶ 212
        ));
213
    }
c987c865 Andy McCall ↶ 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,
36e5a9c1 Andy McCall ↶ 219
            first = runs[0].run,
220
            last = runs[n - 1].run,
c987c865 Andy McCall ↶ 221
        ));
222
    }
554ccacf Andy McCall ↶ 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);
c987c865 Andy McCall ↶ 237
    let picked = match q.report {
554ccacf Andy McCall ↶ 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
    };
c987c865 Andy McCall ↶ 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());
7edaff01 Andy McCall ↶ 245
    // The report before the one shown, on the same bookmark; for a
246
    // bookmark's first report, the default bookmark's latest before it.
247
    let mut previous = report
c987c865 Andy McCall ↶ 248
        .as_ref()
249
        .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned());
7edaff01 Andy McCall ↶ 250
    if let (None, Some(r)) = (&previous, &report) {
251
        if r.bookmark != row.default_bookmark {
252
            previous = coverage::baseline(&app.db, r, &row.default_bookmark).await.map_err(e)?;
253
        }
254
    }
c987c865 Andy McCall ↶ 255
    let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?));
554ccacf Andy McCall ↶ 256
    let mut files = match &report {
257
        Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?,
258
        None => vec![],
259
    };
c987c865 Andy McCall ↶ 260
    // Files that moved against the run before, the largest moves first.
261
    let (mut moved, mut added) = (Vec::new(), 0);
262
    if let Some(p) = &previous {
263
        let before: std::collections::HashMap<String, Option<f64>> =
264
            coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect();
265
        for f in &files {
266
            match before.get(&f.path) {
267
                None => added += 1,
268
                Some(b) => {
269
                    let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0);
270
                    if d.abs() >= 0.05 {
271
                        moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d });
272
                    }
273
                }
274
            }
275
        }
276
        moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path)));
277
        moved.truncate(CHANGED);
278
    }
279
    // How the files spread over coverage bands.
280
    let bounds: [(&str, &str, f64, f64); 5] = [
281
        ("100%", "cov-good", 100.0, 100.1),
282
        ("90 – 99%", "cov-good", 90.0, 100.0),
283
        ("75 – 89%", "cov-fair", 75.0, 90.0),
284
        ("50 – 74%", "cov-low", 50.0, 75.0),
285
        ("under 50%", "cov-poor", -1.0, 50.0),
286
    ];
287
    let counts: Vec<usize> =
288
        bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect();
289
    let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64;
290
    let bands = bounds
291
        .iter()
292
        .zip(&counts)
293
        .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest })
294
        .collect();
554ccacf Andy McCall ↶ 295
    let sort = match q.sort.as_deref() {
296
        Some("percent") => "percent",
297
        Some("path") => "path",
298
        _ => "missed",
299
    };
300
    match sort {
301
        "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))),
302
        "path" => {}
303
        _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))),
304
    }
305
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
c987c865 Andy McCall ↶ 306
    // Charts: oldest on the left.
307
    let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect();
308
    let pcts: Vec<f64> = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect();
309
    let (lo, hi) = (
310
        (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0),
311
        (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0),
312
    );
313
    let tip = |r: &Report| {
314
        format!(
40dd3f86 Andy McCall ↶ 315
            "{} · {} · {} of {} lines",
316
            r.source(),
c987c865 Andy McCall ↶ 317
            r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(),
318
            r.lines_hit,
319
            r.lines_found
320
        )
40dd3f86 Andy McCall ↶ 321
        .replacen("run #", "Run #", 1)
c987c865 Andy McCall ↶ 322
    };
36e5a9c1 Andy McCall ↶ 323
    let points: Vec<Point> = oldest_first
324
        .iter()
325
        .map(|r| Point { run: r.run_number, href: format!("{}/coverage?report={}", repo.url, r.id), tip: tip(r) })
326
        .collect();
c987c865 Andy McCall ↶ 327
    let coverage_chart = chart(
328
        &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()),
36e5a9c1 Andy McCall ↶ 329
        &points,
c987c865 Andy McCall ↶ 330
        &[Series { values: pcts, class: "cov", floor: None }],
331
        lo,
332
        hi,
333
        &|v| format!("{v:.0}%"),
334
    );
335
    let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64;
336
    let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect();
337
    let lines_chart = chart(
338
        &format!("Relevant and covered lines of {bookmark}"),
36e5a9c1 Andy McCall ↶ 339
        &points,
c987c865 Andy McCall ↶ 340
        &[
341
            Series { values: covered.clone(), class: "covered", floor: None },
342
            Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) },
343
        ],
344
        0.0,
345
        (top * 1.08).max(1.0),
346
        &compact,
347
    );
348
    let recent = history
349
        .iter()
350
        .enumerate()
351
        .take(RECENT)
352
        .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) })
353
        .collect();
354
    let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?;
355
    Ok(Page(CoveragePage {
356
        shell,
357
        repo,
358
        bookmark,
359
        bookmarks,
360
        report,
361
        previous,
362
        delta,
363
        files,
364
        sort: sort.into(),
365
        coverage_chart,
366
        lines_chart,
367
        runs: oldest_first.len(),
368
        first_ms,
369
        recent,
370
        moved,
371
        added,
372
        bands,
373
    }))
554ccacf Andy McCall ↶ 374
}
375
376
/// One line of a file, as the file view shows it.
377
pub struct CovLine {
378
    pub number: usize,
379
    pub html: String,
380
    /// None: not an instrumented line.
381
    pub hits: Option<u64>,
382
}
383
384
#[derive(Template)]
385
#[template(path = "coverage_file.html")]
386
pub struct CoverageFilePage {
387
    shell: Shell,
388
    repo: RepoShell,
389
    report: Report,
390
    file: FileRow,
391
    lines: Vec<CovLine>,
392
    note: Option<String>,
393
}
394
395
impl CoverageFilePage {
396
    pub fn pct(&self, p: Option<f64>) -> String {
397
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
398
    }
399
400
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
401
        level(p)
402
    }
403
}
404
405
/// The line counts of the report last read, by file: the file view reads
406
/// the report's artifact again, so that browsing one report's files
407
/// parses it once.
408
type Parsed = Arc<BTreeMap<String, BTreeMap<u32, u64>>>;
409
static LAST: Mutex<Option<(String, Parsed)>> = Mutex::new(None);
410
411
async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> {
412
    if let Some((b, p)) = LAST.lock().ok()?.as_ref() {
413
        if b == blob {
414
            return Some(p.clone());
415
        }
416
    }
417
    let r = oxy_core::hash::ObjectRef::from_hex(blob)?;
418
    let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?;
419
    let parsed = tokio::task::spawn_blocking(move || {
420
        let text = String::from_utf8_lossy(&bytes);
7fff30c4 Andy McCall ↶ 421
        coverage::parse(&text).ok().map(|(files, _, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
554ccacf Andy McCall ↶ 422
    })
423
    .await
424
    .ok()??;
425
    let parsed = Arc::new(parsed);
426
    if let Ok(mut last) = LAST.lock() {
427
        *last = Some((blob.to_string(), parsed.clone()));
428
    }
429
    Some(parsed)
430
}
431
432
/// `GET /{owner}/{name}/coverage/{report}/file/{*path}`.
433
pub async fn file(
434
    State(app): State<Shared>,
435
    viewer: Viewer,
436
    Path((owner, name, id, path)): Path<(String, String, i64, String)>,
437
) -> Result<Page<CoverageFilePage>, Error> {
438
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer);
439
    let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
440
    let e = |e| Error::from_db(shell.clone(), e);
441
    let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
442
    let file = coverage::files(&app.db, report.id)
443
        .await
444
        .map_err(e)?
445
        .into_iter()
446
        .find(|f| f.path == path)
447
        .ok_or_else(|| Error::not_found(shell.clone()))?;
448
    let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default();
449
    let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
450
    let limit = app.config.limits.render_limit.0;
451
    let p = path.clone();
452
    let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads };
453
    let fv = app
454
        .repos
455
        .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit))
456
        .await
457
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
458
    let (lines, note) = match &fv.body {
459
        FileBody::Text(text) => {
460
            let html = crate::render::highlight::lines(&path, text);
461
            let lines = html
462
                .into_iter()
463
                .enumerate()
464
                .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() })
465
                .collect();
466
            (lines, None)
467
        }
468
        FileBody::Binary => (vec![], Some("This file is binary.".to_string())),
469
        FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())),
470
    };
471
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
472
    Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note }))
473
}
474
475
/// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage.
476
pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> {
477
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
478
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
479
    let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?;
480
    let pct = latest.first().and_then(|r| r.percent());
481
    let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into());
482
    let colour = match level(pct) {
483
        "cov-good" => "#2da44e",
484
        "cov-fair" => "#97ca00",
485
        "cov-low" => "#dfb317",
486
        "cov-poor" => "#e05d44",
487
        _ => "#6e7781",
488
    };
489
    let label = "coverage";
490
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
491
    let svg = format!(
492
        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>"##,
493
        w = lw + tw,
494
        lx = lw / 2,
495
        tx = lw + tw / 2,
496
    );
497
    Ok((
498
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
499
        svg,
500
    )
501
        .into_response())
502
}