//! 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, /// Set by `finish()`. None for stability-only workloads (stress-ng), /// or until the workload completes. pub score: Option, /// 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 `-.json`. pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result { 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 ` 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) -> 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) -> 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(' ')); } }