//! 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 the charts and the recent list draw on. const HISTORY: i64 = 200; /// Runs listed under Recent runs. const RECENT: usize = 10; /// Files listed as changed against the run before. const CHANGED: usize = 12; #[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, } /// A run in the Recent runs list. pub struct RecentRow { pub report: Report, /// Against the report before it, in percentage points. pub delta: Option, } /// A file whose coverage moved against the run before. pub struct Moved { pub path: String, pub before: Option, pub after: Option, pub delta: f64, } /// One band of the file distribution. pub struct Band { pub label: &'static str, pub class: &'static str, pub files: usize, /// Of the widest band, for the bar's width. pub width: f64, } #[derive(Template)] #[template(path = "coverage.html")] pub struct CoveragePage { shell: Shell, repo: RepoShell, bookmark: String, bookmarks: Vec, report: Option, /// The report before this one on the bookmark. previous: Option, delta: Option, files: Vec, sort: String, coverage_chart: String, lines_chart: String, runs: usize, first_ms: Option, recent: Vec, moved: Vec, /// Files that are new against the run before. added: usize, bands: Vec, } impl CoveragePage { pub fn pct(&self, p: Option) -> String { p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) } pub fn pct_of(&self, p: &Option) -> String { self.pct(*p) } pub fn signed(&self, d: &f64) -> String { if d.abs() < 0.05 { "±0.0".to_string() } else { format!("{d:+.1}") } } pub fn delta_class(&self, d: &f64) -> &'static str { if *d >= 0.05 { "up" } else if *d <= -0.05 { "down" } else { "same" } } pub fn class_of(&self, p: Option) -> &'static str { level(p) } pub fn hits(&self, r: &Report) -> String { r.hits_per_line().map(|h| if h >= 100.0 { compact(h) } else { format!("{h:.1}") }).unwrap_or_else(|| "–".into()) } pub fn lines_delta(&self) -> Option<(i64, i64)> { let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?); Some((r.lines_found - p.lines_found, r.lines_hit - p.lines_hit)) } } /// A count, short: 9.4k, 147.7k, 2.3M. pub(super) fn compact(v: f64) -> String { match v { v if v >= 1e9 => format!("{:.1}G", v / 1e9), v if v >= 1e6 => format!("{:.1}M", v / 1e6), v if v >= 1e3 => format!("{:.1}k", v / 1e3), v => format!("{v:.0}"), } } /// 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", } } /// A line of values across runs, for [`chart`]. pub(super) struct Series<'a> { pub values: Vec, pub class: &'a str, /// Shade down to these values instead of to the axis: a band. pub floor: Option>, } /// A run on a chart: its number, where a click goes, what a hover says. pub(super) struct Point { pub run: i64, pub href: String, pub tip: String, } /// An SVG chart of one or more series over runs, oldest on the left, with /// a light grid and labels, and a column per run that names it on hover /// and links to its report. `fmt` labels the y axis. pub(super) fn chart(label: &str, runs: &[Point], series: &[Series], lo: f64, hi: f64, fmt: &dyn Fn(f64) -> String) -> String { let (w, h) = (800.0, 180.0); let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0); let (pw, ph) = (w - left - right, h - top - bottom); let n = runs.len(); if n == 0 { return String::new(); } let x = |i: usize| if n == 1 { left + pw / 2.0 } else { left + pw * i as f64 / (n - 1) as f64 }; let span = if hi > lo { hi - lo } else { 1.0 }; let y = |v: f64| top + ph * (hi - v) / span; let mut svg = format!(r#""#); for i in 0..=4 { let v = lo + span * i as f64 / 4.0; svg.push_str(&format!( r#"{t}"#, x2 = w - right, yy = y(v), tx = left - 6.0, ty = y(v) + 3.5, t = fmt(v), )); } for s in series { let pts: Vec = s.values.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); if n > 1 { let area = match &s.floor { Some(floor) => { let back: Vec = floor.iter().enumerate().rev().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); format!("M{} L{} Z", pts.join(" L"), back.join(" L")) } None => format!("M{:.1},{:.1} L{} L{:.1},{:.1} Z", x(0), top + ph, pts.join(" L"), x(n - 1), top + ph), }; svg.push_str(&format!( r#""#, c = s.class, line = pts.join(" ") )); } if let Some(last) = s.values.last() { svg.push_str(&format!(r#""#, s.class, x(n - 1), y(*last))); } } // A column per run: its tip on hover, its report on a click. let col = if n == 1 { pw } else { pw / (n - 1) as f64 }; for (i, r) in runs.iter().enumerate() { svg.push_str(&format!( r#"{t}"#, href = r.href, rx = x(i) - col / 2.0, t = r.tip, )); } if n > 1 { svg.push_str(&format!( r#"run #{first}run #{last}"#, yb = h - 6.0, xr = w - right, first = runs[0].run, last = runs[n - 1].run, )); } 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 picked = 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 = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?; let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?; let report = picked.or_else(|| history.first().cloned()); // The report before the one shown, on the same bookmark; for a // bookmark's first report, the default bookmark's latest before it. let mut previous = report .as_ref() .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned()); if let (None, Some(r)) = (&previous, &report) { if r.bookmark != row.default_bookmark { previous = coverage::baseline(&app.db, r, &row.default_bookmark).await.map_err(e)?; } } let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?)); let mut files = match &report { Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?, None => vec![], }; // Files that moved against the run before, the largest moves first. let (mut moved, mut added) = (Vec::new(), 0); if let Some(p) = &previous { let before: std::collections::HashMap> = coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect(); for f in &files { match before.get(&f.path) { None => added += 1, Some(b) => { let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0); if d.abs() >= 0.05 { moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d }); } } } } moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path))); moved.truncate(CHANGED); } // How the files spread over coverage bands. let bounds: [(&str, &str, f64, f64); 5] = [ ("100%", "cov-good", 100.0, 100.1), ("90 – 99%", "cov-good", 90.0, 100.0), ("75 – 89%", "cov-fair", 75.0, 90.0), ("50 – 74%", "cov-low", 50.0, 75.0), ("under 50%", "cov-poor", -1.0, 50.0), ]; let counts: Vec = bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect(); let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64; let bands = bounds .iter() .zip(&counts) .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest }) .collect(); 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); // Charts: oldest on the left. let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect(); let pcts: Vec = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect(); let (lo, hi) = ( (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0), (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0), ); let tip = |r: &Report| { format!( "{} · {} · {} of {} lines", r.source(), r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(), r.lines_hit, r.lines_found ) .replacen("run #", "Run #", 1) }; let points: Vec = oldest_first .iter() .map(|r| Point { run: r.run_number, href: format!("{}/coverage?report={}", repo.url, r.id), tip: tip(r) }) .collect(); let coverage_chart = chart( &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()), &points, &[Series { values: pcts, class: "cov", floor: None }], lo, hi, &|v| format!("{v:.0}%"), ); let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64; let covered: Vec = oldest_first.iter().map(|r| r.lines_hit as f64).collect(); let lines_chart = chart( &format!("Relevant and covered lines of {bookmark}"), &points, &[ Series { values: covered.clone(), class: "covered", floor: None }, Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) }, ], 0.0, (top * 1.08).max(1.0), &compact, ); let recent = history .iter() .enumerate() .take(RECENT) .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) }) .collect(); let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?; Ok(Page(CoveragePage { shell, repo, bookmark, bookmarks, report, previous, delta, files, sort: sort.into(), coverage_chart, lines_chart, runs: oldest_first.len(), first_ms, recent, moved, added, bands, })) } /// 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(&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()) }