josie / alder-tools

//! Workload abstraction for the sweep. A Workload is something the run
//! loop can start, let run for a duration, stop, and read a stability
//! verdict + a perf score from.
//!
//! Two shapes live behind this trait:
//!  - **Spawned** workloads (stress-ng, y-cruncher, 7zip) own a child
//!    process. `start()` spawns, `stop()` signals + waits, the verdict
//!    comes from the exit code, the score comes from parsed stdout.
//!  - **In-process** workloads (c2c-latency, dram-latency, deferred) run
//!    in a thread. `start()` spawns the thread, `stop()` sets a flag,
//!    the verdict + score come from the measurement itself.
//!
//! The trait abstracts over both so the run loop treats every workload
//! the same. v1 ships the spawned impls; in-process impls come next.

#![allow(dead_code)] // consumed by run.rs + ui.rs — not yet wired

use std::io::{self, Read};
use std::path::PathBuf;
use std::process::{Child, Command, ExitStatus};

/// What a workload reports when it's done. The stability verdict is one
/// half of the product — `Clean` means the workload's own self-check
/// passed (y-cruncher "Passed", 7z exit 0, stress-ng exit 0); anything
/// else is a failure mode the stability question cares about. The other
/// half is the [`Score`], returned by [`Workload::score`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// Workload self-check passed. The chip was stable for this run.
    Clean,
    /// Workload self-check failed (y-cruncher "failed", 7z nonzero exit,
    /// stress-ng nonzero exit). Instability at this ISA rail.
    Failed(String),
    /// Workload was stopped early (user quit, timeout, sweep cancelled).
    /// Not a stability signal — the run didn't complete.
    Stopped,
    /// Workload couldn't run (binary missing, spawn error, etc.). Not a
    /// stability signal — infrastructure failure.
    Error(String),
}

/// The perf score a workload emits. Stability is the verdict's job; this
/// is the comparative-perf number across configs. Per-kind so the TUI +
/// report can render each workload's natural metrics without forcing a
/// one-size-fits-all number. Stress tests (stress-ng-cpu) report `None`
/// — they exist for stability, not for a score.
#[derive(Clone, Debug, PartialEq)]
pub enum Score {
    /// No score (stress-ng-cpu and other pure stability workloads).
    None,
    /// 7-Zip benchmark. `rating_mips` is the headline comparative number
    /// (the `Tot: ... MIPS` column); the rest is the breakdown.
    SevenZip {
        rating_mips: u64,
        r_u_mips: u64,
        usage_pct: u64,
        compress_mips: u64,
        decompress_mips: u64,
    },
    /// y-cruncher stress. One entry per algorithm that ran. Each entry's
    /// `bits_per_sec` is the test speed (e.g. 8.94e8); `passed` mirrors
    /// the verdict but per-test so a partial run still records what
    /// completed. For the configured FFTv4-only sweep there's one entry.
    YCruncher { tests: Vec<YCruncherTest> },
}

/// One y-cruncher algorithm result.
#[derive(Clone, Debug, PartialEq)]
pub struct YCruncherTest {
    /// Algorithm tag, e.g. "FFTv4", "BKT", "N63".
    pub tag: String,
    /// Whether this algorithm's self-check passed.
    pub passed: bool,
    /// Test speed in bits/sec (normalized from the `<m> * 10^<e>` form
    /// y-cruncher prints).
    pub bits_per_sec: f64,
}

/// The common surface the run loop drives. See the module doc for the two
/// shapes behind this trait.
pub trait Workload {
    /// Human-readable name (e.g. "y-cruncher-avx2"). Used in the TUI and
    /// the report.
    fn name(&self) -> &str;
    /// One-line params string (e.g. "60s cores=0-15 FFTv4"). For the report.
    fn params(&self) -> String;
    /// Start the workload. Called once per run.
    fn start(&mut self) -> io::Result<()>;
    /// True between `start()` and `stop()`. The run loop polls this each
    /// tick to decide whether to keep waiting or move to cooldown.
    /// `&mut self` because `try_wait` mutates the Child's internal state.
    fn is_running(&mut self) -> bool;
    /// Stop the workload. May be called before the duration elapses (user
    /// quit, sweep cancelled). Must be safe to call on an already-stopped
    /// workload.
    fn stop(&mut self) -> io::Result<()>;
    /// Block until the workload finishes (or was already stopped). Returns
    /// the verdict. Called once, after the run loop decides the workload is
    /// done (either `is_running()` went false or the run loop's own
    /// duration timer expired and it called `stop()`).
    fn wait(&mut self) -> Verdict;
    /// Parse the perf score from captured stdout. Called after `wait()`.
    /// Implementations that don't produce a score return `Score::None`.
    fn score(&mut self) -> Score;
}

// ---------------------------------------------------------------------------
// Spawned-workload helper
// ---------------------------------------------------------------------------

/// Common machinery for workloads that spawn a child process. Owns the
/// Child, captures stdout (for verdict parsing), and provides a generic
/// `stop()` that tries SIGTERM then SIGKILL. Subtypes build the Command in
/// their `start()` and parse stdout in their `wait()`.
struct SpawnedWorkload {
    child: Option<Child>,
    stdout: Vec<u8>,
    /// Set when `stop()` was called by the run loop (vs. the process
    /// exiting on its own). Distinguishes `Stopped` from `Clean`/`Failed`.
    stopped: bool,
    /// Captured stderr (kept for diagnostics; not parsed for verdict).
    stderr: Vec<u8>,
}

impl SpawnedWorkload {
    fn new() -> Self {
        SpawnedWorkload {
            child: None,
            stdout: Vec::new(),
            stderr: Vec::new(),
            stopped: false,
        }
    }

    fn spawn(cmd: &mut Command) -> io::Result<Self> {
        let child = cmd
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()?;
        Ok(SpawnedWorkload {
            child: Some(child),
            stdout: Vec::new(),
            stderr: Vec::new(),
            stopped: false,
        })
    }

    fn is_running(&mut self) -> bool {
        match &mut self.child {
            Some(child) => child.try_wait().ok().flatten().is_none(),
            None => false,
        }
    }

    fn stop(&mut self) -> io::Result<()> {
        if let Some(child) = &mut self.child {
            self.stopped = true;
            // Try a polite SIGTERM first; the run loop will call wait()
            // next which reaps. If TERM doesn't take in a couple seconds,
            // escalate to SIGKILL — but we don't block here (the run loop
            // owns timing). For now just SIGTERM and let wait() handle the
            // rest. On Unix, `start_kill` sends SIGKILL; for SIGTERM we
            // use the raw pid + nix-less libc. Keep it simple: SIGKILL
            // directly. Stress workloads don't have cleanup that warrants
            // a graceful TERM, and a stuck child would hang the sweep.
            let _ = child.kill();
        }
        Ok(())
    }

    /// Drain stdout/stderr from the child into our buffers. Called by
    /// subtypes' `wait()` before parsing the verdict. Reads as much as is
    /// available without blocking.
    fn drain(&mut self) -> io::Result<()> {
        let Some(child) = &mut self.child else {
            return Ok(());
        };
        if let Some(stdout) = &mut child.stdout {
            let mut buf = [0u8; 4096];
            loop {
                let n = stdout.read(&mut buf)?;
                if n == 0 {
                    break;
                }
                self.stdout.extend_from_slice(&buf[..n]);
            }
        }
        if let Some(stderr) = &mut child.stderr {
            let mut buf = [0u8; 4096];
            loop {
                let n = stderr.read(&mut buf)?;
                if n == 0 {
                    break;
                }
                self.stderr.extend_from_slice(&buf[..n]);
            }
        }
        Ok(())
    }

    /// Block until the child exits, drain stdout/stderr, and return the
    /// raw ExitStatus. Called by subtypes' `wait()`.
    fn wait_raw(&mut self) -> io::Result<Option<ExitStatus>> {
        let Some(child) = &mut self.child else {
            return Ok(None);
        };
        let status = child.wait()?;
        // After wait() returns, the pipes are at EOF — drain anything we
        // missed while we weren't polling.
        self.drain()?;
        Ok(Some(status))
    }

    fn _take_stdout(&mut self) -> String {
        String::from_utf8_lossy(&self.stdout).to_string()
    }
}

// ---------------------------------------------------------------------------
// stress-ng --cpu (integer rail)
// ---------------------------------------------------------------------------

/// Integer rail. `stress-ng --cpu N --taskset <range> --timeout T --cpu-method all`.
/// Stress-ng exits 0 on success, nonzero on failure (including bogo-op
/// self-check mismatches).
pub struct StressNg {
    duration_secs: u64,
    cores: String,
    inner: SpawnedWorkload,
}

impl StressNg {
    pub fn new(duration_secs: u64, cores: String) -> Self {
        StressNg {
            duration_secs,
            cores,
            inner: SpawnedWorkload::new(),
        }
    }
}

impl Workload for StressNg {
    fn name(&self) -> &str {
        "stress-ng-cpu"
    }
    fn params(&self) -> String {
        format!("{}s cores={} --cpu --cpu-method all", self.duration_secs, self.cores)
    }
    fn start(&mut self) -> io::Result<()> {
        let mut cmd = Command::new("stress-ng");
        cmd.arg("--cpu")
            .arg("15") // all logical cpus; --taskset below pins to the range
            .arg("--taskset")
            .arg(&self.cores)
            .arg("--timeout")
            .arg(format!("{}s", self.duration_secs))
            .arg("--cpu-method")
            .arg("all")
            .arg("--metrics-brief");
        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
        Ok(())
    }
    fn is_running(&mut self) -> bool {
        self.inner.is_running()
    }
    fn stop(&mut self) -> io::Result<()> {
        self.inner.stop()
    }
    fn wait(&mut self) -> Verdict {
        let stopped = self.inner.stopped;
        match self.inner.wait_raw() {
            Ok(Some(status)) => {
                if stopped {
                    return Verdict::Stopped;
                }
                if status.success() {
                    Verdict::Clean
                } else {
                    Verdict::Failed(format!("stress-ng exit {}", status))
                }
            }
            Ok(None) => Verdict::Error("no child".to_string()),
            Err(e) => Verdict::Error(format!("wait: {e}")),
        }
    }
    fn score(&mut self) -> Score {
        Score::None
    }
}

// ---------------------------------------------------------------------------
// y-cruncher (SSE or AVX2 rail, via per-microarch variant binary)
// ---------------------------------------------------------------------------

/// SSE or AVX2 rail. y-cruncher has NO CLI flag for ISA selection — we
/// force the ISA by invoking the per-microarch VARIANT BINARY directly
/// (NOT the wrapper, which auto-selects). Verified 2026-09-01:
///   SSE  = Binaries/11-SNB ~ Hina  (Sandy Bridge, SSE4.2 only)
///   AVX2 = Binaries/13-HSW ~ Airi  (Haswell, AVX2)
/// Both run standalone, report "Passed"/"failed" per FFT round, "Stop on
/// Error: Enabled" by default. CLI: `<variant> skip-warnings stress -D:s
/// -TL:s [algorithm]`.
pub struct YCruncher {
    duration_secs: u64,
    cores: String,
    variant: &'static str, // "11-SNB ~ Hina" or "13-HSW ~ Airi"
    label: &'static str,   // "sse" or "avx2"
    bin_dir: PathBuf,
    inner: SpawnedWorkload,
}

impl YCruncher {
    pub fn sse(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
        YCruncher {
            duration_secs,
            cores,
            variant: "11-SNB ~ Hina",
            label: "sse",
            bin_dir,
            inner: SpawnedWorkload::new(),
        }
    }
    pub fn avx2(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
        YCruncher {
            duration_secs,
            cores,
            variant: "13-HSW ~ Airi",
            label: "avx2",
            bin_dir,
            inner: SpawnedWorkload::new(),
        }
    }

    /// Resolve the variant binary path. y-cruncher ships as a versioned
    /// subdir under bin_dir (e.g. `bin/y-cruncher v0.8.7.9547-static/`),
    /// so we glob for the first match rather than hardcode the version.
    fn variant_path(&self) -> Option<PathBuf> {
        let entries = std::fs::read_dir(&self.bin_dir).ok()?;
        for entry in entries.flatten() {
            let name = entry.file_name();
            let name = name.to_str()?;
            if name.starts_with("y-cruncher") {
                let variant = entry.path().join("Binaries").join(self.variant);
                if variant.exists() {
                    return Some(variant);
                }
            }
        }
        None
    }
}

impl Workload for YCruncher {
    fn name(&self) -> &str {
        match self.label {
            "sse" => "y-cruncher-sse",
            "avx2" => "y-cruncher-avx2",
            _ => "y-cruncher",
        }
    }
    fn params(&self) -> String {
        format!(
            "{}s cores={} variant={} stress FFTv4",
            self.duration_secs, self.cores, self.variant
        )
    }
    fn start(&mut self) -> io::Result<()> {
        let variant_path = self.variant_path().ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::NotFound,
                format!(
                    "y-cruncher variant '{}' not found under bin_dir {:?} \
                     (expected bin/y-cruncher v*/Binaries/{})",
                    self.variant, self.bin_dir, self.variant,
                ),
            )
        })?;
        // Canonicalize before setting current_dir — when current_dir is
        // set, the kernel resolves the executable path RELATIVE TO THE NEW
        // cwd, so a relative variant path would be looked up inside the
        // y-cruncher bundle dir and not found. Absolute path survives the
        // cwd change.
        let variant_path = variant_path.canonicalize().map_err(|e| {
            io::Error::new(
                io::ErrorKind::NotFound,
                format!("y-cruncher variant path canonicalize failed: {e}"),
            )
        })?;
        let mut cmd = Command::new(&variant_path);
        cmd.arg("skip-warnings")
            .arg("stress")
            // y-cruncher expects `-D:3` as ONE arg (not `-D:` + `3`).
            .arg(format!("-D:{}", self.duration_secs))
            .arg(format!("-TL:{}", self.duration_secs))
            .arg("FFTv4");
        // y-cruncher reads its Libraries.txt etc. from CWD — run from the
        // variant's parent directory (the static bundle dir).
        if let Some(dir) = variant_path.parent().and_then(|p| p.parent()) {
            cmd.current_dir(dir);
        }
        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
        Ok(())
    }
    fn is_running(&mut self) -> bool {
        self.inner.is_running()
    }
    fn stop(&mut self) -> io::Result<()> {
        self.inner.stop()
    }
    fn wait(&mut self) -> Verdict {
        let stopped = self.inner.stopped;
        match self.inner.wait_raw() {
            Ok(Some(status)) => {
                if stopped {
                    return Verdict::Stopped;
                }
                let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
                // y-cruncher prints "Running FFTv4: Passed" per round and
                // "failed" on mismatch. The definitive signal is the exit
                // code (nonzero on self-check failure since "Stop on Error"
                // is enabled by default), but we also scan stdout for the
                // explicit "failed" string in case of partial output.
                let failed_text = stdout
                    .lines()
                    .any(|l| l.contains("failed") && !l.contains("Running"));
                if status.success() && !failed_text {
                    Verdict::Clean
                } else if failed_text {
                    Verdict::Failed(format!("y-cruncher self-check failed (variant={})", self.variant))
                } else {
                    Verdict::Failed(format!("y-cruncher exit {} (variant={})", status, self.variant))
                }
            }
            Ok(None) => Verdict::Error("no child".to_string()),
            Err(e) => Verdict::Error(format!("wait: {e}")),
        }
    }
    fn score(&mut self) -> Score {
        let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
        Score::YCruncher {
            tests: parse_ycruncher_scores(&stdout),
        }
    }
}

// ---------------------------------------------------------------------------
// 7-Zip benchmark (the z-7ip bench — NOT y-cruncher)
// ---------------------------------------------------------------------------

/// z-7ip bench. `7z b` runs 7-Zip's built-in compression/decompression
/// benchmark; CPU + memory bandwidth throughput. Reports MIPS + total
/// score (the comparative-perf metric across configs). Exits 0 on success.
/// `7z b -mmt<N>` sets thread count; we pass cores count translated from
/// the range string (0-15 → 16 threads). Defaults to all logical cpus if
/// the range is "0-15".
pub struct SevenZip {
    duration_secs: u64,
    cores: String,
    inner: SpawnedWorkload,
}

impl SevenZip {
    pub fn new(duration_secs: u64, cores: String) -> Self {
        SevenZip {
            duration_secs,
            cores,
            inner: SpawnedWorkload::new(),
        }
    }

    /// Translate a cores range ("0-15", "0-11", "12-15") into a thread
    /// count for `7z b -mmt<N>`. Returns None if the range is malformed
    /// — 7z's default (all cpus) is fine in that case.
    fn thread_count(&self) -> Option<u32> {
        let s = self.cores.trim();
        if let Some((a, b)) = s.split_once('-') {
            let a: u32 = a.trim().parse().ok()?;
            let b: u32 = b.trim().parse().ok()?;
            if b >= a {
                return Some(b - a + 1);
            }
        }
        None
    }
}

impl Workload for SevenZip {
    fn name(&self) -> &str {
        "7zip-bench"
    }
    fn params(&self) -> String {
        format!(
            "{}s cores={} (7z b{})",
            self.duration_secs,
            self.cores,
            match self.thread_count() {
                Some(n) => format!(" -mmt{n}"),
                None => String::new(),
            }
        )
    }
    fn start(&mut self) -> io::Result<()> {
        let mut cmd = Command::new("7z");
        cmd.arg("b");
        if let Some(n) = self.thread_count() {
            cmd.arg(format!("-mmt{n}"));
        }
        // 7z b runs a fixed number of passes by default, not time-bound.
        // For a sweep we want a comparable run length across configs —
        // pass `-mmt<N>` for thread control and let the duration_secs be
        // a soft target (7z finishes when it finishes; the run loop will
        // stop() if it overruns). Document this in the report.
        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
        Ok(())
    }
    fn is_running(&mut self) -> bool {
        self.inner.is_running()
    }
    fn stop(&mut self) -> io::Result<()> {
        self.inner.stop()
    }
    fn wait(&mut self) -> Verdict {
        let stopped = self.inner.stopped;
        match self.inner.wait_raw() {
            Ok(Some(status)) => {
                if stopped {
                    return Verdict::Stopped;
                }
                if status.success() {
                    Verdict::Clean
                } else {
                    Verdict::Failed(format!("7z exit {}", status))
                }
            }
            Ok(None) => Verdict::Error("no child".to_string()),
            Err(e) => Verdict::Error(format!("wait: {e}")),
        }
    }
    fn score(&mut self) -> Score {
        let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
        parse_7zip_score(&stdout).unwrap_or(Score::None)
    }
}

// ---------------------------------------------------------------------------
// Public constructor: map a WorkloadSpec name to an impl
// ---------------------------------------------------------------------------

/// Build a Workload from a config spec. Returns None for unknown names
/// (the run loop skips them with a warning) or names that aren't wired
/// yet (c2c-latency, dram-latency, minecraft-server — deferred).
pub fn from_spec(
    spec: &crate::config::WorkloadSpec,
    bin_dir: &std::path::Path,
) -> Option<Box<dyn Workload>> {
    match spec.name.as_str() {
        "stress-ng-cpu" => Some(Box::new(StressNg::new(spec.duration_secs, spec.cores.clone()))),
        "y-cruncher-sse" => {
            Some(Box::new(YCruncher::sse(spec.duration_secs, spec.cores.clone(), bin_dir.to_path_buf())))
        }
        "y-cruncher-avx2" => Some(Box::new(YCruncher::avx2(
            spec.duration_secs,
            spec.cores.clone(),
            bin_dir.to_path_buf(),
        ))),
        "7zip-bench" => Some(Box::new(SevenZip::new(spec.duration_secs, spec.cores.clone()))),
        // Deferred — hand-rolled in-process workloads, next chunk.
        "c2c-latency" | "dram-latency" | "minecraft-server" => None,
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Score parsers
// ---------------------------------------------------------------------------

/// Strip ANSI CSI escape sequences (`\x1b[ ... m` and similar) + convert
/// the `\r` that y-cruncher emits for live-console line overwrites into
/// `\n` so the score parser can split lines cleanly. Not a full ANSI
/// stripper — enough for y-cruncher's color codes, which are all SGR
/// (`\x1b[...m`).
fn strip_ansi(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // Skip `\x1b[ ... <final byte>`. Final byte is in the range
            // 0x40-0x7E per the CSI spec; SGR ends in `m`.
            if chars.peek() == Some(&'[') {
                chars.next();
                for c in chars.by_ref() {
                    if (c as u32) >= 0x40 && (c as u32) <= 0x7E {
                        break;
                    }
                }
                continue;
            }
            // Lone ESC — drop it.
            continue;
        }
        if c == '\r' {
            // y-cruncher uses bare `\r` to overwrite the live console
            // line for progress updates. Convert to `\n` so each
            // overwriting pass becomes its own line for the parser.
            out.push('\n');
            continue;
        }
        out.push(c);
    }
    out
}

/// Parse y-cruncher's `Running <TAG>: Passed  Test Speed:  <m> * 10^<exp>
/// bits / sec` lines. Each test prints twice (once padded for the live
/// console, once clean) — dedup by tag, keep the first. Tests that
/// didn't run (Disabled in the menu) don't appear, so the returned vec
/// only has tests that actually executed.
fn parse_ycruncher_scores(stdout: &str) -> Vec<YCruncherTest> {
    let plain = strip_ansi(stdout);
    let mut tests: Vec<YCruncherTest> = Vec::new();
    for line in plain.lines() {
        // Match the clean form: "Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec"
        // Skip the "live console" form (has trailing whitespace padding
        // before "Running" repeats) by requiring the line to END with
        // "bits / sec" (no trailing spaces).
        let trimmed = line.trim_end();
        if !trimmed.ends_with("bits / sec") {
            continue;
        }
        let Some(rest) = trimmed.strip_prefix("Running ") else {
            continue;
        };
        let Some((tag_part, speed_part)) = rest.split_once(": ") else {
            continue;
        };
        if !speed_part.starts_with("Passed") && !speed_part.starts_with("failed") {
            continue;
        }
        let passed = speed_part.starts_with("Passed");
        // Speed_part looks like: "Passed  Test Speed:  8.94 * 10^08  bits / sec"
        //                       "failed  Test Speed:  <m> * 10^<exp>  bits / sec"
        let Some(speed_idx) = speed_part.find("Test Speed:") else {
            continue;
        };
        let after = speed_part[speed_idx + "Test Speed:".len()..].trim();
        // after = "<m> * 10^<exp>  bits / sec"
        let Some((mantissa_exp, _)) = after.split_once("bits / sec") else {
            continue;
        };
        let mantissa_exp = mantissa_exp.trim();
        // mantissa_exp = "8.94 * 10^08"
        let Some((m_str, e_str)) = mantissa_exp.split_once(" * 10^") else {
            continue;
        };
        let Ok(m) = m_str.trim().parse::<f64>() else {
            continue;
        };
        let Ok(e) = e_str.trim().parse::<i32>() else {
            continue;
        };
        let bits_per_sec = m * 10f64.powi(e);
        let tag = tag_part.trim().to_string();
        // Dedup by tag — y-cruncher prints each test's result twice.
        if tests.iter().any(|t| t.tag == tag) {
            continue;
        }
        tests.push(YCruncherTest { tag, passed, bits_per_sec });
    }
    tests
}

/// 7z `Avr:` line tuple: (compress speed_kibs, usage, r_u_mips, rating,
/// decompress speed_kibs, usage, r_u_mips, rating).
type AvrTuple = (u64, u64, u64, u64, u64, u64, u64, u64);

/// Parse 7-Zip's `7z b` stdout. The footer has:
///   `Avr:     70556  1379   5346  73766  |     674420  1483   3961  58751`
///   `Tot:            1431   4653  66259`
/// `Tot:` = usage_pct, r_u_mips, rating_mips.
/// `Avr:` = compress (speed_kibs, usage, r_u, rating) | decompress (same).
/// We want compress r_u (5346) + decompress r_u (3961) from Avr, and all
/// three from Tot. Returns None if either line is missing/malformed.
fn parse_7zip_score(stdout: &str) -> Option<Score> {
    let mut avr: Option<AvrTuple> = None;
    let mut tot: Option<(u64, u64, u64)> = None;
    for line in stdout.lines() {
        let line = line.trim();
        if line.starts_with("Avr:") {
            // Avr: <c_speed> <c_usage> <c_ru> <c_rating> | <d_speed> <d_usage> <d_ru> <d_rating>
            let rest = line.strip_prefix("Avr:").unwrap_or("").trim();
            let Some((left, right)) = rest.split_once('|') else {
                continue;
            };
            let lc: Vec<u64> = left.split_whitespace().filter_map(|s| s.parse().ok()).collect();
            let rc: Vec<u64> = right.split_whitespace().filter_map(|s| s.parse().ok()).collect();
            if lc.len() >= 4 && rc.len() >= 4 {
                avr = Some((
                    lc[0], lc[1], lc[2], lc[3],
                    rc[0], rc[1], rc[2], rc[3],
                ));
            }
        } else if line.starts_with("Tot:") {
            // Tot: <usage> <r_u> <rating>
            let rest = line.strip_prefix("Tot:").unwrap_or("").trim();
            let nums: Vec<u64> = rest.split_whitespace().filter_map(|s| s.parse().ok()).collect();
            if nums.len() >= 3 {
                tot = Some((nums[0], nums[1], nums[2]));
            }
        }
    }
    let (_c_speed, _c_usage, c_ru, _c_rating, _d_speed, _d_usage, d_ru, _d_rating) = avr?;
    let (usage_pct, r_u_mips, rating_mips) = tot?;
    Some(Score::SevenZip {
        rating_mips,
        r_u_mips,
        usage_pct,
        compress_mips: c_ru,
        decompress_mips: d_ru,
    })
}

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

    #[test]
    fn stressng_params_include_range() {
        let w = StressNg::new(60, "0-11".to_string());
        assert!(w.params().contains("0-11"));
        assert!(w.params().contains("60s"));
    }

    #[test]
    fn ycruncher_variant_paths_resolve() {
        let bin_dir = PathBuf::from("bin");
        let sse = YCruncher::sse(60, "0-15".to_string(), bin_dir.clone());
        let avx2 = YCruncher::avx2(60, "0-15".to_string(), bin_dir);
        assert_eq!(sse.variant, "11-SNB ~ Hina");
        assert_eq!(avx2.variant, "13-HSW ~ Airi");
        // variant_path() should find the actual bundle if present in ./bin
        // (smoke-tested below; skip assertion here so tests pass without
        // the binary).
        let _ = sse.variant_path();
        let _ = avx2.variant_path();
    }

    #[test]
    fn ycruncher_names_distinguish_rails() {
        let sse = YCruncher::sse(60, "0-15".to_string(), PathBuf::from("bin"));
        let avx2 = YCruncher::avx2(60, "0-15".to_string(), PathBuf::from("bin"));
        assert_eq!(sse.name(), "y-cruncher-sse");
        assert_eq!(avx2.name(), "y-cruncher-avx2");
    }

    #[test]
    fn sevenzip_thread_count_parses_ranges() {
        let w = SevenZip::new(60, "0-15".to_string());
        assert_eq!(w.thread_count(), Some(16));
        let w = SevenZip::new(60, "0-11".to_string());
        assert_eq!(w.thread_count(), Some(12));
        let w = SevenZip::new(60, "12-15".to_string());
        assert_eq!(w.thread_count(), Some(4));
        let w = SevenZip::new(60, "junk".to_string());
        assert_eq!(w.thread_count(), None);
    }

    #[test]
    fn from_spec_wires_known_names() {
        let spec = crate::config::WorkloadSpec {
            name: "stress-ng-cpu".to_string(),
            duration_secs: 30,
            cores: "0-15".to_string(),
        };
        let w = from_spec(&spec, std::path::Path::new("bin"));
        assert!(w.is_some());
        assert_eq!(w.unwrap().name(), "stress-ng-cpu");
    }

    #[test]
    fn from_spec_rejects_unknown_names() {
        let spec = crate::config::WorkloadSpec {
            name: "no-such-workload".to_string(),
            duration_secs: 30,
            cores: "0-15".to_string(),
        };
        assert!(from_spec(&spec, std::path::Path::new("bin")).is_none());
    }

    #[test]
    fn from_spec_rejects_deferred_workloads() {
        // c2c-latency / dram-latency / minecraft-server are in scope but
        // not yet implemented — from_spec returns None so the run loop
        // can skip them with a warning rather than panic.
        for name in ["c2c-latency", "dram-latency", "minecraft-server"] {
            let spec = crate::config::WorkloadSpec {
                name: name.to_string(),
                duration_secs: 30,
                cores: "0-15".to_string(),
            };
            assert!(from_spec(&spec, std::path::Path::new("bin")).is_none(), "{name} should be None");
        }
    }

    // --- score parser tests ---

    /// Real y-cruncher FFTv4 stdout (single-algorithm run). Has ANSI
    /// color codes + \r line noise + duplicate lines (live + log form).
    #[test]
    fn parse_ycruncher_fftv4_single_algorithm() {
        let stdout = include_str!("../test-fixtures/yc-avx2-FFTv4.txt");
        let tests = parse_ycruncher_scores(stdout);
        assert_eq!(tests.len(), 1, "dedup by tag should leave one FFTv4 entry");
        assert_eq!(tests[0].tag, "FFTv4");
        assert!(tests[0].passed);
        // 8.94 * 10^08 = 894_000_000
        assert!((tests[0].bits_per_sec - 8.94e8).abs() < 1.0);
    }

    #[test]
    fn parse_ycruncher_handles_plain_ascii() {
        // No ANSI codes — just the clean log line.
        let stdout = "Running FFTv4: Passed  Test Speed:  8.55 * 10^08  bits / sec\n";
        let tests = parse_ycruncher_scores(stdout);
        assert_eq!(tests.len(), 1);
        assert_eq!(tests[0].tag, "FFTv4");
        assert!(tests[0].passed);
        assert!((tests[0].bits_per_sec - 8.55e8).abs() < 1.0);
    }

    #[test]
    fn parse_ycruncher_dedups_repeated_tags() {
        // y-cruncher prints each test's result twice (live console + log).
        let stdout = "Running FFTv4: Passed  Test Speed:  1.01 * 10^09  bits / sec\n\
                       Running FFTv4: Passed  Test Speed:  1.01 * 10^09  bits / sec\n";
        let tests = parse_ycruncher_scores(stdout);
        assert_eq!(tests.len(), 1);
    }

    #[test]
    fn parse_ycruncher_skips_non_score_lines() {
        let stdout = "\
Running from console...
Iteration: 0  Total Elapsed Time: 0.554 seconds ( 0.009 minutes )
Running FFTv4:
Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec
Test Finished. Waiting for threads to terminate...
";
        let tests = parse_ycruncher_scores(stdout);
        assert_eq!(tests.len(), 1);
        assert_eq!(tests[0].tag, "FFTv4");
    }

    #[test]
    fn parse_7zip_real_stdout() {
        let stdout = include_str!("../test-fixtures/7z-default.txt");
        let score = parse_7zip_score(stdout).expect("7z footer has Avr + Tot");
        match score {
            Score::SevenZip {
                rating_mips,
                r_u_mips,
                usage_pct,
                compress_mips,
                decompress_mips,
            } => {
                assert_eq!(rating_mips, 66259, "Tot Rating MIPS");
                assert_eq!(r_u_mips, 4653, "Tot R/U MIPS");
                assert_eq!(usage_pct, 1431, "Tot Usage %");
                assert_eq!(compress_mips, 5346, "Avr compress R/U MIPS");
                assert_eq!(decompress_mips, 3961, "Avr decompress R/U MIPS");
            }
            other => panic!("expected SevenZip, got {other:?}"),
        }
    }

    #[test]
    fn parse_7zip_returns_none_when_footer_missing() {
        assert!(parse_7zip_score("no footer here\njust junk").is_none());
    }

    #[test]
    fn strip_ansi_removes_sgr_codes() {
        let s = "\x1b[01;32mPassed\x1b[01;37m  Test Speed";
        assert_eq!(strip_ansi(s), "Passed  Test Speed");
    }

    #[test]
    fn strip_ansi_passes_plain_text() {
        assert_eq!(strip_ansi("plain text"), "plain text");
        assert_eq!(strip_ansi("with\rcarriage"), "with\ncarriage");
    }
}