| 1 |
|
| 2 |
|
| 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 |
|
| 17 |
const TREND: i64 = 30; |
| 18 |
|
| 19 |
#[derive(Deserialize, Default)] |
| 20 |
pub struct CoverageQuery { |
| 21 |
|
| 22 |
bookmark: Option<String>, |
| 23 |
|
| 24 |
report: Option<i64>, |
| 25 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 144 |
pub struct CovLine { |
| 145 |
pub number: usize, |
| 146 |
pub html: String, |
| 147 |
|
| 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 |
|
| 173 |
|
| 174 |
|
| 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 |
|
| 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 |
|
| 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 |
|