nornir 0.4.21

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Append-only JSONL history (`bench_history.json`).
//!
//! One [`BenchRun`] per line. Existing lines are never edited or
//! reordered. Writes are flushed + fsynced before returning.

use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;

use anyhow::{Context, Result};

use super::BenchRun;

/// Append one run to `path`. Creates the file if missing.
/// Refuses runs with an empty `machine` to preserve same-machine
/// comparison semantics.
pub fn append(path: &Path, run: &BenchRun) -> Result<()> {
    if run.machine.trim().is_empty() {
        return Err(anyhow::anyhow!(
            "BenchRun.machine is required for appended runs"
        ));
    }
    let line = serde_json::to_string(run).context("serialise bench run")?;
    let mut f = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .with_context(|| format!("open {}", path.display()))?;
    writeln!(f, "{line}").context("write bench history line")?;
    f.flush()?;
    f.sync_all()?;
    Ok(())
}

/// Read all runs from `path`. Returns empty Vec if file does not exist.
pub fn read_all(path: &Path) -> Result<Vec<BenchRun>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let f = File::open(path).with_context(|| format!("open {}", path.display()))?;
    let mut out = Vec::new();
    for (idx, line) in BufReader::new(f).lines().enumerate() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let run: BenchRun = serde_json::from_str(&line)
            .with_context(|| format!("parse line {} of {}", idx + 1, path.display()))?;
        out.push(run);
    }
    Ok(out)
}

/// Most recent run for a given machine. Used by the no-regression gate.
pub fn last_for_machine<'a>(history: &'a [BenchRun], machine: &str) -> Option<&'a BenchRun> {
    history.iter().rev().find(|r| r.machine == machine)
}

/// One-shot migration: read a legacy `bench_history.json` (which is
/// already JSONL by accident — one object per line — but with some
/// envelope fields missing) and write `<path>.jsonl` next to it with
/// `version` / `machine` / `cores` / `timestamp` backfilled from
/// `defaults` (timestamp inferred from `date` as `${date}T00:00:00Z`
/// if not already set).
///
/// `tests` defaults to empty; legacy runs predate the test-outcome
/// field. Existing legacy file is left untouched: the SPEC mandates
/// that existing history lines are never edited. The new `.jsonl`
/// becomes the active append target.
pub fn migrate_legacy(legacy: &Path, defaults: &BenchRun) -> Result<std::path::PathBuf> {
    let runs = read_all(legacy)?;
    let target = legacy.with_extension("jsonl");
    let mut out = String::new();
    for mut r in runs {
        if r.version.is_empty() { r.version = defaults.version.clone(); }
        if r.machine.is_empty() { r.machine = defaults.machine.clone(); }
        if r.cores == 0 { r.cores = defaults.cores; }
        if r.timestamp.is_none() {
            r.timestamp = Some(format!("{}T00:00:00Z", r.date));
        }
        out.push_str(&serde_json::to_string(&r)?);
        out.push('\n');
    }
    std::fs::write(&target, out).with_context(|| format!("write {}", target.display()))?;
    Ok(target)
}

#[cfg(test)]
mod tests {
    use super::super::{BenchResult, BenchRun, TestOutcome};

    #[test]
    fn legacy_line_parses_without_timestamp_or_tests() {
        let legacy = r#"{"date":"2026-05-27","version":"0.6.4","machine":"ryzen-9-7900","cores":32,"results":[{"name":"rust_crate_st","ops_sec":420072}]}"#;
        let run: BenchRun = serde_json::from_str(legacy).unwrap();
        assert_eq!(run.date, "2026-05-27");
        assert!(run.timestamp.is_none());
        assert!(run.tests.is_empty());
        assert_eq!(run.results.len(), 1);
        assert!(run.all_tests_passed(), "empty tests => passes");
    }

    #[test]
    fn new_line_roundtrips_with_tests() {
        let run = BenchRun {
            date: "2026-05-30".into(),
            timestamp: Some("2026-05-30T21:00:00Z".into()),
            version: "0.6.5".into(),
            machine: "ryzen-9-7900".into(),
            cores: 32,
            results: vec![BenchResult {
                name: "x".into(),
                metrics: serde_json::Map::new(),
            }],
            tests: vec![
                TestOutcome { name: "a".into(), passed: true,  duration_ms: Some(1.2), message: None },
                TestOutcome { name: "b".into(), passed: false, duration_ms: None,      message: Some("oops".into()) },
            ],
        };
        let s = serde_json::to_string(&run).unwrap();
        let back: BenchRun = serde_json::from_str(&s).unwrap();
        assert_eq!(back.failed_tests(), vec!["b"]);
        assert!(!back.all_tests_passed());
        assert_eq!(back.timestamp.as_deref(), Some("2026-05-30T21:00:00Z"));
    }
}