| 554ccacf Andy McCall ↶ |
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 |
|
| c987c865 Andy McCall ↶ |
16 |
|
| 17 |
const HISTORY: i64 = 200; |
| 18 |
|
| 19 |
const RECENT: usize = 10; |
| 20 |
|
| 21 |
const CHANGED: usize = 12; |
| 554ccacf Andy McCall ↶ |
22 |
|
| 23 |
#[derive(Deserialize, Default)] |
| 24 |
pub struct CoverageQuery { |
| 25 |
|
| 26 |
bookmark: Option<String>, |
| 27 |
|
| 28 |
report: Option<i64>, |
| 29 |
|
| 30 |
sort: Option<String>, |
| 31 |
} |
| 32 |
|
| c987c865 Andy McCall ↶ |
33 |
|
| 34 |
pub struct RecentRow { |
| 35 |
pub report: Report, |
| 36 |
|
| 37 |
pub delta: Option<f64>, |
| 38 |
} |
| 39 |
|
| 40 |
|
| 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 |
|
| 49 |
pub struct Band { |
| 50 |
pub label: &'static str, |
| 51 |
pub class: &'static str, |
| 52 |
pub files: usize, |
| 53 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 36e5a9c1 Andy McCall ↶ |
144 |
pub(super) struct Series<'a> { |
| 145 |
pub values: Vec<f64>, |
| 146 |
pub class: &'a str, |
| c987c865 Andy McCall ↶ |
147 |
|
| 36e5a9c1 Andy McCall ↶ |
148 |
pub floor: Option<Vec<f64>>, |
| c987c865 Andy McCall ↶ |
149 |
} |
| 150 |
|
| 36e5a9c1 Andy McCall ↶ |
151 |
|
| 152 |
pub(super) struct Point { |
| 153 |
pub run: i64, |
| 154 |
pub href: String, |
| 155 |
pub tip: String, |
| 156 |
} |
| 157 |
|
| c987c865 Andy McCall ↶ |
158 |
|
| 159 |
|
| 160 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 246 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 377 |
pub struct CovLine { |
| 378 |
pub number: usize, |
| 379 |
pub html: String, |
| 380 |
|
| 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 |
|
| 406 |
|
| 407 |
|
| 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 |
|
| 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 |
|
| 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 |
} |