nornir 0.2.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Vs-legacy bench helpers — time a legacy decompressor subprocess against an
//! in-process Rust decoder and emit **paired** metrics, so the `benches` doc
//! renderer can bold the faster of the two ("how much faster than the old
//! tool"). The legacy baselines (`unzip`, `bzip2`, `lbzip2`, `gzip`, `pigz`)
//! are dev/bench-only subprocess sites — never library code — per the
//! 100%-Rust law (plan.md §4).
//!
//! A missing legacy tool is **not** an error: the comparison degrades to
//! ours-only (the legacy column is simply absent), so a bench box without
//! `pigz` still produces a valid run.

use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Megabytes-per-second for `bytes` processed in `dur` (1 MB = 1_000_000 B, the
/// convention the READMEs already use). Returns 0.0 for a zero duration.
pub fn mbs(bytes: u64, dur: Duration) -> f64 {
    let secs = dur.as_secs_f64();
    if secs <= 0.0 {
        return 0.0;
    }
    (bytes as f64 / 1_000_000.0) / secs
}

/// Time an in-process decode `f` over `reps` repetitions, returning the **best**
/// (minimum) wall time — best-of-N rejects scheduler noise the way criterion's
/// min estimate does. `reps` is clamped to ≥1.
pub fn time_rust<F: FnMut()>(reps: usize, mut f: F) -> Duration {
    let reps = reps.max(1);
    let mut best = Duration::MAX;
    for _ in 0..reps {
        let t = Instant::now();
        f();
        best = best.min(t.elapsed());
    }
    best
}

/// Is `tool` on `PATH`? (Cheap `which`-style probe via spawning is avoided;
/// we just check `PATH` entries so a `None` result is fast and side-effect
/// free.)
pub fn tool_available(tool: &str) -> bool {
    let Ok(path) = std::env::var("PATH") else { return false };
    std::env::split_paths(&path).any(|dir| {
        let p = dir.join(tool);
        p.is_file() && is_executable(&p)
    })
}

#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(p)
        .map(|m| m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
    true
}

/// Time a legacy decompressor over `reps` reps, returning the best wall time,
/// or `None` if the tool is missing or any run fails. The command is built by
/// `build` each rep (so per-run temp paths work) and is run with stdout+stderr
/// discarded.
///
/// Example (decompress a file to a null sink):
/// ```ignore
/// let dur = time_command_best("bzip2", 3, || {
///     let mut c = Command::new("bzip2");
///     c.arg("-dck").arg(&path);   // -c stdout, -k keep input
///     c
/// });
/// ```
pub fn time_command_best<F: FnMut() -> Command>(
    tool: &str,
    reps: usize,
    mut build: F,
) -> Option<Duration> {
    if !tool_available(tool) {
        return None;
    }
    let reps = reps.max(1);
    let mut best = Duration::MAX;
    for _ in 0..reps {
        let mut cmd = build();
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let t = Instant::now();
        let status = cmd.status().ok()?;
        let elapsed = t.elapsed();
        if !status.success() {
            return None;
        }
        best = best.min(elapsed);
    }
    Some(best)
}

/// A finished ours-vs-legacy comparison for one workload.
#[derive(Debug, Clone)]
pub struct Comparison {
    /// Bytes used as the throughput denominator (same for both sides, so the
    /// speedup is exact). Typically the input (compressed) file size.
    pub bytes: u64,
    pub ours_label: String,
    pub ours: Duration,
    pub legacy_label: String,
    /// `None` when the legacy tool was absent / failed.
    pub legacy: Option<Duration>,
}

impl Comparison {
    /// Build the paired `BenchResult` metrics map: `<ours>_mbs`, and — when the
    /// legacy side ran — `<legacy>_mbs` plus a derived `speedup_x`. Metric keys
    /// use the `_mbs` suffix so the renderer infers "higher is better" and the
    /// bold-best comparison lights up.
    pub fn into_result(self, name: &str) -> crate::bench::BenchResult {
        let mut m = serde_json::Map::new();
        let ours_mbs = mbs(self.bytes, self.ours);
        m.insert(format!("{}_mbs", self.ours_label), serde_json::json!(round2(ours_mbs)));
        if let Some(leg) = self.legacy {
            let leg_mbs = mbs(self.bytes, leg);
            m.insert(format!("{}_mbs", self.legacy_label), serde_json::json!(round2(leg_mbs)));
            let speedup = if leg_mbs > 0.0 { ours_mbs / leg_mbs } else { 0.0 };
            m.insert("speedup_x".into(), serde_json::json!(round2(speedup)));
        }
        crate::bench::BenchResult { name: name.to_string(), metrics: m }
    }
}

fn round2(f: f64) -> f64 {
    (f * 100.0).round() / 100.0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mbs_basic() {
        assert!((mbs(1_000_000, Duration::from_secs(1)) - 1.0).abs() < 1e-9);
        assert_eq!(mbs(123, Duration::ZERO), 0.0);
    }

    #[test]
    fn missing_tool_is_none() {
        assert!(!tool_available("definitely-not-a-real-tool-xyz"));
        let d = time_command_best("definitely-not-a-real-tool-xyz", 1, || {
            Command::new("definitely-not-a-real-tool-xyz")
        });
        assert!(d.is_none());
    }

    #[test]
    fn comparison_pairs_metrics_and_speedup() {
        let cmp = Comparison {
            bytes: 2_000_000,
            ours_label: "ljar".into(),
            ours: Duration::from_millis(100),   // 20 MB/s
            legacy_label: "unzip".into(),
            legacy: Some(Duration::from_millis(200)), // 10 MB/s
        };
        let r = cmp.into_result("jar_2000");
        assert_eq!(r.name, "jar_2000");
        assert_eq!(r.metrics["ljar_mbs"].as_f64().unwrap(), 20.0);
        assert_eq!(r.metrics["unzip_mbs"].as_f64().unwrap(), 10.0);
        assert_eq!(r.metrics["speedup_x"].as_f64().unwrap(), 2.0);
    }

    #[test]
    fn comparison_without_legacy_is_ours_only() {
        let cmp = Comparison {
            bytes: 1_000_000,
            ours_label: "lgz".into(),
            ours: Duration::from_millis(500),
            legacy_label: "pigz".into(),
            legacy: None,
        };
        let r = cmp.into_result("gz_x");
        assert!(r.metrics.contains_key("lgz_mbs"));
        assert!(!r.metrics.contains_key("pigz_mbs"));
        assert!(!r.metrics.contains_key("speedup_x"));
    }
}