OXYGENHUB

Coverage / crates/hub/src/reports.rs 91.7%

343 of 374 lines run, 71 of 120 functions · run #528 on main · run missed

1
//! Reports (oxygenhub#127, docs/reports.md): a directory a Flow job writes
2
//! with `report.toml` at its top. The runner uploads each file as a blob of
3
//! the run's repository, then a manifest naming them as the job's artifact
4
//! under the report's name. The hub checks the manifest, reads the
5
//! declaration and indexes the report, its metrics and its files, so the
6
//! Reports tab, its history and checks read only the database, and garbage
7
//! collection keeps every file a kept report names.
8
9
use crate::db::{now_ms, Pool};
10
use crate::Shared;
11
use serde::Deserialize;
12
13
/// Files in one report, and their total size; the runner keeps to these.
14
pub const MAX_FILES: usize = 2000;
15
pub const MAX_BYTES: u64 = 512 << 20;
16
/// Largest `report.toml` read.
17
const MAX_DECLARATION: usize = 64 << 10;
18
/// Metrics kept from one report.
19
const MAX_METRICS: usize = 200;
20
/// The kinds a report may declare.
21
pub const KINDS: [&str; 4] = ["coverage", "tests", "performance", "custom"];
22
23
/// A report file's path: `/`-separated names of letters, digits, `.`,
24
/// `_`, `-` and `+`, never `.` or `..`, so it travels in a header and a URL
25
/// as it is.
2626×
pub fn path_ok(p: &str) -> bool {
2726×
    p.len() <= 1024
2826×
        && p.split('/').count() <= 32
2934×
        && p.split('/').all(|n| {
30232×
            !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
3134×
        })
3226×
}
33
34
/// A report's manifest, as the runner uploads it.
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 {
4910×
    pub fn bytes(&self) -> u64 {
5010×
        self.files.iter().map(|f| f.size).sum()
5110×
    }
52
53
    /// The blob of the file at `path`.
546×
    pub fn blob(&self, path: &str) -> Option<&str> {
5514×
        self.files.iter().find(|f| f.path == path).map(|f| f.blob.as_str())
566×
    }
57
}
58
59
/// Read and check a manifest: version 1, clean unique paths, blob names,
60
/// within the limits, with `report.toml`.
619×
pub fn parse_manifest(bytes: &[u8]) -> Result<Manifest, String> {
629×
    let m: Manifest = serde_json::from_slice(bytes).map_err(|e| format!("the report manifest is not valid: {e}"))?;
639×
    if m.version != 1 {
641×
        return Err(format!("report manifest version {} is not known; this hub reads version 1", m.version));
658×
    }
668×
    if m.files.len() > MAX_FILES {
670×
        return Err(format!("the report holds {} files; at most {MAX_FILES}", m.files.len()));
688×
    }
698×
    if m.bytes() > MAX_BYTES {
700×
        return Err(format!("the report holds {} MiB; at most {} MiB", m.bytes() >> 20, MAX_BYTES >> 20));
718×
    }
728×
    let mut seen = std::collections::HashSet::new();
7317×
    for f in &m.files {
7417×
        if !path_ok(&f.path) {
752×
            return Err(format!("report file {:?}: names may hold letters, digits, '.', '_', '-' and '+'", f.path));
7615×
        }
7715×
        if !seen.insert(f.path.as_str()) {
781×
            return Err(format!("report file {} is named twice", f.path));
7914×
        }
8014×
        if oxy_core::hash::ObjectRef::from_hex(&f.blob).is_none() {
811×
            return Err(format!("report file {}: {:?} is not a blob name", f.path, f.blob));
8213×
        }
83
    }
844×
    if m.blob("report.toml").is_none() {
851×
        return Err("the report has no report.toml at its top".into());
863×
    }
873×
    Ok(m)
889×
}
89
90
/// What `report.toml` declares.
91
#[derive(Clone, Debug, PartialEq)]
92
pub struct Declaration {
93
    pub kind: String,
94
    pub title: String,
95
    pub summary: String,
96
    /// The data file of a built-in kind (`lcov.info`, `junit.xml`).
97
    pub file: Option<String>,
98
    pub metrics: Vec<Metric>,
99
    /// The free-form `[context]` table, as JSON.
100
    pub context: serde_json::Value,
101
}
102
103
/// A number the hub tracks across runs.
104
#[derive(Clone, Debug, PartialEq, sqlx::FromRow)]
105
pub struct Metric {
106
    pub key: String,
107
    pub value: f64,
108
    pub unit: String,
109
    /// `higher`, `lower`, or empty when neither is better.
110
    pub better: String,
111
    pub fail_above: Option<f64>,
112
    pub fail_below: Option<f64>,
113
}
114
115
impl Declaration {
116
    /// The declaration of a report whose `report.toml` could not be read:
117
    /// kept as a custom report saying why, so its files stay and the reader
118
    /// sees what to fix.
1190×
    pub fn unreadable(name: &str, why: &str) -> Declaration {
1200×
        Declaration {
1210×
            kind: "custom".into(),
1220×
            title: name.into(),
1230×
            summary: format!("report.toml: {why}"),
1240×
            file: None,
1250×
            metrics: vec![],
1260×
            context: serde_json::json!({}),
1270×
        }
1280×
    }
129
}
130
13112×
fn number(v: &toml::Value) -> Option<f64> {
1323×
    match v {
1333×
        toml::Value::Integer(i) => Some(*i as f64),
1343×
        toml::Value::Float(f) if f.is_finite() => Some(*f),
1356×
        _ => None,
136
    }
13712×
}
138
1397×
fn metric_key_ok(k: &str) -> bool {
14051×
    !k.is_empty() && k.len() <= 64 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
1417×
}
142
143
/// Read `report.toml` for report `name`; the error says what to fix.
14410×
pub fn parse_declaration(name: &str, bytes: &[u8]) -> Result<Declaration, String> {
14510×
    if bytes.len() > MAX_DECLARATION {
1460×
        return Err(format!("{} KiB; at most {} KiB", bytes.len() >> 10, MAX_DECLARATION >> 10));
14710×
    }
14810×
    let text = std::str::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())?;
14910×
    let table: toml::Table = toml::from_str(text).map_err(|e| {
1501×
        let line = e.span().map(|s| text[..s.start.min(text.len())].lines().count().max(1));
1511×
        let what = if e.message().trim().is_empty() { "not valid TOML" } else { e.message().trim() };
1521×
        match line {
1531×
            Some(n) => format!("line {n}: {what}"),
1540×
            None => what.to_string(),
155
        }
1561×
    })?;
15720×
    let text_of = |key: &str, max: usize| -> Result<Option<String>, String> {
15820×
        match table.get(key) {
1598×
            None => Ok(None),
16012×
            Some(toml::Value::String(s)) if s.len() <= max => Ok(Some(s.clone())),
1610×
            Some(toml::Value::String(_)) => Err(format!("`{key}` is longer than {max} bytes")),
1620×
            Some(_) => Err(format!("`{key}` is a string")),
163
        }
16420×
    };
16522×
    for key in table.keys() {
16622×
        if !matches!(key.as_str(), "kind" | "title" | "summary" | "file" | "metrics" | "context") {
1671×
            return Err(format!("`{key}` is not known (kind, title, summary, file, metrics, context)"));
16821×
        }
169
    }
1708×
    let kind = text_of("kind", 32)?.ok_or("`kind` is required: one of coverage, tests, performance, custom")?;
1717×
    if !KINDS.contains(&kind.as_str()) {
1721×
        return Err(format!("kind {kind:?} is not known: one of coverage, tests, performance, custom"));
1736×
    }
1746×
    let file = text_of("file", 1024)?;
1756×
    if let Some(f) = &file {
1761×
        if !path_ok(f) {
1770×
            return Err(format!("file {f:?} is not a path in the report"));
1781×
        }
1795×
    }
1806×
    let mut metrics = Vec::new();
1816×
    match table.get("metrics") {
1821×
        None => {}
1835×
        Some(toml::Value::Table(t)) => {
1845×
            if t.len() > MAX_METRICS {
1850×
                return Err(format!("{} metrics; at most {MAX_METRICS}", t.len()));
1865×
            }
1877×
            for (key, v) in t {
1887×
                if !metric_key_ok(key) {
1890×
                    return Err(format!("metric {key:?}: names hold letters, digits, '.', '_' and '-'"));
1907×
                }
1917×
                metrics.push(parse_metric(key, v).map_err(|e| format!("metric {key}: {e}"))?);
192
            }
193
        }
1940×
        Some(_) => return Err("`metrics` is a table".into()),
195
    }
1963×
    let context = match table.get("context") {
1971×
        None => serde_json::json!({}),
1982×
        Some(v @ toml::Value::Table(_)) => serde_json::to_value(v).map_err(|e| format!("context: {e}"))?,
1990×
        Some(_) => return Err("`context` is a table".into()),
200
    };
201
    Ok(Declaration {
2023×
        kind,
2033×
        title: text_of("title", 200)?.unwrap_or_else(|| name.to_string()),
2043×
        summary: text_of("summary", 1000)?.unwrap_or_default(),
2053×
        file,
2063×
        metrics,
2073×
        context,
208
    })
20910×
}
210
2117×
fn parse_metric(key: &str, v: &toml::Value) -> Result<Metric, String> {
2127×
    let mut m = Metric { key: key.into(), value: 0.0, unit: String::new(), better: String::new(), fail_above: None, fail_below: None };
2137×
    if let Some(n) = number(v) {
2141×
        m.value = n;
2151×
        return Ok(m);
2166×
    }
2176×
    let toml::Value::Table(t) = v else {
2181×
        return Err("a number, or a table with `value`".into());
219
    };
22013×
    for (k, v) in t {
22113×
        match k.as_str() {
22213×
            "value" => m.value = number(v).ok_or("`value` is a number")?,
22310×
            "unit" => match v {
2244×
                toml::Value::String(s) if s.len() <= 32 => m.unit = s.clone(),
2250×
                _ => return Err("`unit` is a short string".into()),
226
            },
2276×
            "better" => match v.as_str() {
2284×
                Some(s @ ("higher" | "lower")) => m.better = s.into(),
2291×
                _ => return Err("`better` is \"higher\" or \"lower\"".into()),
230
            },
2312×
            "fail_above" => m.fail_above = Some(number(v).ok_or("`fail_above` is a number")?),
2320×
            "fail_below" => m.fail_below = Some(number(v).ok_or("`fail_below` is a number")?),
2330×
            _ => return Err(format!("`{k}` is not known (value, unit, better, fail_above, fail_below)")),
234
        }
235
    }
2364×
    if !t.contains_key("value") {
2371×
        return Err("`value` is required".into());
2383×
    }
2393×
    Ok(m)
2407×
}
241
242
/// Where a report came from.
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
/// Index report `name` of a job, replacing any the job kept before.
2541×
pub async fn keep(app: &Shared, o: &Origin<'_>, name: &str, d: &Declaration, m: &Manifest, manifest: &str) -> sqlx::Result<i64> {
2551×
    let mut tx = crate::db::begin_write(&app.db).await?;
2561×
    sqlx::query("DELETE FROM reports WHERE job_id = $1 AND name = $2").bind(o.job_id).bind(name).execute(&mut *tx).await?;
2571×
    let id: i64 = sqlx::query_scalar(
2581×
        "INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, \
2591×
         manifest, files, bytes, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id",
2601×
    )
2611×
    .bind(o.repo_id)
2621×
    .bind(name)
2631×
    .bind(&d.kind)
2641×
    .bind(&d.title)
2651×
    .bind(&d.summary)
2661×
    .bind(d.context.to_string())
2671×
    .bind(o.run_id)
2681×
    .bind(o.job_id)
2691×
    .bind(o.run_number)
2701×
    .bind(o.job)
2711×
    .bind(o.bookmark)
2721×
    .bind(o.state)
2731×
    .bind(manifest)
2741×
    .bind(m.files.len() as i64)
2751×
    .bind(m.bytes() as i64)
2761×
    .bind(now_ms())
2771×
    .fetch_one(&mut *tx)
2781×
    .await?;
2791×
    for x in &d.metrics {
2801×
        sqlx::query(
2811×
            "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above, fail_below) VALUES ($1, $2, $3, $4, $5, $6, $7)",
2821×
        )
2831×
        .bind(id)
2841×
        .bind(&x.key)
2851×
        .bind(x.value)
2861×
        .bind(&x.unit)
2871×
        .bind(&x.better)
2881×
        .bind(x.fail_above)
2891×
        .bind(x.fail_below)
2901×
        .execute(&mut *tx)
2911×
        .await?;
292
    }
2933×
    for f in &m.files {
2943×
        sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES ($1, $2, $3, $4)")
2953×
            .bind(id)
2963×
            .bind(&f.path)
2973×
            .bind(&f.blob)
2983×
            .bind(f.size as i64)
2993×
            .execute(&mut *tx)
3003×
            .await?;
301
    }
3021×
    tx.commit().await?;
3031×
    mark(app, o.repo_id);
3041×
    Ok(id)
3051×
}
306
307
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
308
309
/// Per hub (tests run several in one process) and repository.
3106×
fn key(app: &Shared, repo_id: i64) -> String {
3116×
    format!("{}#{repo_id}", app.repos.root().display())
3126×
}
313
314
/// Whether a repository has reports, for the Reports tab.
315538×
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
316538×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
317538×
}
318
3191×
fn mark(app: &Shared, repo_id: i64) {
3201×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
3211×
}
322
323
/// At start: which repositories have reports.
324202×
pub async fn load(app: &Shared) -> sqlx::Result<()> {
325202×
    let ids: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT repo_id FROM reports").fetch_all(&app.db).await?;
326202×
    for id in ids {
3270×
        mark(app, id);
3280×
    }
329202×
    Ok(())
330202×
}
331
332
/// A kept report (one job's, or one upload's).
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
    /// The `[context]` table as JSON.
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
    /// When its files expired under retention (§3.3); its metrics stay.
354
    pub files_expired_ms: Option<i64>,
355
}
356
357
impl Report {
358
    /// The `[context]` table's entries, as text, in order.
3594×
    pub fn context_rows(&self) -> Vec<(String, String)> {
3604×
        let v: serde_json::Value = serde_json::from_str(&self.context).unwrap_or_default();
3614×
        v.as_object()
3624×
            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect())
3634×
            .unwrap_or_default()
3644×
    }
365
}
366
367
/// The newest report of each name in a repository, on `bookmark` when
368
/// given (else on any), newest first.
3693×
pub async fn latest(pool: &Pool, repo_id: i64, bookmark: Option<&str>) -> sqlx::Result<Vec<Report>> {
3703×
    sqlx::query_as(
3713×
        "SELECT * FROM reports r WHERE repo_id = $1 AND ($2 IS NULL OR bookmark = $3) \
3723×
         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) \
3733×
                   ORDER BY created_ms DESC, id DESC LIMIT 1) \
3743×
         ORDER BY created_ms DESC",
3753×
    )
3763×
    .bind(repo_id)
3773×
    .bind(bookmark)
3783×
    .bind(bookmark)
3793×
    .bind(bookmark)
3803×
    .bind(bookmark)
3813×
    .fetch_all(pool)
3823×
    .await
3833×
}
384
385
/// Report `name`'s history on `bookmark`, newest first.
3868×
pub async fn history(pool: &Pool, repo_id: i64, name: &str, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
3878×
    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")
3888×
        .bind(repo_id)
3898×
        .bind(name)
3908×
        .bind(bookmark)
3918×
        .bind(limit)
3928×
        .fetch_all(pool)
3938×
        .await
3948×
}
395
396
/// The bookmarks report `name` has runs on, most recent first.
3979×
pub async fn bookmarks_of(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<String>> {
3989×
    sqlx::query_scalar("SELECT bookmark FROM reports WHERE repo_id = $1 AND name = $2 GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
3999×
        .bind(repo_id)
4009×
        .bind(name)
4019×
        .fetch_all(pool)
4029×
        .await
4039×
}
404
405
/// One report of a repository.
4068×
pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
4078×
    sqlx::query_as("SELECT * FROM reports WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await
4088×
}
409
410
/// A file of report `id`: its blob and size.
41112×
pub async fn file(pool: &Pool, id: i64, path: &str) -> sqlx::Result<Option<(String, i64)>> {
41212×
    sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = $1 AND path = $2").bind(id).bind(path).fetch_optional(pool).await
41312×
}
414
415
/// Report `id`'s files, by path, with their sizes.
4164×
pub async fn files_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<(String, i64)>> {
4174×
    sqlx::query_as("SELECT path, size FROM report_files WHERE report_id = $1 ORDER BY path").bind(id).fetch_all(pool).await
4184×
}
419
420
/// The reports a job published, by name.
4216×
pub async fn of_job(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Report>> {
4226×
    sqlx::query_as("SELECT * FROM reports WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await
4236×
}
424
425
/// The capability a report's files are served under: they are read from a
426
/// sandboxed frame, which carries no session, so the link itself is what
427
/// lets it in, and only readers of the repository are given it.
42865×
pub fn file_mac(app: &Shared, id: i64) -> String {
42965×
    app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string()
43065×
}
431
432
/// Runs of a report on a bookmark other than the default whose files are
433
/// kept (docs/reports.md §3.3); older runs keep their metrics only.
434
pub const KEEP_OTHER_RUNS: i64 = 50;
435
436
/// Expire report files under retention (docs/reports.md §3.3): every run on
437
/// a repository's default bookmark keeps them; on any other bookmark (an
438
/// upload for a change has none) the newest [`KEEP_OTHER_RUNS`] runs of each
439
/// report do. An expired report keeps its metrics and summary and loses
440
/// its `report_files` rows, so garbage collection may prune the blobs.
441
/// Answers how many reports expired.
4425×
pub async fn expire(pool: &Pool) -> sqlx::Result<u64> {
4435×
    let mut tx = crate::db::begin_write(pool).await?;
4445×
    let ids: Vec<i64> = sqlx::query_scalar(
4455×
        "SELECT r.id FROM reports r JOIN repositories p ON p.id = r.repo_id \
4465×
         WHERE r.files_expired_ms IS NULL AND r.bookmark != p.default_bookmark \
4475×
         AND (SELECT COUNT(*) FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND s.bookmark = r.bookmark \
4485×
              AND (s.created_ms > r.created_ms OR (s.created_ms = r.created_ms AND s.id > r.id))) >= $1",
4495×
    )
4505×
    .bind(KEEP_OTHER_RUNS)
4515×
    .fetch_all(&mut *tx)
4525×
    .await?;
4535×
    let now = now_ms();
4545×
    for id in &ids {
4552×
        sqlx::query("DELETE FROM report_files WHERE report_id = $1").bind(id).execute(&mut *tx).await?;
4562×
        sqlx::query("UPDATE reports SET files_expired_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?;
457
    }
4585×
    tx.commit().await?;
4595×
    Ok(ids.len() as u64)
4605×
}
461
462
/// Report `id`'s metrics, by key.
46319×
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> {
46419×
    sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = $1 ORDER BY key")
46519×
        .bind(id)
46619×
        .fetch_all(pool)
46719×
        .await
46819×
}
469
470
/// Every file blob a repository's kept reports name, for garbage collection
471
/// to keep too: the database is the only thing that names them.
4724×
pub async fn file_blobs(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
4734×
    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")
4744×
        .bind(repo_id)
4754×
        .fetch_all(pool)
4764×
        .await
4774×
}
478
479
#[cfg(test)]
480
mod tests {
481
    use super::*;
482
483
    #[test]
4841×
    fn declarations_are_read_with_their_metrics_and_context() {
4851×
        let d = parse_declaration(
4861×
            "frame-timing",
4871×
            br#"
4881×
kind    = "performance"
4891×
title   = "Frame timing"
4901×
summary = "Median frame 16.4 ms"
4911×
4921×
[metrics]
4931×
median_frame_ms = { value = 16.4, unit = "ms", better = "lower" }
4941×
p99_frame_ms    = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 }
4951×
scenes          = 12
4961×
4971×
[context]
4981×
target_fps    = 30
4991×
budget_cycles = 268800
5001×
"#,
501
        )
5021×
        .unwrap();
5031×
        assert_eq!((d.kind.as_str(), d.title.as_str()), ("performance", "Frame timing"));
5043×
        let keys: Vec<&str> = d.metrics.iter().map(|m| m.key.as_str()).collect();
5051×
        assert_eq!(keys, ["median_frame_ms", "p99_frame_ms", "scenes"]);
5061×
        assert_eq!(d.metrics[1].fail_above, Some(20.0));
5071×
        assert_eq!((d.metrics[2].value, d.metrics[2].unit.as_str()), (12.0, ""));
5081×
        assert_eq!(d.context["budget_cycles"], 268800);
509
5101×
        let minimal = parse_declaration("cov", b"kind = \"coverage\"\nfile = \"lcov.info\"\n").unwrap();
5111×
        assert_eq!((minimal.title.as_str(), minimal.file.as_deref()), ("cov", Some("lcov.info")));
512
5137×
        let bad = |t: &str| parse_declaration("r", t.as_bytes()).unwrap_err();
5141×
        assert!(bad("title = \"x\"").contains("`kind` is required"));
5151×
        assert!(bad("kind = \"graphs\"").contains("not known"));
5161×
        assert!(bad("kind = \"custom\"\ntitel = \"x\"").contains("`titel` is not known"));
5171×
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = \"fast\"").contains("metric fps"));
5181×
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = { value = 1, better = \"more\" }").contains("better"));
5191×
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = { unit = \"Hz\" }").contains("`value` is required"));
5201×
        assert!(bad("kind = \"custom\"\ntitle = ").starts_with("line 2"), "{}", bad("kind = \"custom\"\ntitle = "));
5211×
    }
522
523
    #[test]
5241×
    fn manifests_are_checked() {
5251×
        let blob = "a".repeat(64);
5266×
        let m = |files: &str| parse_manifest(format!("{{\"version\":1,\"files\":[{files}]}}").as_bytes());
5279×
        let file = |p: &str| format!("{{\"path\":\"{p}\",\"blob\":\"{blob}\",\"size\":3}}");
5281×
        let ok = m(&[file("report.toml"), file("data/a.csv")].join(",")).unwrap();
5291×
        assert_eq!((ok.files.len(), ok.bytes()), (2, 6));
5301×
        assert!(m(&file("index.html")).unwrap_err().contains("report.toml"));
5311×
        assert!(m(&[file("report.toml"), file("../x")].join(",")).is_err());
5321×
        assert!(m(&[file("report.toml"), file("a b")].join(",")).is_err());
5331×
        assert!(m(&[file("report.toml"), file("report.toml")].join(",")).unwrap_err().contains("twice"));
5341×
        assert!(m("{\"path\":\"report.toml\",\"blob\":\"zz\",\"size\":1}").unwrap_err().contains("blob"));
5351×
        assert!(parse_manifest(b"{\"version\":2,\"files\":[]}").unwrap_err().contains("version 2"));
5361×
        assert!(path_ok("assets/js/chart.min.js") && !path_ok("/abs") && !path_ok("a//b") && !path_ok(""));
5371×
    }
538
}