//! Coverage pages (oxygenhub#113): a repository's coverage, its trend, //! every file, and each file's lines marked run or missed; and a badge. use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::coverage::{self, FileRow, Report}; use crate::repos::view::{self, FileBody, RefKind, RefTarget}; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; /// Reports drawn on the trend. const TREND: i64 = 30; #[derive(Deserialize, Default)] pub struct CoverageQuery { /// A bookmark other than the default. bookmark: Option, /// One report, by id. report: Option, /// `missed` (default), `percent` or `path`. sort: Option, } #[derive(Template)] #[template(path = "coverage.html")] pub struct CoveragePage { shell: Shell, repo: RepoShell, bookmark: String, report: Option, /// Against the report before it on the bookmark, in percentage points. delta: Option, files: Vec, sort: String, trend: String, runs: usize, } impl CoveragePage { pub fn pct(&self, p: Option) -> String { p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) } pub fn delta_text(&self) -> Option { self.delta.map(|d| if d.abs() < 0.05 { "unchanged".to_string() } else { format!("{d:+.1}") }) } pub fn class_of(&self, p: Option) -> &'static str { level(p) } } /// A colour class for a percentage. pub fn level(p: Option) -> &'static str { match p { Some(p) if p >= 90.0 => "cov-good", Some(p) if p >= 75.0 => "cov-fair", Some(p) if p >= 50.0 => "cov-low", Some(_) => "cov-poor", None => "cov-none", } } /// The trend as an SVG line chart, oldest on the left. fn trend_svg(reports: &[Report], url: &str) -> String { let points: Vec<(f64, &Report)> = reports.iter().rev().filter_map(|r| r.percent().map(|p| (p, r))).collect(); if points.len() < 2 { return String::new(); } let (w, h, pad) = (640.0, 120.0, 8.0); let lo = points.iter().map(|p| p.0).fold(100.0f64, f64::min).floor().max(0.0); let hi = points.iter().map(|p| p.0).fold(0.0f64, f64::max).ceil().min(100.0); let (lo, hi) = if hi - lo < 2.0 { ((lo - 1.0).max(0.0), (hi + 1.0).min(100.0)) } else { (lo, hi) }; let step = (w - 2.0 * pad) / (points.len() - 1) as f64; let y = |p: f64| pad + (hi - p) / (hi - lo) * (h - 2.0 * pad); let line: Vec = points.iter().enumerate().map(|(i, (p, _))| format!("{:.1},{:.1}", pad + i as f64 * step, y(*p))).collect(); let mut svg = format!( r#"{hi}%{lo}%"#, n = points.len(), first = points[0].0, last = points[points.len() - 1].0, hb = h - 2.0, pts = line.join(" "), ); for (i, (p, r)) in points.iter().enumerate() { svg.push_str(&format!( r#"Run #{run}: {p:.1}%"#, id = r.id, x = pad + i as f64 * step, y = y(*p), run = r.run_number, )); } svg.push_str(""); svg } /// `GET /{owner}/{name}/coverage`. pub async fn overview( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let report = match q.report { Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?), None => None, }; let bookmark = report.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); let history = coverage::on_bookmark(&app.db, row.id, &bookmark, TREND).await.map_err(e)?; let report = report.or_else(|| history.first().cloned()); let delta = report.as_ref().and_then(|r| { let before = history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id))?; Some(r.percent()? - before.percent()?) }); let mut files = match &report { Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?, None => vec![], }; let sort = match q.sort.as_deref() { Some("percent") => "percent", Some("path") => "path", _ => "missed", }; match sort { "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))), "path" => {} _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))), } let repo = RepoShell::new(&app, &row, "coverage").with_role(role); let trend = trend_svg(&history, &repo.url); Ok(Page(CoveragePage { shell, repo, bookmark, report, delta, files, sort: sort.into(), trend, runs: history.len() })) } /// One line of a file, as the file view shows it. pub struct CovLine { pub number: usize, pub html: String, /// None: not an instrumented line. pub hits: Option, } #[derive(Template)] #[template(path = "coverage_file.html")] pub struct CoverageFilePage { shell: Shell, repo: RepoShell, report: Report, file: FileRow, lines: Vec, note: Option, } impl CoverageFilePage { pub fn pct(&self, p: Option) -> String { p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) } pub fn class_of(&self, p: Option) -> &'static str { level(p) } } /// The line counts of the report last read, by file: the file view reads /// the report's artifact again, so that browsing one report's files /// parses it once. type Parsed = Arc>>; static LAST: Mutex> = Mutex::new(None); async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option { if let Some((b, p)) = LAST.lock().ok()?.as_ref() { if b == blob { return Some(p.clone()); } } let r = oxy_core::hash::ObjectRef::from_hex(blob)?; let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?; let parsed = tokio::task::spawn_blocking(move || { let text = String::from_utf8_lossy(&bytes); coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::>()) }) .await .ok()??; let parsed = Arc::new(parsed); if let Ok(mut last) = LAST.lock() { *last = Some((blob.to_string(), parsed.clone())); } Some(parsed) } /// `GET /{owner}/{name}/coverage/{report}/file/{*path}`. pub async fn file( State(app): State, viewer: Viewer, Path((owner, name, id, path)): Path<(String, String, i64, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer); let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; let file = coverage::files(&app.db, report.id) .await .map_err(e)? .into_iter() .find(|f| f.path == path) .ok_or_else(|| Error::not_found(shell.clone()))?; let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default(); let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); let limit = app.config.limits.render_limit.0; let p = path.clone(); let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads }; let fv = app .repos .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit)) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let (lines, note) = match &fv.body { FileBody::Text(text) => { let html = crate::render::highlight::lines(&path, text); let lines = html .into_iter() .enumerate() .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() }) .collect(); (lines, None) } FileBody::Binary => (vec![], Some("This file is binary.".to_string())), FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())), }; let repo = RepoShell::new(&app, &row, "coverage").with_role(role); Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note })) } /// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage. pub async fn badge(State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?; let pct = latest.first().and_then(|r| r.percent()); let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into()); let colour = match level(pct) { "cov-good" => "#2da44e", "cov-fair" => "#97ca00", "cov-low" => "#dfb317", "cov-poor" => "#e05d44", _ => "#6e7781", }; let label = "coverage"; let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10); let svg = format!( r##"{label}: {text}{label}{text}"##, w = lw + tw, lx = lw / 2, tx = lw + tw / 2, ); Ok(( [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")], svg, ) .into_response()) }