OXYGENHUB
# 0b203bbf7cc3 oxygenhub / crates / hub / src / web / coverage.rs
11.0 KiB · Rust · last changed Coverage reports, built in 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
16
/// Reports drawn on the trend.
17
const TREND: i64 = 30;
18
19
#[derive(Deserialize, Default)]
20
pub struct CoverageQuery {
21
    /// A bookmark other than the default.
22
    bookmark: Option<String>,
23
    /// One report, by id.
24
    report: Option<i64>,
25
    /// `missed` (default), `percent` or `path`.
26
    sort: Option<String>,
27
}
28
29
#[derive(Template)]
30
#[template(path = "coverage.html")]
31
pub struct CoveragePage {
32
    shell: Shell,
33
    repo: RepoShell,
34
    bookmark: String,
35
    report: Option<Report>,
36
    /// Against the report before it on the bookmark, in percentage points.
37
    delta: Option<f64>,
38
    files: Vec<FileRow>,
39
    sort: String,
40
    trend: String,
41
    runs: usize,
42
}
43
44
impl CoveragePage {
45
    pub fn pct(&self, p: Option<f64>) -> String {
46
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
47
    }
48
49
    pub fn delta_text(&self) -> Option<String> {
50
        self.delta.map(|d| if d.abs() < 0.05 { "unchanged".to_string() } else { format!("{d:+.1}") })
51
    }
52
53
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
54
        level(p)
55
    }
56
}
57
58
/// A colour class for a percentage.
59
pub fn level(p: Option<f64>) -> &'static str {
60
    match p {
61
        Some(p) if p >= 90.0 => "cov-good",
62
        Some(p) if p >= 75.0 => "cov-fair",
63
        Some(p) if p >= 50.0 => "cov-low",
64
        Some(_) => "cov-poor",
65
        None => "cov-none",
66
    }
67
}
68
69
/// The trend as an SVG line chart, oldest on the left.
70
fn trend_svg(reports: &[Report], url: &str) -> String {
71
    let points: Vec<(f64, &Report)> = reports.iter().rev().filter_map(|r| r.percent().map(|p| (p, r))).collect();
72
    if points.len() < 2 {
73
        return String::new();
74
    }
75
    let (w, h, pad) = (640.0, 120.0, 8.0);
76
    let lo = points.iter().map(|p| p.0).fold(100.0f64, f64::min).floor().max(0.0);
77
    let hi = points.iter().map(|p| p.0).fold(0.0f64, f64::max).ceil().min(100.0);
78
    let (lo, hi) = if hi - lo < 2.0 { ((lo - 1.0).max(0.0), (hi + 1.0).min(100.0)) } else { (lo, hi) };
79
    let step = (w - 2.0 * pad) / (points.len() - 1) as f64;
80
    let y = |p: f64| pad + (hi - p) / (hi - lo) * (h - 2.0 * pad);
81
    let line: Vec<String> = points.iter().enumerate().map(|(i, (p, _))| format!("{:.1},{:.1}", pad + i as f64 * step, y(*p))).collect();
82
    let mut svg = format!(
83
        r#"<svg class="cov-trend" viewBox="0 0 {w} {h}" role="img" aria-label="Coverage over the last {n} runs, from {first:.1}% to {last:.1}%"><text x="{w}" y="12" text-anchor="end" class="axis">{hi}%</text><text x="{w}" y="{hb}" text-anchor="end" class="axis">{lo}%</text><polyline points="{pts}"/>"#,
84
        n = points.len(),
85
        first = points[0].0,
86
        last = points[points.len() - 1].0,
87
        hb = h - 2.0,
88
        pts = line.join(" "),
89
    );
90
    for (i, (p, r)) in points.iter().enumerate() {
91
        svg.push_str(&format!(
92
            r#"<a href="{url}/coverage?report={id}"><circle cx="{x:.1}" cy="{y:.1}" r="3.5"><title>Run #{run}: {p:.1}%</title></circle></a>"#,
93
            id = r.id,
94
            x = pad + i as f64 * step,
95
            y = y(*p),
96
            run = r.run_number,
97
        ));
98
    }
99
    svg.push_str("</svg>");
100
    svg
101
}
102
103
/// `GET /{owner}/{name}/coverage`.
104
pub async fn overview(
105
    State(app): State<Shared>,
106
    viewer: Viewer,
107
    Path((owner, name)): Path<(String, String)>,
108
    Query(q): Query<CoverageQuery>,
109
) -> Result<Page<CoveragePage>, Error> {
110
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
111
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
112
    let e = |e| Error::from_db(shell.clone(), e);
113
    let report = match q.report {
114
        Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?),
115
        None => None,
116
    };
117
    let bookmark = report.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone());
118
    let history = coverage::on_bookmark(&app.db, row.id, &bookmark, TREND).await.map_err(e)?;
119
    let report = report.or_else(|| history.first().cloned());
120
    let delta = report.as_ref().and_then(|r| {
121
        let before = history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id))?;
122
        Some(r.percent()? - before.percent()?)
123
    });
124
    let mut files = match &report {
125
        Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?,
126
        None => vec![],
127
    };
128
    let sort = match q.sort.as_deref() {
129
        Some("percent") => "percent",
130
        Some("path") => "path",
131
        _ => "missed",
132
    };
133
    match sort {
134
        "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))),
135
        "path" => {}
136
        _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))),
137
    }
138
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
139
    let trend = trend_svg(&history, &repo.url);
140
    Ok(Page(CoveragePage { shell, repo, bookmark, report, delta, files, sort: sort.into(), trend, runs: history.len() }))
141
}
142
143
/// One line of a file, as the file view shows it.
144
pub struct CovLine {
145
    pub number: usize,
146
    pub html: String,
147
    /// None: not an instrumented line.
148
    pub hits: Option<u64>,
149
}
150
151
#[derive(Template)]
152
#[template(path = "coverage_file.html")]
153
pub struct CoverageFilePage {
154
    shell: Shell,
155
    repo: RepoShell,
156
    report: Report,
157
    file: FileRow,
158
    lines: Vec<CovLine>,
159
    note: Option<String>,
160
}
161
162
impl CoverageFilePage {
163
    pub fn pct(&self, p: Option<f64>) -> String {
164
        p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into())
165
    }
166
167
    pub fn class_of(&self, p: Option<f64>) -> &'static str {
168
        level(p)
169
    }
170
}
171
172
/// The line counts of the report last read, by file: the file view reads
173
/// the report's artifact again, so that browsing one report's files
174
/// parses it once.
175
type Parsed = Arc<BTreeMap<String, BTreeMap<u32, u64>>>;
176
static LAST: Mutex<Option<(String, Parsed)>> = Mutex::new(None);
177
178
async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> {
179
    if let Some((b, p)) = LAST.lock().ok()?.as_ref() {
180
        if b == blob {
181
            return Some(p.clone());
182
        }
183
    }
184
    let r = oxy_core::hash::ObjectRef::from_hex(blob)?;
185
    let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?;
186
    let parsed = tokio::task::spawn_blocking(move || {
187
        let text = String::from_utf8_lossy(&bytes);
188
        coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
189
    })
190
    .await
191
    .ok()??;
192
    let parsed = Arc::new(parsed);
193
    if let Ok(mut last) = LAST.lock() {
194
        *last = Some((blob.to_string(), parsed.clone()));
195
    }
196
    Some(parsed)
197
}
198
199
/// `GET /{owner}/{name}/coverage/{report}/file/{*path}`.
200
pub async fn file(
201
    State(app): State<Shared>,
202
    viewer: Viewer,
203
    Path((owner, name, id, path)): Path<(String, String, i64, String)>,
204
) -> Result<Page<CoverageFilePage>, Error> {
205
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer);
206
    let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
207
    let e = |e| Error::from_db(shell.clone(), e);
208
    let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
209
    let file = coverage::files(&app.db, report.id)
210
        .await
211
        .map_err(e)?
212
        .into_iter()
213
        .find(|f| f.path == path)
214
        .ok_or_else(|| Error::not_found(shell.clone()))?;
215
    let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default();
216
    let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
217
    let limit = app.config.limits.render_limit.0;
218
    let p = path.clone();
219
    let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads };
220
    let fv = app
221
        .repos
222
        .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit))
223
        .await
224
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
225
    let (lines, note) = match &fv.body {
226
        FileBody::Text(text) => {
227
            let html = crate::render::highlight::lines(&path, text);
228
            let lines = html
229
                .into_iter()
230
                .enumerate()
231
                .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() })
232
                .collect();
233
            (lines, None)
234
        }
235
        FileBody::Binary => (vec![], Some("This file is binary.".to_string())),
236
        FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())),
237
    };
238
    let repo = RepoShell::new(&app, &row, "coverage").with_role(role);
239
    Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note }))
240
}
241
242
/// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage.
243
pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> {
244
    let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer);
245
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
246
    let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?;
247
    let pct = latest.first().and_then(|r| r.percent());
248
    let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into());
249
    let colour = match level(pct) {
250
        "cov-good" => "#2da44e",
251
        "cov-fair" => "#97ca00",
252
        "cov-low" => "#dfb317",
253
        "cov-poor" => "#e05d44",
254
        _ => "#6e7781",
255
    };
256
    let label = "coverage";
257
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
258
    let svg = format!(
259
        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>"##,
260
        w = lw + tw,
261
        lx = lw / 2,
262
        tx = lw + tw / 2,
263
    );
264
    Ok((
265
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
266
        svg,
267
    )
268
        .into_response())
269
}
270