use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkResult {
pub name: String,
pub iterations: u64,
pub real_time: f64,
pub cpu_time: f64,
pub time_unit: String,
pub bytes_per_second: Option<f64>,
pub items_per_second: Option<f64>,
pub counters: HashMap<String, f64>,
}
impl BenchmarkResult {
pub fn format_time(&self) -> String {
if self.real_time >= 1_000_000_000.0 {
format!("{:.2} s", self.real_time / 1_000_000_000.0)
} else if self.real_time >= 1_000_000.0 {
format!("{:.2} ms", self.real_time / 1_000_000.0)
} else if self.real_time >= 1_000.0 {
format!("{:.2} us", self.real_time / 1_000.0)
} else {
format!("{:.2} ns", self.real_time)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkRun {
pub timestamp: String,
pub run_id: String,
pub system_info: SystemInfo,
pub results: Vec<BenchmarkResult>,
}
impl BenchmarkRun {
pub fn new(run_id: &str) -> Self {
Self {
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
run_id: run_id.to_string(),
system_info: SystemInfo::current(),
results: Vec::new(),
}
}
pub fn add_result(&mut self, result: BenchmarkResult) {
self.results.push(result);
}
pub fn get(&self, name: &str) -> Option<&BenchmarkResult> {
self.results.iter().find(|r| r.name == name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
pub os: String,
pub cpu: String,
pub cores: u32,
pub memory: u64,
}
impl SystemInfo {
pub fn current() -> Self {
Self {
os: std::env::consts::OS.to_string(),
cpu: "Unknown".to_string(), cores: std::thread::available_parallelism()
.map(|p| p.get() as u32)
.unwrap_or(1),
memory: 0, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkComparison {
pub name: String,
pub baseline: BenchmarkResult,
pub current: BenchmarkResult,
pub time_diff_percent: f64,
pub time_diff_abs: f64,
pub is_regression: bool,
pub is_improvement: bool,
pub threshold: f64,
}
impl BenchmarkComparison {
pub fn compare(baseline: &BenchmarkResult, current: &BenchmarkResult, threshold: f64) -> Self {
let time_diff_abs = current.real_time - baseline.real_time;
let time_diff_percent = if baseline.real_time > 0.0 {
(time_diff_abs / baseline.real_time) * 100.0
} else {
0.0
};
let is_regression = time_diff_percent > threshold;
let is_improvement = time_diff_percent < -threshold;
Self {
name: current.name.clone(),
baseline: baseline.clone(),
current: current.clone(),
time_diff_percent,
time_diff_abs,
is_regression,
is_improvement,
threshold,
}
}
pub fn format(&self) -> String {
let status = if self.is_regression {
"🔴 REGRESSION"
} else if self.is_improvement {
"🟢 IMPROVEMENT"
} else {
"⚪ NO CHANGE"
};
format!(
"{}: {} -> {} ({:+.1}%) {}",
self.name,
self.baseline.format_time(),
self.current.format_time(),
self.time_diff_percent,
status
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonReport {
pub baseline_id: String,
pub current_id: String,
pub timestamp: String,
pub comparisons: Vec<BenchmarkComparison>,
pub regressions: usize,
pub improvements: usize,
pub threshold: f64,
}
impl ComparisonReport {
pub fn new(baseline: &BenchmarkRun, current: &BenchmarkRun, threshold: f64) -> Self {
let mut comparisons = Vec::new();
for current_result in ¤t.results {
if let Some(baseline_result) = baseline.get(¤t_result.name) {
comparisons.push(BenchmarkComparison::compare(
baseline_result,
current_result,
threshold,
));
}
}
let regressions = comparisons.iter().filter(|c| c.is_regression).count();
let improvements = comparisons.iter().filter(|c| c.is_improvement).count();
Self {
baseline_id: baseline.run_id.clone(),
current_id: current.run_id.clone(),
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
comparisons,
regressions,
improvements,
threshold,
}
}
pub fn has_regressions(&self) -> bool {
self.regressions > 0
}
pub fn print_report(&self) {
println!("\n{}", "═".repeat(70));
println!("BENCHMARK COMPARISON REPORT");
println!("{}", "═".repeat(70));
println!("Baseline: {}", self.baseline_id);
println!("Current: {}", self.current_id);
println!("Threshold: {:.1}%", self.threshold);
println!();
println!("Summary:");
println!(" Total comparisons: {}", self.comparisons.len());
println!(" 🟢 Improvements: {}", self.improvements);
println!(" 🔴 Regressions: {}", self.regressions);
println!(
" ⚪ No change: {}",
self.comparisons.len() - self.improvements - self.regressions
);
println!();
println!("{}", "─".repeat(70));
println!("Details:");
println!();
if self.regressions > 0 {
println!("REGRESSIONS:");
for comp in self.comparisons.iter().filter(|c| c.is_regression) {
println!(" {}", comp.format());
}
println!();
}
if self.improvements > 0 {
println!("IMPROVEMENTS:");
for comp in self.comparisons.iter().filter(|c| c.is_improvement) {
println!(" {}", comp.format());
}
println!();
}
let no_change: Vec<_> = self
.comparisons
.iter()
.filter(|c| !c.is_regression && !c.is_improvement)
.collect();
if !no_change.is_empty() {
println!("NO SIGNIFICANT CHANGE:");
for comp in no_change.iter().take(10) {
println!(" {}", comp.format());
}
if no_change.len() > 10 {
println!(" ... and {} more", no_change.len() - 10);
}
}
println!("{}", "═".repeat(70));
if self.has_regressions() {
println!("⚠️ {} regression(s) detected!", self.regressions);
} else {
println!("✓ No regressions detected");
}
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("Failed to serialize comparison report")
}
pub fn to_markdown(&self) -> String {
let mut md = String::new();
md.push_str("# Benchmark Comparison Report\n\n");
md.push_str(&format!("**Baseline:** {}\n", self.baseline_id));
md.push_str(&format!("**Current:** {}\n", self.current_id));
md.push_str(&format!("**Threshold:** {:.1}%\n\n", self.threshold));
md.push_str("## Summary\n\n");
md.push_str(&format!("| Metric | Count |\n"));
md.push_str(&format!("|--------|-------|\n"));
md.push_str(&format!("| Total | {} |\n", self.comparisons.len()));
md.push_str(&format!("| 🟢 Improvements | {} |\n", self.improvements));
md.push_str(&format!("| 🔴 Regressions | {} |\n", self.regressions));
md.push_str(&format!(
"| ⚪ No change | {} |\n\n",
self.comparisons.len() - self.improvements - self.regressions
));
md.push_str("## Details\n\n");
md.push_str("| Benchmark | Baseline | Current | Change | Status |\n");
md.push_str("|-----------|----------|---------|--------|--------|\n");
for comp in &self.comparisons {
let status = if comp.is_regression {
"🔴"
} else if comp.is_improvement {
"🟢"
} else {
"⚪"
};
md.push_str(&format!(
"| {} | {} | {} | {:+.1}% | {} |\n",
comp.name,
comp.baseline.format_time(),
comp.current.format_time(),
comp.time_diff_percent,
status
));
}
md
}
}
pub struct BenchmarkStore {
store_dir: PathBuf,
}
impl BenchmarkStore {
pub fn new(store_dir: PathBuf) -> Self {
Self { store_dir }
}
pub fn default_path() -> PathBuf {
PathBuf::from(".ccgo").join("benchmarks")
}
pub fn save(&self, run: &BenchmarkRun) -> Result<PathBuf> {
std::fs::create_dir_all(&self.store_dir)?;
let filename = format!(
"{}_{}.json",
run.run_id.replace(['/', '\\', ' '], "_"),
chrono::Local::now().format("%Y%m%d_%H%M%S")
);
let path = self.store_dir.join(filename);
let json = serde_json::to_string_pretty(run)?;
std::fs::write(&path, json)?;
Ok(path)
}
pub fn load(&self, run_id: &str) -> Result<BenchmarkRun> {
for entry in std::fs::read_dir(&self.store_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "json").unwrap_or(false) {
let name = path.file_stem().unwrap().to_string_lossy();
if name.starts_with(&run_id.replace(['/', '\\', ' '], "_")) {
let content = std::fs::read_to_string(&path)?;
return serde_json::from_str(&content)
.context("Failed to parse benchmark file");
}
}
}
anyhow::bail!("Benchmark run not found: {}", run_id)
}
pub fn load_latest(&self) -> Result<BenchmarkRun> {
let mut latest: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in std::fs::read_dir(&self.store_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(metadata) = path.metadata() {
if let Ok(modified) = metadata.modified() {
match &latest {
None => latest = Some((path, modified)),
Some((_, latest_time)) if modified > *latest_time => {
latest = Some((path, modified))
}
_ => {}
}
}
}
}
}
if let Some((path, _)) = latest {
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content).context("Failed to parse benchmark file")
} else {
anyhow::bail!("No benchmark runs found")
}
}
pub fn list(&self) -> Result<Vec<String>> {
let mut runs = Vec::new();
if !self.store_dir.exists() {
return Ok(runs);
}
for entry in std::fs::read_dir(&self.store_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(run) = serde_json::from_str::<BenchmarkRun>(&content) {
runs.push(run.run_id);
}
}
}
}
Ok(runs)
}
pub fn parse_gbench_json(&self, path: &Path) -> Result<BenchmarkRun> {
let content = std::fs::read_to_string(path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
let mut run = BenchmarkRun::new("current");
if let Some(benchmarks) = json.get("benchmarks").and_then(|b| b.as_array()) {
for bench in benchmarks {
let name = bench.get("name").and_then(|n| n.as_str()).unwrap_or("");
let iterations = bench.get("iterations").and_then(|i| i.as_u64()).unwrap_or(0);
let real_time = bench.get("real_time").and_then(|t| t.as_f64()).unwrap_or(0.0);
let cpu_time = bench.get("cpu_time").and_then(|t| t.as_f64()).unwrap_or(0.0);
let time_unit = bench
.get("time_unit")
.and_then(|u| u.as_str())
.unwrap_or("ns")
.to_string();
run.add_result(BenchmarkResult {
name: name.to_string(),
iterations,
real_time,
cpu_time,
time_unit,
bytes_per_second: bench.get("bytes_per_second").and_then(|b| b.as_f64()),
items_per_second: bench.get("items_per_second").and_then(|i| i.as_f64()),
counters: HashMap::new(),
});
}
}
Ok(run)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_benchmark_result_format_time() {
let result = BenchmarkResult {
name: "test".to_string(),
iterations: 1000,
real_time: 1500.0,
cpu_time: 1500.0,
time_unit: "ns".to_string(),
bytes_per_second: None,
items_per_second: None,
counters: HashMap::new(),
};
assert_eq!(result.format_time(), "1.50 us");
let result2 = BenchmarkResult {
real_time: 1_500_000.0,
..result.clone()
};
assert_eq!(result2.format_time(), "1.50 ms");
}
#[test]
fn test_comparison() {
let baseline = BenchmarkResult {
name: "test".to_string(),
iterations: 1000,
real_time: 100.0,
cpu_time: 100.0,
time_unit: "ns".to_string(),
bytes_per_second: None,
items_per_second: None,
counters: HashMap::new(),
};
let current = BenchmarkResult {
real_time: 120.0,
..baseline.clone()
};
let comp = BenchmarkComparison::compare(&baseline, ¤t, 10.0);
assert_eq!(comp.time_diff_percent, 20.0);
assert!(comp.is_regression);
assert!(!comp.is_improvement);
}
#[test]
fn test_comparison_improvement() {
let baseline = BenchmarkResult {
name: "test".to_string(),
iterations: 1000,
real_time: 100.0,
cpu_time: 100.0,
time_unit: "ns".to_string(),
bytes_per_second: None,
items_per_second: None,
counters: HashMap::new(),
};
let current = BenchmarkResult {
real_time: 80.0,
..baseline.clone()
};
let comp = BenchmarkComparison::compare(&baseline, ¤t, 10.0);
assert_eq!(comp.time_diff_percent, -20.0);
assert!(!comp.is_regression);
assert!(comp.is_improvement);
}
}