nornir 0.1.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Release gates. Each gate returns `Ok(())` on pass, `Err` on fail.
//! Generated binaries propagate the first error and abort the release.

use std::path::Path;

use anyhow::{anyhow, Context, Result};

use crate::bench::{history, BenchRun};

/// Gate 1: no `[patch.crates-io]` znippy entries in the repo's
/// `Cargo.toml`. Implementation: textual scan; refine later with a
/// proper TOML parse if needed.
pub fn no_path_patches(repo_root: &Path) -> Result<()> {
    let cargo = repo_root.join("Cargo.toml");
    let text = std::fs::read_to_string(&cargo)
        .with_context(|| format!("read {}", cargo.display()))?;
    let mut in_patch = false;
    for (i, line) in text.lines().enumerate() {
        let l = line.trim();
        if l.starts_with('[') {
            in_patch = l.starts_with("[patch.crates-io")
                || l.starts_with("[patch.\"crates-io\"");
            continue;
        }
        if in_patch && l.contains("znippy") && !l.starts_with('#') {
            return Err(anyhow!(
                "[patch.crates-io] znippy entry at {}:{} — strip before release",
                cargo.display(),
                i + 1
            ));
        }
    }
    Ok(())
}

/// Gate 3: holger ops/sec must be ≥ nexus ops/sec for every result.
/// Looks for `holger_ops_sec` and `nexus_ops_sec` in each result's
/// metrics map. Results lacking both keys are skipped.
pub fn nexus_floor(run: &BenchRun) -> Result<()> {
    for r in &run.results {
        let h = r.metrics.get("holger_ops_sec").and_then(|v| v.as_f64());
        let n = r.metrics.get("nexus_ops_sec").and_then(|v| v.as_f64());
        if let (Some(h), Some(n)) = (h, n) {
            if h < n {
                return Err(anyhow!(
                    "nexus floor: {} holger={:.0} < nexus={:.0}",
                    r.name,
                    h,
                    n
                ));
            }
        }
    }
    Ok(())
}

/// Gate 4: no result drops more than `max_drop_pct` versus the last
/// same-machine entry in the history. Compares the first numeric
/// metric present in each result (so works for both ops/sec and MB/s
/// shaped runs).
pub fn no_regression(run: &BenchRun, history_path: &Path, max_drop_pct: f64) -> Result<()> {
    let history = history::read_all(history_path)?;
    let Some(last) = history::last_for_machine(&history, &run.machine) else {
        return Ok(());
    };
    for r in &run.results {
        let Some(prev) = last.find(&r.name) else { continue };
        for (key, new_val) in &r.metrics {
            let Some(new_f) = new_val.as_f64() else { continue };
            let Some(prev_f) = prev.metrics.get(key).and_then(|v| v.as_f64()) else {
                continue;
            };
            if prev_f <= 0.0 {
                continue;
            }
            let drop_pct = (prev_f - new_f) / prev_f * 100.0;
            if drop_pct > max_drop_pct {
                return Err(anyhow!(
                    "regression: {} {} dropped {:.1}% ({:.2} → {:.2})",
                    r.name,
                    key,
                    drop_pct,
                    prev_f,
                    new_f
                ));
            }
        }
    }
    Ok(())
}

/// Gate 5: integration round-trip. Caller supplies a closure that
/// performs `agent push → server store → agent pull` for one artifact
/// kind; this gate runs them in order.
pub fn integration_roundtrip<F>(kinds: &[&str], mut run_one: F) -> Result<()>
where
    F: FnMut(&str) -> Result<()>,
{
    for k in kinds {
        run_one(k).with_context(|| format!("roundtrip failed for {k}"))?;
    }
    Ok(())
}

/// Gate 5 driver: invoke gate 5 by shelling out to
/// `cargo test --test roundtrip_<kind> --release` per kind. Consumer
/// repos (holger, znippy) implement the actual roundtrip logic as
/// Rust `#[test]` functions under `tests/roundtrip_<kind>.rs`. This
/// is the one allowed cargo subprocess pattern (matches the
/// `run_cargo_publish` decision), keeping nornir free of bash
/// shellouts.
pub fn integration_roundtrip_via_cargo_test(repo_root: &Path, kinds: &[&str]) -> Result<()> {
    integration_roundtrip(kinds, |k| {
        let test_name = format!("roundtrip_{k}");
        let status = std::process::Command::new("cargo")
            .args(["test", "--test", &test_name, "--release"])
            .current_dir(repo_root)
            .status()
            .with_context(|| format!("spawn cargo test --test {test_name}"))?;
        if !status.success() {
            return Err(anyhow!("cargo test --test {test_name} exited {status}"));
        }
        Ok(())
    })
}