| 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 HISTORY: i64 = 200; |
| 18 | |
|
| 19 | | const RECENT: usize = 10; |
| 20 | |
|
| 21 | | const CHANGED: usize = 12; |
| 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 | |
|
| 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 | |
|
| 57 | | #[derive(Template)] |
| 58 | | #[template(path = "coverage.html")] |
| 59 | | pub struct CoveragePage { |
| 60 | | shell: Shell, |
| 61 | | repo: RepoShell, |
| 62 | | bookmark: String, |
| 63 | | bookmarks: Vec<String>, |
| 64 | | report: Option<Report>, |
| 65 | | |
| 66 | | previous: Option<Report>, |
| 67 | | delta: Option<f64>, |
| 68 | | files: Vec<FileRow>, |
| 69 | | sort: String, |
| 70 | | coverage_chart: String, |
| 71 | | lines_chart: String, |
| 72 | | runs: usize, |
| 73 | | first_ms: Option<i64>, |
| 74 | | recent: Vec<RecentRow>, |
| 75 | | moved: Vec<Moved>, |
| 76 | | |
| 77 | | added: usize, |
| 78 | | bands: Vec<Band>, |
| 79 | | } |
| 80 | |
|
| 81 | | impl CoveragePage { |
| 82 | 11× | pub fn pct(&self, p: Option<f64>) -> String { |
| 83 | 11× | p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) |
| 84 | 11× | } |
| 85 | |
|
| 86 | 0× | pub fn pct_of(&self, p: &Option<f64>) -> String { |
| 87 | 0× | self.pct(*p) |
| 88 | 0× | } |
| 89 | |
|
| 90 | 0× | pub fn signed(&self, d: &f64) -> String { |
| 91 | 0× | if d.abs() < 0.05 { |
| 92 | 0× | "±0.0".to_string() |
| 93 | | } else { |
| 94 | 0× | format!("{d:+.1}") |
| 95 | | } |
| 96 | 0× | } |
| 97 | |
|
| 98 | 0× | pub fn delta_class(&self, d: &f64) -> &'static str { |
| 99 | 0× | if *d >= 0.05 { |
| 100 | 0× | "up" |
| 101 | 0× | } else if *d <= -0.05 { |
| 102 | 0× | "down" |
| 103 | | } else { |
| 104 | 0× | "same" |
| 105 | | } |
| 106 | 0× | } |
| 107 | |
|
| 108 | 6× | pub fn class_of(&self, p: Option<f64>) -> &'static str { |
| 109 | 6× | level(p) |
| 110 | 6× | } |
| 111 | |
|
| 112 | 4× | pub fn hits(&self, r: &Report) -> String { |
| 113 | 4× | r.hits_per_line().map(|h| if h >= 100.0 { compact(h) } else { format!("{h:.1}") }).unwrap_or_else(|| "–".into()) |
| 114 | 4× | } |
| 115 | |
|
| 116 | 2× | pub fn lines_delta(&self) -> Option<(i64, i64)> { |
| 117 | 2× | let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?); |
| 118 | 0× | Some((r.lines_found - p.lines_found, r.lines_hit - p.lines_hit)) |
| 119 | 2× | } |
| 120 | | } |
| 121 | |
|
| 122 | |
|
| 123 | 25× | pub(super) fn compact(v: f64) -> String { |
| 124 | 25× | match v { |
| 125 | 25× | v if v >= 1e9 => format!("{:.1}G", v / 1e9), |
| 126 | 25× | v if v >= 1e6 => format!("{:.1}M", v / 1e6), |
| 127 | 25× | v if v >= 1e3 => format!("{:.1}k", v / 1e3), |
| 128 | 25× | v => format!("{v:.0}"), |
| 129 | | } |
| 130 | 25× | } |
| 131 | |
|
| 132 | |
|
| 133 | 8× | pub fn level(p: Option<f64>) -> &'static str { |
| 134 | 5× | match p { |
| 135 | 8× | Some(p) if p >= 90.0 => "cov-good", |
| 136 | 8× | Some(p) if p >= 75.0 => "cov-fair", |
| 137 | 5× | Some(p) if p >= 50.0 => "cov-low", |
| 138 | 0× | Some(_) => "cov-poor", |
| 139 | 0× | None => "cov-none", |
| 140 | | } |
| 141 | 8× | } |
| 142 | |
|
| 143 | |
|
| 144 | | pub(super) struct Series<'a> { |
| 145 | | pub values: Vec<f64>, |
| 146 | | pub class: &'a str, |
| 147 | | |
| 148 | | pub floor: Option<Vec<f64>>, |
| 149 | | } |
| 150 | |
|
| 151 | |
|
| 152 | | pub(super) struct Point { |
| 153 | | pub run: i64, |
| 154 | | pub href: String, |
| 155 | | pub tip: String, |
| 156 | | } |
| 157 | |
|
| 158 | |
|
| 159 | |
|
| 160 | |
|
| 161 | 11× | pub(super) fn chart(label: &str, runs: &[Point], series: &[Series], lo: f64, hi: f64, fmt: &dyn Fn(f64) -> String) -> String { |
| 162 | 11× | let (w, h) = (800.0, 180.0); |
| 163 | 11× | let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0); |
| 164 | 11× | let (pw, ph) = (w - left - right, h - top - bottom); |
| 165 | 11× | let n = runs.len(); |
| 166 | 11× | if n == 0 { |
| 167 | 0× | return String::new(); |
| 168 | 11× | } |
| 169 | 100× | let x = |i: usize| if n == 1 { left + pw / 2.0 } else { left + pw * i as f64 / (n - 1) as f64 }; |
| 170 | 11× | let span = if hi > lo { hi - lo } else { 1.0 }; |
| 171 | 171× | let y = |v: f64| top + ph * (hi - v) / span; |
| 172 | 11× | let mut svg = format!(r#"<svg class="cov-chart" viewBox="0 0 {w} {h}" role="img" aria-label="{label}">"#); |
| 173 | 55× | for i in 0..=4 { |
| 174 | 55× | let v = lo + span * i as f64 / 4.0; |
| 175 | 55× | svg.push_str(&format!( |
| 176 | 55× | 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 | 55× | x2 = w - right, |
| 178 | 55× | yy = y(v), |
| 179 | 55× | tx = left - 6.0, |
| 180 | 55× | ty = y(v) + 3.5, |
| 181 | 55× | t = fmt(v), |
| 182 | 55× | )); |
| 183 | 55× | } |
| 184 | 16× | for s in series { |
| 185 | 36× | let pts: Vec<String> = s.values.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); |
| 186 | 16× | if n > 1 { |
| 187 | 10× | let area = match &s.floor { |
| 188 | 3× | Some(floor) => { |
| 189 | 9× | let back: Vec<String> = floor.iter().enumerate().rev().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); |
| 190 | 3× | format!("M{} L{} Z", pts.join(" L"), back.join(" L")) |
| 191 | | } |
| 192 | 7× | None => format!("M{:.1},{:.1} L{} L{:.1},{:.1} Z", x(0), top + ph, pts.join(" L"), x(n - 1), top + ph), |
| 193 | | }; |
| 194 | 10× | svg.push_str(&format!( |
| 195 | 10× | r#"<path class="area {c}" d="{area}"/><polyline class="line {c}" points="{line}"/>"#, |
| 196 | 10× | c = s.class, |
| 197 | 10× | line = pts.join(" ") |
| 198 | 10× | )); |
| 199 | 6× | } |
| 200 | 16× | if let Some(last) = s.values.last() { |
| 201 | 16× | svg.push_str(&format!(r#"<circle class="dot {}" cx="{:.1}" cy="{:.1}" r="4"/>"#, s.class, x(n - 1), y(*last))); |
| 202 | 16× | } |
| 203 | | } |
| 204 | | |
| 205 | 11× | let col = if n == 1 { pw } else { pw / (n - 1) as f64 }; |
| 206 | 25× | for (i, r) in runs.iter().enumerate() { |
| 207 | 25× | svg.push_str(&format!( |
| 208 | 25× | r#"<a href="{href}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#, |
| 209 | 25× | href = r.href, |
| 210 | 25× | rx = x(i) - col / 2.0, |
| 211 | 25× | t = r.tip, |
| 212 | 25× | )); |
| 213 | 25× | } |
| 214 | 11× | if n > 1 { |
| 215 | 7× | svg.push_str(&format!( |
| 216 | 7× | 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 | 7× | yb = h - 6.0, |
| 218 | 7× | xr = w - right, |
| 219 | 7× | first = runs[0].run, |
| 220 | 7× | last = runs[n - 1].run, |
| 221 | 7× | )); |
| 222 | 7× | } |
| 223 | 11× | svg.push_str("</svg>"); |
| 224 | 11× | svg |
| 225 | 11× | } |
| 226 | |
|
| 227 | |
|
| 228 | 2× | pub async fn overview( |
| 229 | 2× | State(app): State<Shared>, |
| 230 | 2× | viewer: Viewer, |
| 231 | 2× | Path((owner, name)): Path<(String, String)>, |
| 232 | 2× | Query(q): Query<CoverageQuery>, |
| 233 | 2× | ) -> Result<Page<CoveragePage>, Error> { |
| 234 | 2× | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); |
| 235 | 2× | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 236 | 2× | let e = |e| Error::from_db(shell.clone(), e); |
| 237 | 2× | let picked = match q.report { |
| 238 | 0× | Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?), |
| 239 | 2× | None => None, |
| 240 | | }; |
| 241 | 2× | let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); |
| 242 | 2× | let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?; |
| 243 | 2× | let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?; |
| 244 | 2× | let report = picked.or_else(|| history.first().cloned()); |
| 245 | | |
| 246 | | |
| 247 | 2× | let mut previous = report |
| 248 | 2× | .as_ref() |
| 249 | 2× | .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned()); |
| 250 | 2× | if let (None, Some(r)) = (&previous, &report) { |
| 251 | 2× | if r.bookmark != row.default_bookmark { |
| 252 | 0× | previous = coverage::baseline(&app.db, r, &row.default_bookmark).await.map_err(e)?; |
| 253 | 2× | } |
| 254 | 0× | } |
| 255 | 2× | let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?)); |
| 256 | 2× | let mut files = match &report { |
| 257 | 2× | Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?, |
| 258 | 0× | None => vec![], |
| 259 | | }; |
| 260 | | |
| 261 | 2× | let (mut moved, mut added) = (Vec::new(), 0); |
| 262 | 2× | if let Some(p) = &previous { |
| 263 | 0× | let before: std::collections::HashMap<String, Option<f64>> = |
| 264 | 0× | coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect(); |
| 265 | 0× | for f in &files { |
| 266 | 0× | match before.get(&f.path) { |
| 267 | 0× | None => added += 1, |
| 268 | 0× | Some(b) => { |
| 269 | 0× | let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0); |
| 270 | 0× | if d.abs() >= 0.05 { |
| 271 | 0× | moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d }); |
| 272 | 0× | } |
| 273 | | } |
| 274 | | } |
| 275 | | } |
| 276 | 0× | moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path))); |
| 277 | 0× | moved.truncate(CHANGED); |
| 278 | 2× | } |
| 279 | | |
| 280 | 2× | let bounds: [(&str, &str, f64, f64); 5] = [ |
| 281 | 2× | ("100%", "cov-good", 100.0, 100.1), |
| 282 | 2× | ("90 – 99%", "cov-good", 90.0, 100.0), |
| 283 | 2× | ("75 – 89%", "cov-fair", 75.0, 90.0), |
| 284 | 2× | ("50 – 74%", "cov-low", 50.0, 75.0), |
| 285 | 2× | ("under 50%", "cov-poor", -1.0, 50.0), |
| 286 | 2× | ]; |
| 287 | 2× | let counts: Vec<usize> = |
| 288 | 10× | bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect(); |
| 289 | 2× | let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64; |
| 290 | 2× | let bands = bounds |
| 291 | 2× | .iter() |
| 292 | 2× | .zip(&counts) |
| 293 | 10× | .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest }) |
| 294 | 2× | .collect(); |
| 295 | 2× | let sort = match q.sort.as_deref() { |
| 296 | 0× | Some("percent") => "percent", |
| 297 | 0× | Some("path") => "path", |
| 298 | 2× | _ => "missed", |
| 299 | | }; |
| 300 | 2× | match sort { |
| 301 | 2× | "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 | 2× | "path" => {} |
| 303 | 2× | _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))), |
| 304 | | } |
| 305 | 2× | let repo = RepoShell::new(&app, &row, "coverage").with_role(role); |
| 306 | | |
| 307 | 2× | let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect(); |
| 308 | 2× | let pcts: Vec<f64> = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect(); |
| 309 | 2× | let (lo, hi) = ( |
| 310 | 2× | (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0), |
| 311 | 2× | (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0), |
| 312 | 2× | ); |
| 313 | 2× | let tip = |r: &Report| { |
| 314 | 2× | format!( |
| 315 | | "{} · {} · {} of {} lines", |
| 316 | 2× | r.source(), |
| 317 | 2× | r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(), |
| 318 | | r.lines_hit, |
| 319 | | r.lines_found |
| 320 | | ) |
| 321 | 2× | .replacen("run #", "Run #", 1) |
| 322 | 2× | }; |
| 323 | 2× | let points: Vec<Point> = oldest_first |
| 324 | 2× | .iter() |
| 325 | 2× | .map(|r| Point { run: r.run_number, href: format!("{}/coverage?report={}", repo.url, r.id), tip: tip(r) }) |
| 326 | 2× | .collect(); |
| 327 | 2× | let coverage_chart = chart( |
| 328 | 2× | &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()), |
| 329 | 2× | &points, |
| 330 | 2× | &[Series { values: pcts, class: "cov", floor: None }], |
| 331 | 2× | lo, |
| 332 | 2× | hi, |
| 333 | 10× | &|v| format!("{v:.0}%"), |
| 334 | | ); |
| 335 | 2× | let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64; |
| 336 | 2× | let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect(); |
| 337 | 2× | let lines_chart = chart( |
| 338 | 2× | &format!("Relevant and covered lines of {bookmark}"), |
| 339 | 2× | &points, |
| 340 | | &[ |
| 341 | 2× | Series { values: covered.clone(), class: "covered", floor: None }, |
| 342 | 2× | Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) }, |
| 343 | | ], |
| 344 | | 0.0, |
| 345 | 2× | (top * 1.08).max(1.0), |
| 346 | 2× | &compact, |
| 347 | | ); |
| 348 | 2× | let recent = history |
| 349 | 2× | .iter() |
| 350 | 2× | .enumerate() |
| 351 | 2× | .take(RECENT) |
| 352 | 2× | .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) }) |
| 353 | 2× | .collect(); |
| 354 | 2× | let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?; |
| 355 | 2× | Ok(Page(CoveragePage { |
| 356 | 2× | shell, |
| 357 | 2× | repo, |
| 358 | 2× | bookmark, |
| 359 | 2× | bookmarks, |
| 360 | 2× | report, |
| 361 | 2× | previous, |
| 362 | 2× | delta, |
| 363 | 2× | files, |
| 364 | 2× | sort: sort.into(), |
| 365 | 2× | coverage_chart, |
| 366 | 2× | lines_chart, |
| 367 | 2× | runs: oldest_first.len(), |
| 368 | 2× | first_ms, |
| 369 | 2× | recent, |
| 370 | 2× | moved, |
| 371 | 2× | added, |
| 372 | 2× | bands, |
| 373 | 2× | })) |
| 374 | 2× | } |
| 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 | 1× | pub fn pct(&self, p: Option<f64>) -> String { |
| 397 | 1× | p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) |
| 398 | 1× | } |
| 399 | |
|
| 400 | 1× | pub fn class_of(&self, p: Option<f64>) -> &'static str { |
| 401 | 1× | level(p) |
| 402 | 1× | } |
| 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 | 1× | async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> { |
| 412 | 1× | if let Some((b, p)) = LAST.lock().ok()?.as_ref() { |
| 413 | 0× | if b == blob { |
| 414 | 0× | return Some(p.clone()); |
| 415 | 0× | } |
| 416 | 1× | } |
| 417 | 1× | let r = oxy_core::hash::ObjectRef::from_hex(blob)?; |
| 418 | 1× | let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?; |
| 419 | 1× | let parsed = tokio::task::spawn_blocking(move || { |
| 420 | 1× | let text = String::from_utf8_lossy(&bytes); |
| 421 | 2× | coverage::parse(&text).ok().map(|(files, _, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>()) |
| 422 | 1× | }) |
| 423 | 1× | .await |
| 424 | 1× | .ok()??; |
| 425 | 1× | let parsed = Arc::new(parsed); |
| 426 | 1× | if let Ok(mut last) = LAST.lock() { |
| 427 | 1× | *last = Some((blob.to_string(), parsed.clone())); |
| 428 | 1× | } |
| 429 | 1× | Some(parsed) |
| 430 | 1× | } |
| 431 | |
|
| 432 | |
|
| 433 | 2× | pub async fn file( |
| 434 | 2× | State(app): State<Shared>, |
| 435 | 2× | viewer: Viewer, |
| 436 | 2× | Path((owner, name, id, path)): Path<(String, String, i64, String)>, |
| 437 | 2× | ) -> Result<Page<CoverageFilePage>, Error> { |
| 438 | 2× | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer); |
| 439 | 2× | let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 440 | 2× | let e = |e| Error::from_db(shell.clone(), e); |
| 441 | 2× | let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 442 | 2× | let file = coverage::files(&app.db, report.id) |
| 443 | 2× | .await |
| 444 | 2× | .map_err(e)? |
| 445 | 2× | .into_iter() |
| 446 | 2× | .find(|f| f.path == path) |
| 447 | 2× | .ok_or_else(|| Error::not_found(shell.clone()))?; |
| 448 | 1× | let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default(); |
| 449 | 1× | let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
| 450 | 1× | let limit = app.config.limits.render_limit.0; |
| 451 | 1× | let p = path.clone(); |
| 452 | 1× | let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads }; |
| 453 | 1× | let fv = app |
| 454 | 1× | .repos |
| 455 | 1× | .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit)) |
| 456 | 1× | .await |
| 457 | 1× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 458 | 1× | let (lines, note) = match &fv.body { |
| 459 | 1× | FileBody::Text(text) => { |
| 460 | 1× | let html = crate::render::highlight::lines(&path, text); |
| 461 | 1× | let lines = html |
| 462 | 1× | .into_iter() |
| 463 | 1× | .enumerate() |
| 464 | 7× | .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() }) |
| 465 | 1× | .collect(); |
| 466 | 1× | (lines, None) |
| 467 | | } |
| 468 | 0× | FileBody::Binary => (vec![], Some("This file is binary.".to_string())), |
| 469 | 0× | FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())), |
| 470 | | }; |
| 471 | 1× | let repo = RepoShell::new(&app, &row, "coverage").with_role(role); |
| 472 | 1× | Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note })) |
| 473 | 2× | } |
| 474 | |
|
| 475 | |
|
| 476 | 1× | pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> { |
| 477 | 1× | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); |
| 478 | 1× | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 479 | 1× | let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 480 | 1× | let pct = latest.first().and_then(|r| r.percent()); |
| 481 | 1× | let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into()); |
| 482 | 1× | let colour = match level(pct) { |
| 483 | 1× | "cov-good" => "#2da44e", |
| 484 | 1× | "cov-fair" => "#97ca00", |
| 485 | 1× | "cov-low" => "#dfb317", |
| 486 | 0× | "cov-poor" => "#e05d44", |
| 487 | 0× | _ => "#6e7781", |
| 488 | | }; |
| 489 | 1× | let label = "coverage"; |
| 490 | 1× | let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10); |
| 491 | 1× | 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 | 1× | w = lw + tw, |
| 494 | 1× | lx = lw / 2, |
| 495 | 1× | tx = lw + tw / 2, |
| 496 | | ); |
| 497 | 1× | Ok(( |
| 498 | 1× | [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")], |
| 499 | 1× | svg, |
| 500 | 1× | ) |
| 501 | 1× | .into_response()) |
| 502 | 1× | } |