josie / alder-tools

//! JSON report writer. One file per workload run, under `report_dir`
//! (`./runs/` by default). Schema v2: run metadata, workload verdict,
//! and the perf score. No sensor fields — aldermon owns sensors; this
//! report only records what the workload itself produced.
//!
//! Incremental flush: the whole file is rewritten on each call. A v2
//! report is small (no per-tick samples), so we only flush once at
//! `finish()` — partial runs leave a JSON file with verdict=null +
//! score=null, which is parseable.
//!
//! Zero-dep: hand-rolled JSON. The schema is flat enough that serde_json
//! would be one dep for little gain; revisit if the schema nests deeper.

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

use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::workload::{Score, Verdict};

/// The data captured for one workload run. The run loop owns this,
/// feeds it the verdict + score when the workload completes, and calls
/// `finish()` to flush.
pub struct Report {
    pub schema_version: u32,
    pub workload: String,
    pub params: String,
    /// Unix timestamp (seconds) when the run started.
    pub started: u64,
    /// ISA class — "integer", "sse", "avx2", "mixed", or "other".
    /// Inferred from the workload name (see `isa_class`).
    pub isa_class: &'static str,
    /// Set by `finish()`. None until the workload completes.
    pub verdict: Option<Verdict>,
    /// Set by `finish()`. None for stability-only workloads (stress-ng),
    /// or until the workload completes.
    pub score: Option<Score>,
    /// Path the report will be written to (set at construction).
    path: PathBuf,
}

impl Report {
    /// Construct a new report for a workload. Creates `report_dir` if
    /// missing. The filename is `<unix-seconds>-<workload>.json`.
    pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result<Self> {
        fs::create_dir_all(report_dir)?;
        let started = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        // Sanitize the workload name for the filename (replace chars that
        // are hostile to filesystems).
        let safe = workload.replace(['/', ' ', ':'], "_");
        let path = report_dir.join(format!("{started}-{safe}.json"));
        Ok(Report {
            schema_version: 2,
            workload: workload.to_string(),
            params: params.to_string(),
            started,
            isa_class: isa_class(workload),
            verdict: None,
            score: None,
            path,
        })
    }

    /// Mark the run complete + flush the final state with the verdict +
    /// score. Called once, after the workload has exited and its score
    /// has been parsed. The score is stored verbatim — `Score::None` is
    /// kept (so `human_summary` can distinguish "stability-only workload"
    /// from "not finished"), and `json_score` renders it as `null`.
    pub fn finish(&mut self, verdict: Verdict, score: Score) -> io::Result<()> {
        self.verdict = Some(verdict);
        self.score = Some(score);
        self.flush()
    }

    /// Where the report is being written. The run loop may want this for
    /// the TUI / stdout summary.
    pub fn path(&self) -> &Path {
        &self.path
    }

    fn flush(&self) -> io::Result<()> {
        let mut f = File::create(&self.path)?;
        f.write_all(self.to_json().as_bytes())?;
        f.sync_all().ok(); // best-effort; don't fail the run on sync error
        Ok(())
    }

    /// Serialize to a JSON string. Used by `flush()` and by the
    /// `--report <path>` printer in main.rs.
    pub fn to_json(&self) -> String {
        let mut out = String::with_capacity(1024);
        out.push('{');
        out.push_str(&format!("\"schema_version\":{},", self.schema_version));
        out.push_str(&format!("\"workload\":{},", json_str(&self.workload)));
        out.push_str(&format!("\"params\":{},", json_str(&self.params)));
        out.push_str(&format!("\"started\":{},", self.started));
        out.push_str(&format!("\"isa_class\":{},", json_str(self.isa_class)));
        out.push_str(&format!("\"verdict\":{},", json_verdict(&self.verdict)));
        out.push_str(&format!("\"score\":{}", json_score(&self.score)));
        out.push('}');
        out
    }
}

/// JSON-encode a string. Escapes the minimal set (quote, backslash,
/// control chars). Assumes UTF-8 input (Rust strings are).
fn json_str(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// JSON-encode the verdict. None (run not finished) → `null`; otherwise
/// an object with `kind` + optional `detail`.
fn json_verdict(v: &Option<Verdict>) -> String {
    match v {
        None => "null".to_string(),
        Some(Verdict::Clean) => "{\"kind\":\"clean\"}".to_string(),
        Some(Verdict::Failed(msg)) => {
            format!("{{\"kind\":\"failed\",\"detail\":{}}}", json_str(msg))
        }
        Some(Verdict::Stopped) => "{\"kind\":\"stopped\"}".to_string(),
        Some(Verdict::Error(msg)) => format!("{{\"kind\":\"error\",\"detail\":{}}}", json_str(msg)),
    }
}

/// JSON-encode a Score. None (no score / not finished) → `null`.
/// Otherwise an object with `kind` + the per-kind fields.
fn json_score(s: &Option<Score>) -> String {
    match s {
        None => "null".to_string(),
        Some(Score::None) => "null".to_string(),
        Some(Score::SevenZip {
            rating_mips,
            r_u_mips,
            usage_pct,
            compress_mips,
            decompress_mips,
        }) => format!(
            "{{\"kind\":\"7zip\",\"rating_mips\":{rating_mips},\"r_u_mips\":{r_u_mips},\
             \"usage_pct\":{usage_pct},\"compress_mips\":{compress_mips},\
             \"decompress_mips\":{decompress_mips}}}"
        ),
        Some(Score::YCruncher { tests }) => {
            let mut out = String::from("{\"kind\":\"y-cruncher\",\"tests\":[");
            for (i, t) in tests.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&format!(
                    "{{\"tag\":{},\"passed\":{},\"bits_per_sec\":{}}}",
                    json_str(&t.tag),
                    t.passed,
                    t.bits_per_sec
                ));
            }
            out.push_str("]}");
            out
        }
        Some(Score::C2c { pairs }) => {
            let mut out = String::from("{\"kind\":\"c2c\",\"pairs\":[");
            for (i, p) in pairs.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&format!(
                    "{{\"a\":{},\"b\":{},\"ns\":{}}}",
                    p.cpu_a, p.cpu_b, p.ns_per_hop
                ));
            }
            out.push_str("]}");
            out
        }
        Some(Score::Dram { levels }) => {
            let mut out = String::from("{\"kind\":\"dram\",\"levels\":[");
            for (i, l) in levels.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&format!(
                    "{{\"kb\":{},\"ns\":{}}}",
                    l.size_kb, l.ns_per_access
                ));
            }
            out.push_str("]}");
            out
        }
    }
}

/// Infer the ISA class from the workload name. Used for the report's
/// `isa_class` field so sweeps can be grouped/diffed by rail.
pub fn isa_class(workload: &str) -> &'static str {
    match workload {
        "stress-ng-cpu" => "integer",
        "y-cruncher-sse" => "sse",
        "y-cruncher-avx2" => "avx2",
        "7zip-bench" => "mixed",
        "c2c-latency" | "dram-latency" => "other",
        _ => "other",
    }
}

/// Human-readable one-line summary of a finished report. Printed to
/// stdout after the TUI exits, one per workload in the sweep.
pub fn human_summary(r: &Report) -> String {
    let verdict = match &r.verdict {
        Some(Verdict::Clean) => "CLEAN".to_string(),
        Some(Verdict::Failed(msg)) => format!("FAILED ({msg})"),
        Some(Verdict::Stopped) => "STOPPED".to_string(),
        Some(Verdict::Error(msg)) => format!("ERROR ({msg})"),
        None => "(not finished)".to_string(),
    };
    let score = match &r.score {
        None => "(no score)".to_string(),
        Some(Score::None) => "(stability only)".to_string(),
        Some(Score::SevenZip { rating_mips, .. }) => format!("rating={rating_mips} MIPS"),
        Some(Score::YCruncher { tests }) => {
            if tests.len() == 1 {
                let t = &tests[0];
                let gbps = t.bits_per_sec / 1e9;
                format!("{} {:.2} Gb/s", t.tag, gbps)
            } else {
                format!("{} tests", tests.len())
            }
        }
        Some(Score::C2c { pairs }) => format!("{} pairs", pairs.len()),
        Some(Score::Dram { levels }) => levels
            .iter()
            .map(|l| format!("{}K {:.0}ns", l.size_kb, l.ns_per_access))
            .collect::<Vec<_>>()
            .join(" "),
    };
    format!(
        "{:<18} {:<8} {:<24} verdict={}",
        r.workload, r.isa_class, score, verdict
    )
}

// ---------------------------------------------------------------------------
// Report reader (`--report <path>`): parse back a JSON file written above
// and print a human summary. Zero-dep: we only ever read our own output,
// so a minimal depth-tracking object scanner replaces a JSON crate.
// ---------------------------------------------------------------------------

/// Split a JSON object body (`{...}`) into (key, raw value slice) pairs at
/// depth 0. Handles nested objects/arrays and string escapes. Returns None
/// if `obj` isn't a well-formed-enough `{...}` object.
fn object_entries(obj: &str) -> Option<Vec<(&str, &str)>> {
    let bytes = obj.as_bytes();
    if bytes.len() < 2 || bytes[0] != b'{' || *bytes.last()? != b'}' {
        return None;
    }
    let mut entries = Vec::new();
    let mut depth = 0i32;
    let mut in_str = false;
    let mut esc = false;
    let mut entry_start = 1usize;
    for i in 1..bytes.len() - 1 {
        let c = bytes[i];
        if in_str {
            if esc {
                esc = false;
            } else if c == b'\\' {
                esc = true;
            } else if c == b'"' {
                in_str = false;
            }
            continue;
        }
        match c {
            b'"' => in_str = true,
            b'{' | b'[' => depth += 1,
            b'}' | b']' => depth -= 1,
            b',' if depth == 0 => {
                entries.push(entry_pair(&obj[entry_start..i])?);
                entry_start = i + 1;
            }
            _ => {}
        }
    }
    let last = &obj[entry_start..obj.len() - 1];
    if !last.trim().is_empty() {
        entries.push(entry_pair(last)?);
    }
    Some(entries)
}

/// Turn one `"key": value` entry slice into a (key, value) pair. Our
/// writer's keys never contain a colon, so the first `:` is the split.
fn entry_pair(raw: &str) -> Option<(&str, &str)> {
    let (key, val) = raw.split_once(':')?;
    let key = key.trim();
    let key = key.strip_prefix('"')?.strip_suffix('"')?;
    Some((key, val.trim()))
}

/// Split a JSON array (`[...]`) into its element slices at depth 0.
fn array_items(arr: &str) -> Option<Vec<&str>> {
    let bytes = arr.as_bytes();
    if bytes.len() < 2 || bytes[0] != b'[' || *bytes.last()? != b']' {
        return None;
    }
    let mut items = Vec::new();
    let mut depth = 0i32;
    let mut in_str = false;
    let mut esc = false;
    let mut start = 1usize;
    for i in 1..bytes.len() - 1 {
        let c = bytes[i];
        if in_str {
            if esc {
                esc = false;
            } else if c == b'\\' {
                esc = true;
            } else if c == b'"' {
                in_str = false;
            }
            continue;
        }
        match c {
            b'"' => in_str = true,
            b'{' | b'[' => depth += 1,
            b'}' | b']' => depth -= 1,
            b',' if depth == 0 => {
                items.push(arr[start..i].trim());
                start = i + 1;
            }
            _ => {}
        }
    }
    let last = arr[start..arr.len() - 1].trim();
    if !last.is_empty() {
        items.push(last);
    }
    Some(items)
}

/// Unquote a JSON string value (our writer only emits the basic escapes).
fn unquote(s: &str) -> Option<String> {
    let inner = s.strip_prefix('"')?.strip_suffix('"')?;
    let mut out = String::with_capacity(inner.len());
    let mut chars = inner.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        match chars.next()? {
            '"' => out.push('"'),
            '\\' => out.push('\\'),
            'n' => out.push('\n'),
            'r' => out.push('\r'),
            't' => out.push('\t'),
            other => out.push(other),
        }
    }
    Some(out)
}

fn get_str<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<String> {
    entries
        .iter()
        .find(|(k, _)| *k == key)
        .and_then(|(_, v)| unquote(v))
}

fn get_raw<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<&'a str> {
    entries.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}

/// Human-readable one-line summary from raw report JSON (the `--report`
/// mode). Mirrors `human_summary`'s layout, reconstructed from the file.
pub fn summary_from_json(json: &str) -> Option<String> {
    let top = object_entries(json)?;
    let workload = get_str(&top, "workload")?;
    let isa = get_str(&top, "isa_class").unwrap_or_else(|| "other".to_string());
    let verdict = match get_raw(&top, "verdict") {
        None | Some("null") => "(not finished)".to_string(),
        Some(v) => {
            let e = object_entries(v)?;
            match get_str(&e, "kind")?.as_str() {
                "clean" => "CLEAN".to_string(),
                "stopped" => "STOPPED".to_string(),
                "failed" => format!("FAILED ({})", get_str(&e, "detail").unwrap_or_default()),
                "error" => format!("ERROR ({})", get_str(&e, "detail").unwrap_or_default()),
                other => format!("?{other}"),
            }
        }
    };
    let score = match get_raw(&top, "score") {
        None | Some("null") => {
            if workload == "stress-ng-cpu" {
                "(stability only)".to_string()
            } else {
                "(no score)".to_string()
            }
        }
        Some(s) => {
            let e = object_entries(s)?;
            match get_str(&e, "kind")?.as_str() {
                "7zip" => format!("rating={} MIPS", get_raw(&e, "rating_mips").unwrap_or("?")),
                "y-cruncher" => {
                    let tests = get_raw(&e, "tests").unwrap_or("[]");
                    let mut parts = Vec::new();
                    for item in array_items(tests).unwrap_or_default() {
                        let t = object_entries(item)?;
                        let tag = get_str(&t, "tag").unwrap_or_default();
                        let passed = get_raw(&t, "passed") == Some("true");
                        let bps: f64 = get_raw(&t, "bits_per_sec")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or(0.0);
                        parts.push(format!(
                            "{tag}={} {:.2} Gb/s",
                            if passed { "pass" } else { "FAIL" },
                            bps / 1e9
                        ));
                    }
                    parts.join("  ")
                }
                "c2c" => {
                    let pairs = get_raw(&e, "pairs").unwrap_or("[]");
                    let items = array_items(pairs).unwrap_or_default();
                    let mut ns_vals = Vec::new();
                    for item in &items {
                        let p = object_entries(item)?;
                        ns_vals.push(
                            get_raw(&p, "ns")
                                .and_then(|v| v.parse::<f64>().ok())
                                .unwrap_or(0.0),
                        );
                    }
                    if ns_vals.is_empty() {
                        "0 pairs".to_string()
                    } else {
                        let lo = ns_vals.iter().cloned().fold(f64::INFINITY, f64::min);
                        let hi = ns_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
                        format!("{} pairs min={:.1} max={:.1} ns", ns_vals.len(), lo, hi)
                    }
                }
                "dram" => {
                    let levels = get_raw(&e, "levels").unwrap_or("[]");
                    let items = array_items(levels).unwrap_or_default();
                    let mut parts = Vec::new();
                    for item in &items {
                        let l = object_entries(item)?;
                        let kb = get_raw(&l, "kb").unwrap_or("?");
                        let ns: f64 = get_raw(&l, "ns")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or(0.0);
                        parts.push(format!("{kb}K={ns:.1} ns"));
                    }
                    parts.join(" ")
                }
                other => format!("?{other}"),
            }
        }
    };
    Some(format!(
        "{:<18} {:<8} {:<24} verdict={}",
        workload, isa, score, verdict
    ))
}

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

    /// Unique dir per test (by test name) so tests don't race on a shared
    /// directory when one test's `remove_dir_all` wipes another's parent.
    fn test_dir(name: &str) -> std::path::PathBuf {
        std::path::PathBuf::from("/tmp/opencode/test-reports").join(name)
    }

    fn sample_report() -> Report {
        let mut r = Report::new(
            &test_dir("sample_report"),
            "y-cruncher-avx2",
            "60s cores=0-15 FFTv4",
        )
        .unwrap();
        r.finish(
            Verdict::Clean,
            Score::YCruncher {
                tests: vec![YCruncherTest {
                    tag: "FFTv4".to_string(),
                    passed: true,
                    bits_per_sec: 8.94e8,
                }],
            },
        )
        .unwrap();
        r
    }

    #[test]
    fn isa_class_infers_from_name() {
        assert_eq!(isa_class("stress-ng-cpu"), "integer");
        assert_eq!(isa_class("y-cruncher-sse"), "sse");
        assert_eq!(isa_class("y-cruncher-avx2"), "avx2");
        assert_eq!(isa_class("7zip-bench"), "mixed");
        assert_eq!(isa_class("c2c-latency"), "other");
        assert_eq!(isa_class("unknown-future"), "other");
    }

    #[test]
    fn json_escapes_strings() {
        assert_eq!(json_str("hello"), "\"hello\"");
        assert_eq!(json_str("a\"b"), "\"a\\\"b\"");
        assert_eq!(json_str("a\\b"), "\"a\\\\b\"");
        assert_eq!(json_str("a\nb"), "\"a\\nb\"");
        assert_eq!(json_str(""), "\"\"");
    }

    #[test]
    fn json_verdict_variants() {
        assert_eq!(json_verdict(&None), "null");
        assert_eq!(json_verdict(&Some(Verdict::Clean)), "{\"kind\":\"clean\"}");
        assert_eq!(
            json_verdict(&Some(Verdict::Stopped)),
            "{\"kind\":\"stopped\"}"
        );
        assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
            .contains("\"kind\":\"failed\""));
        assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
            .contains("\"detail\":\"oops\""));
    }

    #[test]
    fn json_score_null_for_none() {
        assert_eq!(json_score(&None), "null");
        assert_eq!(json_score(&Some(Score::None)), "null");
    }

    #[test]
    fn json_score_7zip_round_trips() {
        let s = Score::SevenZip {
            rating_mips: 66259,
            r_u_mips: 4653,
            usage_pct: 1431,
            compress_mips: 5346,
            decompress_mips: 3961,
        };
        let j = json_score(&Some(s));
        assert!(j.contains("\"kind\":\"7zip\""));
        assert!(j.contains("\"rating_mips\":66259"));
        assert!(j.contains("\"r_u_mips\":4653"));
        assert!(j.contains("\"usage_pct\":1431"));
        assert!(j.contains("\"compress_mips\":5346"));
        assert!(j.contains("\"decompress_mips\":3961"));
    }

    #[test]
    fn json_score_ycruncher_round_trips() {
        let s = Score::YCruncher {
            tests: vec![
                YCruncherTest {
                    tag: "FFTv4".to_string(),
                    passed: true,
                    bits_per_sec: 8.94e8,
                },
                YCruncherTest {
                    tag: "BKT".to_string(),
                    passed: true,
                    bits_per_sec: 9.49e9,
                },
            ],
        };
        let j = json_score(&Some(s));
        assert!(j.contains("\"kind\":\"y-cruncher\""));
        assert!(j.contains("\"tag\":\"FFTv4\""));
        assert!(j.contains("\"passed\":true"));
        assert!(j.contains("\"bits_per_sec\":894000000"));
        assert!(j.contains("\"tag\":\"BKT\""));
    }

    #[test]
    fn finish_stores_score_and_verdict() {
        let mut r =
            Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
        r.finish(
            Verdict::Clean,
            Score::SevenZip {
                rating_mips: 1000,
                r_u_mips: 500,
                usage_pct: 1500,
                compress_mips: 700,
                decompress_mips: 300,
            },
        )
        .unwrap();
        assert_eq!(r.verdict, Some(Verdict::Clean));
        assert!(matches!(r.score, Some(Score::SevenZip { .. })));
    }

    #[test]
    fn finish_keeps_none_score_for_summary() {
        let mut r = Report::new(
            &test_dir("finish_keeps_none_score_for_summary"),
            "test",
            "test",
        )
        .unwrap();
        r.finish(Verdict::Clean, Score::None).unwrap();
        // Stored as Some(Score::None) so human_summary can distinguish
        // "stability only" from "not finished". JSON still renders null.
        assert_eq!(r.score, Some(Score::None));
        assert!(r.to_json().contains("\"score\":null"));
    }

    #[test]
    fn to_json_has_schema_v2() {
        let r = sample_report();
        let j = r.to_json();
        assert!(j.contains("\"schema_version\":2"));
        assert!(!j.contains("\"samples\"")); // v1 field, gone
        assert!(!j.contains("\"peak_clock_khz\"")); // v1 field, gone
    }

    #[test]
    fn to_json_is_valid_structure() {
        let r = sample_report();
        let j = r.to_json();
        assert!(j.starts_with('{'));
        assert!(j.ends_with('}'));
        assert!(j.contains("\"workload\":\"y-cruncher-avx2\""));
        assert!(j.contains("\"isa_class\":\"avx2\""));
        assert!(j.contains("\"verdict\":{\"kind\":\"clean\"}"));
        assert!(j.contains("\"kind\":\"y-cruncher\""));
        assert!(j.contains("\"tag\":\"FFTv4\""));
        assert!(j.contains("\"bits_per_sec\":894000000"));
    }

    #[test]
    fn flush_writes_readable_file() {
        let dir = test_dir("flush_writes_readable_file");
        let _ = fs::remove_dir_all(&dir);
        let mut r = Report::new(&dir, "flush-test", "30s cores=0-15").unwrap();
        r.finish(Verdict::Clean, Score::None).unwrap();
        let written = fs::read_to_string(r.path()).unwrap();
        assert!(written.contains("\"workload\":\"flush-test\""));
        assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
        assert!(written.contains("\"score\":null"));
        assert!(r
            .path()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .contains("flush-test.json"));
    }

    #[test]
    fn human_summary_formats_one_line() {
        let r = sample_report();
        let s = human_summary(&r);
        assert!(s.contains("y-cruncher-avx2"));
        assert!(s.contains("avx2"));
        assert!(s.contains("CLEAN"));
        assert!(s.contains("FFTv4"));
        assert!(s.contains("Gb/s"));
    }

    #[test]
    fn human_summary_for_7zip() {
        let mut r = Report::new(&test_dir("human_summary_for_7zip"), "7zip-bench", "60s").unwrap();
        r.finish(
            Verdict::Clean,
            Score::SevenZip {
                rating_mips: 66259,
                r_u_mips: 4653,
                usage_pct: 1431,
                compress_mips: 5346,
                decompress_mips: 3961,
            },
        )
        .unwrap();
        let s = human_summary(&r);
        assert!(s.contains("66259 MIPS"));
        assert!(s.contains("mixed"));
    }

    #[test]
    fn human_summary_for_stability_only() {
        let mut r = Report::new(
            &test_dir("human_summary_for_stability_only"),
            "stress-ng-cpu",
            "60s",
        )
        .unwrap();
        r.finish(Verdict::Clean, Score::None).unwrap();
        let s = human_summary(&r);
        assert!(s.contains("(stability only)"));
        assert!(s.contains("integer"));
        assert!(s.contains("CLEAN"));
    }

    #[test]
    fn filename_sanitizes_workload_name() {
        let dir = test_dir("filename_sanitizes_workload_name");
        let _ = fs::remove_dir_all(&dir);
        let r = Report::new(&dir, "workload/with:spaces", "test").unwrap();
        let name = r.path().file_name().unwrap().to_str().unwrap();
        assert!(!name.contains('/'));
        assert!(!name.contains(':'));
        assert!(!name.contains(' '));
    }

    #[test]
    fn summary_from_json_round_trips_ycruncher() {
        let r = sample_report();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("y-cruncher-avx2"), "{s}");
        assert!(s.contains("avx2"), "{s}");
        assert!(s.contains("CLEAN"), "{s}");
        assert!(s.contains("FFTv4=pass 0.89 Gb/s"), "{s}");
    }

    #[test]
    fn summary_from_json_round_trips_7zip() {
        let mut r = Report::new(&test_dir("summary_7zip"), "7zip-bench", "60s").unwrap();
        r.finish(
            Verdict::Clean,
            Score::SevenZip {
                rating_mips: 66259,
                r_u_mips: 4653,
                usage_pct: 1431,
                compress_mips: 5346,
                decompress_mips: 3961,
            },
        )
        .unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("rating=66259 MIPS"), "{s}");
        assert!(s.contains("mixed"), "{s}");
    }

    #[test]
    fn summary_from_json_not_finished_and_stability_only() {
        let r = Report::new(&test_dir("summary_not_finished"), "test", "60s").unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("(not finished)"), "{s}");
        assert!(s.contains("(no score)"), "{s}");

        let mut r = Report::new(&test_dir("summary_stability"), "stress-ng-cpu", "60s").unwrap();
        r.finish(Verdict::Clean, Score::None).unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("(stability only)"), "{s}");
        assert!(s.contains("CLEAN"), "{s}");
    }

    #[test]
    fn summary_from_json_survives_special_chars_in_detail() {
        let mut r = Report::new(&test_dir("summary_special"), "test", "60s").unwrap();
        r.finish(
            Verdict::Failed("bad: exit {1}, \"quoted\"".to_string()),
            Score::None,
        )
        .unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("FAILED (bad: exit {1}, \"quoted\")"), "{s}");
    }

    #[test]
    fn summary_from_json_rejects_junk() {
        assert!(summary_from_json("not json").is_none());
        assert!(summary_from_json("{}").is_none()); // no workload key
    }

    #[test]
    fn summary_from_json_round_trips_c2c() {
        let mut r = Report::new(&test_dir("summary_c2c"), "c2c-latency", "30s").unwrap();
        r.finish(
            Verdict::Clean,
            Score::C2c {
                pairs: vec![
                    crate::workload::C2cPair {
                        cpu_a: 0,
                        cpu_b: 1,
                        ns_per_hop: 28.02,
                    },
                    crate::workload::C2cPair {
                        cpu_a: 0,
                        cpu_b: 2,
                        ns_per_hop: 43.83,
                    },
                ],
            },
        )
        .unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("2 pairs min=28.0 max=43.8 ns"), "{s}");
        assert!(s.contains("CLEAN"), "{s}");
    }

    #[test]
    fn summary_from_json_round_trips_dram() {
        let mut r = Report::new(&test_dir("summary_dram"), "dram-latency", "30s").unwrap();
        r.finish(
            Verdict::Clean,
            Score::Dram {
                levels: vec![
                    crate::workload::DramLevel {
                        size_kb: 32,
                        ns_per_access: 4.536,
                    },
                    crate::workload::DramLevel {
                        size_kb: 65536,
                        ns_per_access: 67.51,
                    },
                ],
            },
        )
        .unwrap();
        let s = summary_from_json(&r.to_json()).unwrap();
        assert!(s.contains("32K=4.5 ns"), "{s}");
        assert!(s.contains("65536K=67.5 ns"), "{s}");
    }
}