554ccacf Andy McCall 1 month ago ↶
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. 0 f64 , f64 :: min) .floor ( ) .max ( 0. 0 ) ;
77
let hi = points.iter ( ) .map ( | p | p.0 ) .fold ( 0. 0 f64 , 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