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 — adlermon 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
        }
    }
}

/// 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())
            }
        }
    };
    format!(
        "{:<18} {:<8} {:<24} verdict={}",
        r.workload, r.isa_class, 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(' '));
    }
}