| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use crate::db::{now_ms, Pool}; |
| 10 | | use crate::Shared; |
| 11 | | use serde::Deserialize; |
| 12 | |
|
| 13 | |
|
| 14 | | pub const MAX_FILES: usize = 2000; |
| 15 | | pub const MAX_BYTES: u64 = 512 << 20; |
| 16 | |
|
| 17 | | const MAX_DECLARATION: usize = 64 << 10; |
| 18 | |
|
| 19 | | const MAX_METRICS: usize = 200; |
| 20 | |
|
| 21 | | pub const KINDS: [&str; 4] = ["coverage", "tests", "performance", "custom"]; |
| 22 | |
|
| 23 | |
|
| 24 | |
|
| 25 | |
|
| 26 | 26× | pub fn path_ok(p: &str) -> bool { |
| 27 | 26× | p.len() <= 1024 |
| 28 | 26× | && p.split('/').count() <= 32 |
| 29 | 34× | && p.split('/').all(|n| { |
| 30 | 232× | !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) |
| 31 | 34× | }) |
| 32 | 26× | } |
| 33 | |
|
| 34 | |
|
| 35 | | #[derive(Clone, Debug, Deserialize)] |
| 36 | | pub struct Manifest { |
| 37 | | pub version: u32, |
| 38 | | pub files: Vec<ManifestFile>, |
| 39 | | } |
| 40 | |
|
| 41 | | #[derive(Clone, Debug, Deserialize)] |
| 42 | | pub struct ManifestFile { |
| 43 | | pub path: String, |
| 44 | | pub blob: String, |
| 45 | | pub size: u64, |
| 46 | | } |
| 47 | |
|
| 48 | | impl Manifest { |
| 49 | 10× | pub fn bytes(&self) -> u64 { |
| 50 | 10× | self.files.iter().map(|f| f.size).sum() |
| 51 | 10× | } |
| 52 | |
|
| 53 | | |
| 54 | 6× | pub fn blob(&self, path: &str) -> Option<&str> { |
| 55 | 14× | self.files.iter().find(|f| f.path == path).map(|f| f.blob.as_str()) |
| 56 | 6× | } |
| 57 | | } |
| 58 | |
|
| 59 | |
|
| 60 | |
|
| 61 | 9× | pub fn parse_manifest(bytes: &[u8]) -> Result<Manifest, String> { |
| 62 | 9× | let m: Manifest = serde_json::from_slice(bytes).map_err(|e| format!("the report manifest is not valid: {e}"))?; |
| 63 | 9× | if m.version != 1 { |
| 64 | 1× | return Err(format!("report manifest version {} is not known; this hub reads version 1", m.version)); |
| 65 | 8× | } |
| 66 | 8× | if m.files.len() > MAX_FILES { |
| 67 | 0× | return Err(format!("the report holds {} files; at most {MAX_FILES}", m.files.len())); |
| 68 | 8× | } |
| 69 | 8× | if m.bytes() > MAX_BYTES { |
| 70 | 0× | return Err(format!("the report holds {} MiB; at most {} MiB", m.bytes() >> 20, MAX_BYTES >> 20)); |
| 71 | 8× | } |
| 72 | 8× | let mut seen = std::collections::HashSet::new(); |
| 73 | 17× | for f in &m.files { |
| 74 | 17× | if !path_ok(&f.path) { |
| 75 | 2× | return Err(format!("report file {:?}: names may hold letters, digits, '.', '_', '-' and '+'", f.path)); |
| 76 | 15× | } |
| 77 | 15× | if !seen.insert(f.path.as_str()) { |
| 78 | 1× | return Err(format!("report file {} is named twice", f.path)); |
| 79 | 14× | } |
| 80 | 14× | if oxy_core::hash::ObjectRef::from_hex(&f.blob).is_none() { |
| 81 | 1× | return Err(format!("report file {}: {:?} is not a blob name", f.path, f.blob)); |
| 82 | 13× | } |
| 83 | | } |
| 84 | 4× | if m.blob("report.toml").is_none() { |
| 85 | 1× | return Err("the report has no report.toml at its top".into()); |
| 86 | 3× | } |
| 87 | 3× | Ok(m) |
| 88 | 9× | } |
| 89 | |
|
| 90 | |
|
| 91 | | #[derive(Clone, Debug, PartialEq)] |
| 92 | | pub struct Declaration { |
| 93 | | pub kind: String, |
| 94 | | pub title: String, |
| 95 | | pub summary: String, |
| 96 | | |
| 97 | | pub file: Option<String>, |
| 98 | | pub metrics: Vec<Metric>, |
| 99 | | |
| 100 | | pub context: serde_json::Value, |
| 101 | | } |
| 102 | |
|
| 103 | |
|
| 104 | | #[derive(Clone, Debug, PartialEq, sqlx::FromRow)] |
| 105 | | pub struct Metric { |
| 106 | | pub key: String, |
| 107 | | pub value: f64, |
| 108 | | pub unit: String, |
| 109 | | |
| 110 | | pub better: String, |
| 111 | | pub fail_above: Option<f64>, |
| 112 | | pub fail_below: Option<f64>, |
| 113 | | } |
| 114 | |
|
| 115 | | impl Declaration { |
| 116 | | |
| 117 | | |
| 118 | | |
| 119 | 0× | pub fn unreadable(name: &str, why: &str) -> Declaration { |
| 120 | 0× | Declaration { |
| 121 | 0× | kind: "custom".into(), |
| 122 | 0× | title: name.into(), |
| 123 | 0× | summary: format!("report.toml: {why}"), |
| 124 | 0× | file: None, |
| 125 | 0× | metrics: vec![], |
| 126 | 0× | context: serde_json::json!({}), |
| 127 | 0× | } |
| 128 | 0× | } |
| 129 | | } |
| 130 | |
|
| 131 | 12× | fn number(v: &toml::Value) -> Option<f64> { |
| 132 | 3× | match v { |
| 133 | 3× | toml::Value::Integer(i) => Some(*i as f64), |
| 134 | 3× | toml::Value::Float(f) if f.is_finite() => Some(*f), |
| 135 | 6× | _ => None, |
| 136 | | } |
| 137 | 12× | } |
| 138 | |
|
| 139 | 7× | fn metric_key_ok(k: &str) -> bool { |
| 140 | 51× | !k.is_empty() && k.len() <= 64 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) |
| 141 | 7× | } |
| 142 | |
|
| 143 | |
|
| 144 | 10× | pub fn parse_declaration(name: &str, bytes: &[u8]) -> Result<Declaration, String> { |
| 145 | 10× | if bytes.len() > MAX_DECLARATION { |
| 146 | 0× | return Err(format!("{} KiB; at most {} KiB", bytes.len() >> 10, MAX_DECLARATION >> 10)); |
| 147 | 10× | } |
| 148 | 10× | let text = std::str::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())?; |
| 149 | 10× | let table: toml::Table = toml::from_str(text).map_err(|e| { |
| 150 | 1× | let line = e.span().map(|s| text[..s.start.min(text.len())].lines().count().max(1)); |
| 151 | 1× | let what = if e.message().trim().is_empty() { "not valid TOML" } else { e.message().trim() }; |
| 152 | 1× | match line { |
| 153 | 1× | Some(n) => format!("line {n}: {what}"), |
| 154 | 0× | None => what.to_string(), |
| 155 | | } |
| 156 | 1× | })?; |
| 157 | 20× | let text_of = |key: &str, max: usize| -> Result<Option<String>, String> { |
| 158 | 20× | match table.get(key) { |
| 159 | 8× | None => Ok(None), |
| 160 | 12× | Some(toml::Value::String(s)) if s.len() <= max => Ok(Some(s.clone())), |
| 161 | 0× | Some(toml::Value::String(_)) => Err(format!("`{key}` is longer than {max} bytes")), |
| 162 | 0× | Some(_) => Err(format!("`{key}` is a string")), |
| 163 | | } |
| 164 | 20× | }; |
| 165 | 22× | for key in table.keys() { |
| 166 | 22× | if !matches!(key.as_str(), "kind" | "title" | "summary" | "file" | "metrics" | "context") { |
| 167 | 1× | return Err(format!("`{key}` is not known (kind, title, summary, file, metrics, context)")); |
| 168 | 21× | } |
| 169 | | } |
| 170 | 8× | let kind = text_of("kind", 32)?.ok_or("`kind` is required: one of coverage, tests, performance, custom")?; |
| 171 | 7× | if !KINDS.contains(&kind.as_str()) { |
| 172 | 1× | return Err(format!("kind {kind:?} is not known: one of coverage, tests, performance, custom")); |
| 173 | 6× | } |
| 174 | 6× | let file = text_of("file", 1024)?; |
| 175 | 6× | if let Some(f) = &file { |
| 176 | 1× | if !path_ok(f) { |
| 177 | 0× | return Err(format!("file {f:?} is not a path in the report")); |
| 178 | 1× | } |
| 179 | 5× | } |
| 180 | 6× | let mut metrics = Vec::new(); |
| 181 | 6× | match table.get("metrics") { |
| 182 | 1× | None => {} |
| 183 | 5× | Some(toml::Value::Table(t)) => { |
| 184 | 5× | if t.len() > MAX_METRICS { |
| 185 | 0× | return Err(format!("{} metrics; at most {MAX_METRICS}", t.len())); |
| 186 | 5× | } |
| 187 | 7× | for (key, v) in t { |
| 188 | 7× | if !metric_key_ok(key) { |
| 189 | 0× | return Err(format!("metric {key:?}: names hold letters, digits, '.', '_' and '-'")); |
| 190 | 7× | } |
| 191 | 7× | metrics.push(parse_metric(key, v).map_err(|e| format!("metric {key}: {e}"))?); |
| 192 | | } |
| 193 | | } |
| 194 | 0× | Some(_) => return Err("`metrics` is a table".into()), |
| 195 | | } |
| 196 | 3× | let context = match table.get("context") { |
| 197 | 1× | None => serde_json::json!({}), |
| 198 | 2× | Some(v @ toml::Value::Table(_)) => serde_json::to_value(v).map_err(|e| format!("context: {e}"))?, |
| 199 | 0× | Some(_) => return Err("`context` is a table".into()), |
| 200 | | }; |
| 201 | | Ok(Declaration { |
| 202 | 3× | kind, |
| 203 | 3× | title: text_of("title", 200)?.unwrap_or_else(|| name.to_string()), |
| 204 | 3× | summary: text_of("summary", 1000)?.unwrap_or_default(), |
| 205 | 3× | file, |
| 206 | 3× | metrics, |
| 207 | 3× | context, |
| 208 | | }) |
| 209 | 10× | } |
| 210 | |
|
| 211 | 7× | fn parse_metric(key: &str, v: &toml::Value) -> Result<Metric, String> { |
| 212 | 7× | let mut m = Metric { key: key.into(), value: 0.0, unit: String::new(), better: String::new(), fail_above: None, fail_below: None }; |
| 213 | 7× | if let Some(n) = number(v) { |
| 214 | 1× | m.value = n; |
| 215 | 1× | return Ok(m); |
| 216 | 6× | } |
| 217 | 6× | let toml::Value::Table(t) = v else { |
| 218 | 1× | return Err("a number, or a table with `value`".into()); |
| 219 | | }; |
| 220 | 13× | for (k, v) in t { |
| 221 | 13× | match k.as_str() { |
| 222 | 13× | "value" => m.value = number(v).ok_or("`value` is a number")?, |
| 223 | 10× | "unit" => match v { |
| 224 | 4× | toml::Value::String(s) if s.len() <= 32 => m.unit = s.clone(), |
| 225 | 0× | _ => return Err("`unit` is a short string".into()), |
| 226 | | }, |
| 227 | 6× | "better" => match v.as_str() { |
| 228 | 4× | Some(s @ ("higher" | "lower")) => m.better = s.into(), |
| 229 | 1× | _ => return Err("`better` is \"higher\" or \"lower\"".into()), |
| 230 | | }, |
| 231 | 2× | "fail_above" => m.fail_above = Some(number(v).ok_or("`fail_above` is a number")?), |
| 232 | 0× | "fail_below" => m.fail_below = Some(number(v).ok_or("`fail_below` is a number")?), |
| 233 | 0× | _ => return Err(format!("`{k}` is not known (value, unit, better, fail_above, fail_below)")), |
| 234 | | } |
| 235 | | } |
| 236 | 4× | if !t.contains_key("value") { |
| 237 | 1× | return Err("`value` is required".into()); |
| 238 | 3× | } |
| 239 | 3× | Ok(m) |
| 240 | 7× | } |
| 241 | |
|
| 242 | |
|
| 243 | | pub struct Origin<'a> { |
| 244 | | pub repo_id: i64, |
| 245 | | pub run_id: i64, |
| 246 | | pub run_number: i64, |
| 247 | | pub job_id: i64, |
| 248 | | pub job: &'a str, |
| 249 | | pub bookmark: &'a str, |
| 250 | | pub state: &'a str, |
| 251 | | } |
| 252 | |
|
| 253 | |
|
| 254 | 1× | pub async fn keep(app: &Shared, o: &Origin<'_>, name: &str, d: &Declaration, m: &Manifest, manifest: &str) -> sqlx::Result<i64> { |
| 255 | 1× | let mut tx = crate::db::begin_write(&app.db).await?; |
| 256 | 1× | sqlx::query("DELETE FROM reports WHERE job_id = $1 AND name = $2").bind(o.job_id).bind(name).execute(&mut *tx).await?; |
| 257 | 1× | let id: i64 = sqlx::query_scalar( |
| 258 | 1× | "INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, \ |
| 259 | 1× | manifest, files, bytes, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id", |
| 260 | 1× | ) |
| 261 | 1× | .bind(o.repo_id) |
| 262 | 1× | .bind(name) |
| 263 | 1× | .bind(&d.kind) |
| 264 | 1× | .bind(&d.title) |
| 265 | 1× | .bind(&d.summary) |
| 266 | 1× | .bind(d.context.to_string()) |
| 267 | 1× | .bind(o.run_id) |
| 268 | 1× | .bind(o.job_id) |
| 269 | 1× | .bind(o.run_number) |
| 270 | 1× | .bind(o.job) |
| 271 | 1× | .bind(o.bookmark) |
| 272 | 1× | .bind(o.state) |
| 273 | 1× | .bind(manifest) |
| 274 | 1× | .bind(m.files.len() as i64) |
| 275 | 1× | .bind(m.bytes() as i64) |
| 276 | 1× | .bind(now_ms()) |
| 277 | 1× | .fetch_one(&mut *tx) |
| 278 | 1× | .await?; |
| 279 | 1× | for x in &d.metrics { |
| 280 | 1× | sqlx::query( |
| 281 | 1× | "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above, fail_below) VALUES ($1, $2, $3, $4, $5, $6, $7)", |
| 282 | 1× | ) |
| 283 | 1× | .bind(id) |
| 284 | 1× | .bind(&x.key) |
| 285 | 1× | .bind(x.value) |
| 286 | 1× | .bind(&x.unit) |
| 287 | 1× | .bind(&x.better) |
| 288 | 1× | .bind(x.fail_above) |
| 289 | 1× | .bind(x.fail_below) |
| 290 | 1× | .execute(&mut *tx) |
| 291 | 1× | .await?; |
| 292 | | } |
| 293 | 3× | for f in &m.files { |
| 294 | 3× | sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES ($1, $2, $3, $4)") |
| 295 | 3× | .bind(id) |
| 296 | 3× | .bind(&f.path) |
| 297 | 3× | .bind(&f.blob) |
| 298 | 3× | .bind(f.size as i64) |
| 299 | 3× | .execute(&mut *tx) |
| 300 | 3× | .await?; |
| 301 | | } |
| 302 | 1× | tx.commit().await?; |
| 303 | 1× | mark(app, o.repo_id); |
| 304 | 1× | Ok(id) |
| 305 | 1× | } |
| 306 | |
|
| 307 | | static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None); |
| 308 | |
|
| 309 | |
|
| 310 | 6× | fn key(app: &Shared, repo_id: i64) -> String { |
| 311 | 6× | format!("{}#{repo_id}", app.repos.root().display()) |
| 312 | 6× | } |
| 313 | |
|
| 314 | |
|
| 315 | 525× | pub fn has_reports(app: &Shared, repo_id: i64) -> bool { |
| 316 | 525× | HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id))) |
| 317 | 525× | } |
| 318 | |
|
| 319 | 1× | fn mark(app: &Shared, repo_id: i64) { |
| 320 | 1× | HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id)); |
| 321 | 1× | } |
| 322 | |
|
| 323 | |
|
| 324 | 194× | pub async fn load(app: &Shared) -> sqlx::Result<()> { |
| 325 | 194× | let ids: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT repo_id FROM reports").fetch_all(&app.db).await?; |
| 326 | 194× | for id in ids { |
| 327 | 0× | mark(app, id); |
| 328 | 0× | } |
| 329 | 194× | Ok(()) |
| 330 | 194× | } |
| 331 | |
|
| 332 | |
|
| 333 | | #[derive(Clone, Debug, sqlx::FromRow)] |
| 334 | | pub struct Report { |
| 335 | | pub id: i64, |
| 336 | | pub repo_id: i64, |
| 337 | | pub name: String, |
| 338 | | pub kind: String, |
| 339 | | pub title: String, |
| 340 | | pub summary: String, |
| 341 | | |
| 342 | | pub context: String, |
| 343 | | pub run_id: Option<i64>, |
| 344 | | pub job_id: Option<i64>, |
| 345 | | pub run_number: Option<i64>, |
| 346 | | pub job: String, |
| 347 | | pub bookmark: String, |
| 348 | | pub state: String, |
| 349 | | pub manifest: Option<String>, |
| 350 | | pub files: i64, |
| 351 | | pub bytes: i64, |
| 352 | | pub created_ms: i64, |
| 353 | | |
| 354 | | pub files_expired_ms: Option<i64>, |
| 355 | | } |
| 356 | |
|
| 357 | | impl Report { |
| 358 | | |
| 359 | 4× | pub fn context_rows(&self) -> Vec<(String, String)> { |
| 360 | 4× | let v: serde_json::Value = serde_json::from_str(&self.context).unwrap_or_default(); |
| 361 | 4× | v.as_object() |
| 362 | 4× | .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect()) |
| 363 | 4× | .unwrap_or_default() |
| 364 | 4× | } |
| 365 | | } |
| 366 | |
|
| 367 | |
|
| 368 | |
|
| 369 | 2× | pub async fn latest(pool: &Pool, repo_id: i64, bookmark: Option<&str>) -> sqlx::Result<Vec<Report>> { |
| 370 | 2× | sqlx::query_as( |
| 371 | 2× | "SELECT * FROM reports r WHERE repo_id = $1 AND ($2 IS NULL OR bookmark = $3) \ |
| 372 | 2× | AND id = (SELECT id FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND ($4 IS NULL OR s.bookmark = $5) \ |
| 373 | 2× | ORDER BY created_ms DESC, id DESC LIMIT 1) \ |
| 374 | 2× | ORDER BY created_ms DESC", |
| 375 | 2× | ) |
| 376 | 2× | .bind(repo_id) |
| 377 | 2× | .bind(bookmark) |
| 378 | 2× | .bind(bookmark) |
| 379 | 2× | .bind(bookmark) |
| 380 | 2× | .bind(bookmark) |
| 381 | 2× | .fetch_all(pool) |
| 382 | 2× | .await |
| 383 | 2× | } |
| 384 | |
|
| 385 | |
|
| 386 | 7× | pub async fn history(pool: &Pool, repo_id: i64, name: &str, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> { |
| 387 | 7× | sqlx::query_as("SELECT * FROM reports WHERE repo_id = $1 AND name = $2 AND bookmark = $3 ORDER BY created_ms DESC, id DESC LIMIT $4") |
| 388 | 7× | .bind(repo_id) |
| 389 | 7× | .bind(name) |
| 390 | 7× | .bind(bookmark) |
| 391 | 7× | .bind(limit) |
| 392 | 7× | .fetch_all(pool) |
| 393 | 7× | .await |
| 394 | 7× | } |
| 395 | |
|
| 396 | |
|
| 397 | 6× | pub async fn bookmarks_of(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<String>> { |
| 398 | 6× | sqlx::query_scalar("SELECT bookmark FROM reports WHERE repo_id = $1 AND name = $2 GROUP BY bookmark ORDER BY MAX(created_ms) DESC") |
| 399 | 6× | .bind(repo_id) |
| 400 | 6× | .bind(name) |
| 401 | 6× | .fetch_all(pool) |
| 402 | 6× | .await |
| 403 | 6× | } |
| 404 | |
|
| 405 | |
|
| 406 | 6× | pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> { |
| 407 | 6× | sqlx::query_as("SELECT * FROM reports WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await |
| 408 | 6× | } |
| 409 | |
|
| 410 | |
|
| 411 | 8× | pub async fn file(pool: &Pool, id: i64, path: &str) -> sqlx::Result<Option<(String, i64)>> { |
| 412 | 8× | sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = $1 AND path = $2").bind(id).bind(path).fetch_optional(pool).await |
| 413 | 8× | } |
| 414 | |
|
| 415 | |
|
| 416 | |
|
| 417 | |
|
| 418 | 65× | pub fn file_mac(app: &Shared, id: i64) -> String { |
| 419 | 65× | app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string() |
| 420 | 65× | } |
| 421 | |
|
| 422 | |
|
| 423 | |
|
| 424 | | pub const KEEP_OTHER_RUNS: i64 = 50; |
| 425 | |
|
| 426 | |
|
| 427 | |
|
| 428 | |
|
| 429 | |
|
| 430 | |
|
| 431 | |
|
| 432 | 5× | pub async fn expire(pool: &Pool) -> sqlx::Result<u64> { |
| 433 | 5× | let mut tx = crate::db::begin_write(pool).await?; |
| 434 | 5× | let ids: Vec<i64> = sqlx::query_scalar( |
| 435 | 5× | "SELECT r.id FROM reports r JOIN repositories p ON p.id = r.repo_id \ |
| 436 | 5× | WHERE r.files_expired_ms IS NULL AND r.bookmark != p.default_bookmark \ |
| 437 | 5× | AND (SELECT COUNT(*) FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND s.bookmark = r.bookmark \ |
| 438 | 5× | AND (s.created_ms > r.created_ms OR (s.created_ms = r.created_ms AND s.id > r.id))) >= $1", |
| 439 | 5× | ) |
| 440 | 5× | .bind(KEEP_OTHER_RUNS) |
| 441 | 5× | .fetch_all(&mut *tx) |
| 442 | 5× | .await?; |
| 443 | 5× | let now = now_ms(); |
| 444 | 5× | for id in &ids { |
| 445 | 2× | sqlx::query("DELETE FROM report_files WHERE report_id = $1").bind(id).execute(&mut *tx).await?; |
| 446 | 2× | sqlx::query("UPDATE reports SET files_expired_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?; |
| 447 | | } |
| 448 | 5× | tx.commit().await?; |
| 449 | 5× | Ok(ids.len() as u64) |
| 450 | 5× | } |
| 451 | |
|
| 452 | |
|
| 453 | 14× | pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> { |
| 454 | 14× | sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = $1 ORDER BY key") |
| 455 | 14× | .bind(id) |
| 456 | 14× | .fetch_all(pool) |
| 457 | 14× | .await |
| 458 | 14× | } |
| 459 | |
|
| 460 | |
|
| 461 | |
|
| 462 | 4× | pub async fn file_blobs(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
| 463 | 4× | sqlx::query_scalar("SELECT DISTINCT f.blob FROM report_files f JOIN reports r ON r.id = f.report_id WHERE r.repo_id = $1") |
| 464 | 4× | .bind(repo_id) |
| 465 | 4× | .fetch_all(pool) |
| 466 | 4× | .await |
| 467 | 4× | } |
| 468 | |
|
| 469 | | #[cfg(test)] |
| 470 | | mod tests { |
| 471 | | use super::*; |
| 472 | |
|
| 473 | | #[test] |
| 474 | 1× | fn declarations_are_read_with_their_metrics_and_context() { |
| 475 | 1× | let d = parse_declaration( |
| 476 | 1× | "frame-timing", |
| 477 | 1× | br#" |
| 478 | 1× | kind = "performance" |
| 479 | 1× | title = "Frame timing" |
| 480 | 1× | summary = "Median frame 16.4 ms" |
| 481 | 1× |
|
| 482 | 1× | [metrics] |
| 483 | 1× | median_frame_ms = { value = 16.4, unit = "ms", better = "lower" } |
| 484 | 1× | p99_frame_ms = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 } |
| 485 | 1× | scenes = 12 |
| 486 | 1× |
|
| 487 | 1× | [context] |
| 488 | 1× | target_fps = 30 |
| 489 | 1× | budget_cycles = 268800 |
| 490 | 1× | "#, |
| 491 | | ) |
| 492 | 1× | .unwrap(); |
| 493 | 1× | assert_eq!((d.kind.as_str(), d.title.as_str()), ("performance", "Frame timing")); |
| 494 | 3× | let keys: Vec<&str> = d.metrics.iter().map(|m| m.key.as_str()).collect(); |
| 495 | 1× | assert_eq!(keys, ["median_frame_ms", "p99_frame_ms", "scenes"]); |
| 496 | 1× | assert_eq!(d.metrics[1].fail_above, Some(20.0)); |
| 497 | 1× | assert_eq!((d.metrics[2].value, d.metrics[2].unit.as_str()), (12.0, "")); |
| 498 | 1× | assert_eq!(d.context["budget_cycles"], 268800); |
| 499 | |
|
| 500 | 1× | let minimal = parse_declaration("cov", b"kind = \"coverage\"\nfile = \"lcov.info\"\n").unwrap(); |
| 501 | 1× | assert_eq!((minimal.title.as_str(), minimal.file.as_deref()), ("cov", Some("lcov.info"))); |
| 502 | |
|
| 503 | 7× | let bad = |t: &str| parse_declaration("r", t.as_bytes()).unwrap_err(); |
| 504 | 1× | assert!(bad("title = \"x\"").contains("`kind` is required")); |
| 505 | 1× | assert!(bad("kind = \"graphs\"").contains("not known")); |
| 506 | 1× | assert!(bad("kind = \"custom\"\ntitel = \"x\"").contains("`titel` is not known")); |
| 507 | 1× | assert!(bad("kind = \"custom\"\n[metrics]\nfps = \"fast\"").contains("metric fps")); |
| 508 | 1× | assert!(bad("kind = \"custom\"\n[metrics]\nfps = { value = 1, better = \"more\" }").contains("better")); |
| 509 | 1× | assert!(bad("kind = \"custom\"\n[metrics]\nfps = { unit = \"Hz\" }").contains("`value` is required")); |
| 510 | 1× | assert!(bad("kind = \"custom\"\ntitle = ").starts_with("line 2"), "{}", bad("kind = \"custom\"\ntitle = ")); |
| 511 | 1× | } |
| 512 | |
|
| 513 | | #[test] |
| 514 | 1× | fn manifests_are_checked() { |
| 515 | 1× | let blob = "a".repeat(64); |
| 516 | 6× | let m = |files: &str| parse_manifest(format!("{{\"version\":1,\"files\":[{files}]}}").as_bytes()); |
| 517 | 9× | let file = |p: &str| format!("{{\"path\":\"{p}\",\"blob\":\"{blob}\",\"size\":3}}"); |
| 518 | 1× | let ok = m(&[file("report.toml"), file("data/a.csv")].join(",")).unwrap(); |
| 519 | 1× | assert_eq!((ok.files.len(), ok.bytes()), (2, 6)); |
| 520 | 1× | assert!(m(&file("index.html")).unwrap_err().contains("report.toml")); |
| 521 | 1× | assert!(m(&[file("report.toml"), file("../x")].join(",")).is_err()); |
| 522 | 1× | assert!(m(&[file("report.toml"), file("a b")].join(",")).is_err()); |
| 523 | 1× | assert!(m(&[file("report.toml"), file("report.toml")].join(",")).unwrap_err().contains("twice")); |
| 524 | 1× | assert!(m("{\"path\":\"report.toml\",\"blob\":\"zz\",\"size\":1}").unwrap_err().contains("blob")); |
| 525 | 1× | assert!(parse_manifest(b"{\"version\":2,\"files\":[]}").unwrap_err().contains("version 2")); |
| 526 | 1× | assert!(path_ok("assets/js/chart.min.js") && !path_ok("/abs") && !path_ok("a//b") && !path_ok("")); |
| 527 | 1× | } |
| 528 | | } |