use crate::config::ComparisonConfig;
use crate::{BenchResult, CpuSnapshot, Percentiles};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
fn get_primary_mac_address() -> Result<String, std::io::Error> {
let interface = default_net::get_default_interface().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Failed to get default network interface: {}", e),
)
})?;
let mac_addr = interface.mac_addr.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Default interface has no MAC address",
)
})?;
let mac_string = format!("{}", mac_addr).replace(':', "-").to_lowercase();
hash_mac_address(&mac_string)
}
fn hash_mac_address(mac: &str) -> Result<String, std::io::Error> {
let mut hasher = Sha256::new();
hasher.update(mac.as_bytes());
let result = hasher.finalize();
Ok(format!("{:x}", result)[..16].to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineData {
pub benchmark_name: String,
pub module: String,
pub timestamp: String,
pub samples: Vec<u128>,
pub statistics: crate::Statistics,
#[serde(alias = "hostname")]
pub machine_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cpu_samples: Vec<CpuSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percentiles: Option<Percentiles>,
#[serde(default, skip_serializing_if = "is_false")]
pub was_regression: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl BaselineData {
pub fn from_bench_result(
result: &BenchResult,
machine_id: String,
was_regression: bool,
) -> Self {
let samples: Vec<u128> = result.all_timings.iter().map(|d| d.as_nanos()).collect();
let statistics = crate::calculate_statistics(&samples);
Self {
benchmark_name: result.name.clone(),
module: result.module.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
samples,
statistics,
machine_id,
cpu_samples: result.cpu_samples.clone(),
percentiles: Some(result.percentiles.clone()),
was_regression,
}
}
pub fn to_bench_result(&self) -> BenchResult {
let percentiles = if let Some(ref p) = self.percentiles {
p.clone()
} else {
Percentiles {
mean: Duration::from_nanos(self.statistics.mean as u64),
p50: Duration::from_nanos(self.statistics.median as u64),
p90: Duration::from_nanos(self.statistics.p90 as u64),
p99: Duration::from_nanos(self.statistics.p99 as u64),
}
};
let all_timings: Vec<Duration> = self
.samples
.iter()
.map(|&ns| Duration::from_nanos(ns as u64))
.collect();
BenchResult {
name: self.benchmark_name.clone(),
module: self.module.clone(),
percentiles,
samples: self.samples.len(),
all_timings,
cpu_samples: self.cpu_samples.clone(),
warmup_ms: None,
warmup_iterations: None,
}
}
}
#[derive(Debug)]
pub struct BaselineManager {
root_dir: PathBuf,
machine_id: String,
}
impl BaselineManager {
pub fn new() -> Result<Self, std::io::Error> {
let machine_id = get_primary_mac_address()?;
Ok(Self {
root_dir: PathBuf::from(".benches"),
machine_id,
})
}
pub fn with_root_dir<P: AsRef<Path>>(root_dir: P) -> Result<Self, std::io::Error> {
let machine_id = get_primary_mac_address()?;
Ok(Self {
root_dir: root_dir.as_ref().to_path_buf(),
machine_id,
})
}
fn machine_dir(&self) -> PathBuf {
self.root_dir.join(&self.machine_id)
}
fn benchmark_dir(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
let dir_name = format!("{}_{}", crate_name, benchmark_name);
self.machine_dir().join(dir_name)
}
fn legacy_baseline_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
let filename = format!("{}_{}.json", crate_name, benchmark_name);
self.machine_dir().join(filename)
}
fn get_run_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
let filename = format!("{}.json", timestamp);
self.benchmark_dir(crate_name, benchmark_name)
.join(filename)
}
fn ensure_dir_exists(
&self,
crate_name: &str,
benchmark_name: &str,
) -> Result<(), std::io::Error> {
fs::create_dir_all(self.benchmark_dir(crate_name, benchmark_name))
}
pub fn save_baseline(
&self,
crate_name: &str,
result: &BenchResult,
was_regression: bool,
) -> Result<(), std::io::Error> {
self.ensure_dir_exists(crate_name, &result.name)?;
let baseline =
BaselineData::from_bench_result(result, self.machine_id.clone(), was_regression);
let json = serde_json::to_string_pretty(&baseline)?;
let path = self.get_run_path(crate_name, &result.name);
fs::write(path, json)?;
Ok(())
}
pub fn load_baseline(
&self,
crate_name: &str,
benchmark_name: &str,
) -> Result<Option<BaselineData>, std::io::Error> {
let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
if bench_dir.exists() && bench_dir.is_dir() {
let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.collect();
if runs.is_empty() {
return Ok(None);
}
runs.sort_by_key(|e| e.file_name());
let latest = runs.last().unwrap();
let contents = fs::read_to_string(latest.path())?;
let baseline: BaselineData = serde_json::from_str(&contents)?;
return Ok(Some(baseline));
}
let legacy_path = self.legacy_baseline_path(crate_name, benchmark_name);
if legacy_path.exists() {
let contents = fs::read_to_string(legacy_path)?;
let baseline: BaselineData = serde_json::from_str(&contents)?;
return Ok(Some(baseline));
}
Ok(None)
}
pub fn has_baseline(&self, crate_name: &str, benchmark_name: &str) -> bool {
let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
if bench_dir.exists() && bench_dir.is_dir() {
return true;
}
self.legacy_baseline_path(crate_name, benchmark_name)
.exists()
}
pub fn list_runs(
&self,
crate_name: &str,
benchmark_name: &str,
) -> Result<Vec<String>, std::io::Error> {
let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
if !bench_dir.exists() || !bench_dir.is_dir() {
return Ok(vec![]);
}
let mut runs: Vec<String> = fs::read_dir(&bench_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|e| {
e.file_name()
.to_string_lossy()
.strip_suffix(".json")
.map(|s| s.to_string())
})
.collect();
runs.sort();
Ok(runs)
}
pub fn load_run(
&self,
crate_name: &str,
benchmark_name: &str,
timestamp: &str,
) -> Result<Option<BaselineData>, std::io::Error> {
let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
let filename = format!("{}.json", timestamp);
let path = bench_dir.join(filename);
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
let baseline: BaselineData = serde_json::from_str(&contents)?;
Ok(Some(baseline))
}
pub fn list_baselines(&self, crate_name: &str) -> Result<Vec<String>, std::io::Error> {
let machine_dir = self.machine_dir();
if !machine_dir.exists() {
return Ok(vec![]);
}
let prefix = format!("{}_", crate_name);
let mut baselines = Vec::new();
for entry in fs::read_dir(machine_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && entry.path().is_dir() {
let benchmark_name = name.strip_prefix(&prefix).unwrap_or(&name).to_string();
baselines.push(benchmark_name);
}
else if name.starts_with(&prefix) && name.ends_with(".json") {
let benchmark_name = name
.strip_prefix(&prefix)
.and_then(|s| s.strip_suffix(".json"))
.unwrap_or(&name)
.to_string();
baselines.push(benchmark_name);
}
}
Ok(baselines)
}
pub fn load_recent_baselines(
&self,
crate_name: &str,
benchmark_name: &str,
count: usize,
) -> Result<Vec<BaselineData>, std::io::Error> {
let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
if !bench_dir.exists() || !bench_dir.is_dir() {
return Ok(vec![]);
}
let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.collect();
if runs.is_empty() {
return Ok(vec![]);
}
runs.sort_by_key(|e| e.file_name());
let mut baselines = Vec::new();
for entry in runs.iter().rev() {
if baselines.len() >= count {
break;
}
let contents = fs::read_to_string(entry.path())?;
if let Ok(baseline) = serde_json::from_str::<BaselineData>(&contents) {
if !baseline.was_regression {
baselines.push(baseline);
}
}
}
baselines.reverse();
Ok(baselines)
}
}
impl Default for BaselineManager {
fn default() -> Self {
Self::new().expect("Failed to get primary MAC address")
}
}
#[derive(Debug, Clone)]
pub struct ComparisonResult {
pub benchmark_name: String,
pub comparison: Option<crate::Comparison>,
pub is_regression: bool,
}
pub fn detect_regression_with_cpd(
current: &crate::BenchResult,
historical: &[BaselineData],
threshold: f64,
confidence_level: f64,
cp_threshold: f64,
hazard_rate: f64,
) -> ComparisonResult {
if historical.is_empty() {
return ComparisonResult {
benchmark_name: current.name.clone(),
comparison: None,
is_regression: false,
};
}
let historical_means: Vec<f64> = historical
.iter()
.map(|b| b.statistics.mean as f64)
.collect();
let current_mean = current.percentiles.mean.as_nanos() as f64;
let hist_mean = crate::statistics::mean(&historical_means);
let hist_stddev = crate::statistics::standard_deviation(&historical_means);
let z_score_value = crate::statistics::z_score(current_mean, hist_mean, hist_stddev);
let z_critical = if (confidence_level - 0.90).abs() < 0.01 {
1.282 } else if (confidence_level - 0.95).abs() < 0.01 {
1.645 } else if (confidence_level - 0.99).abs() < 0.01 {
2.326 } else {
1.96 };
let upper_bound = hist_mean + (z_critical * hist_stddev);
let lower_bound = hist_mean - (z_critical * hist_stddev);
let statistically_significant = current_mean > upper_bound;
let change_probability = crate::changepoint::bayesian_change_point_probability(
current_mean,
&historical_means,
hazard_rate,
);
let percentage_change = ((current_mean - hist_mean) / hist_mean) * 100.0;
let practically_significant = percentage_change > threshold;
let is_regression = if z_score_value.abs() > 5.0 {
statistically_significant && practically_significant
} else if z_score_value.abs() > 2.0 {
statistically_significant && practically_significant && change_probability > cp_threshold
} else {
false
};
ComparisonResult {
benchmark_name: current.name.clone(),
comparison: Some(crate::Comparison {
current_mean: current.percentiles.mean,
baseline_mean: Duration::from_nanos(hist_mean as u64),
percentage_change,
baseline_count: historical.len(),
z_score: Some(z_score_value),
confidence_interval: Some((lower_bound, upper_bound)),
change_probability: Some(change_probability),
}),
is_regression,
}
}
pub fn process_with_baselines(
results: &[crate::BenchResult],
config: &ComparisonConfig,
) -> Result<Vec<ComparisonResult>, std::io::Error> {
let baseline_manager = BaselineManager::new()?;
let mut comparisons = Vec::new();
for result in results {
let crate_name = result.module.split("::").next().unwrap_or("unknown");
let historical =
baseline_manager.load_recent_baselines(crate_name, &result.name, config.window_size)?;
let comparison_result = if !historical.is_empty() {
detect_regression_with_cpd(
result,
&historical,
config.threshold,
config.confidence_level,
config.cp_threshold,
config.hazard_rate,
)
} else {
ComparisonResult {
benchmark_name: result.name.clone(),
comparison: None,
is_regression: false,
}
};
let is_regression = comparison_result.is_regression;
comparisons.push(comparison_result);
baseline_manager.save_baseline(crate_name, result, is_regression)?;
}
Ok(comparisons)
}
pub fn check_regressions_and_exit(comparisons: &[ComparisonResult], config: &ComparisonConfig) {
if !config.ci_mode {
return;
}
let has_regression = comparisons.iter().any(|c| c.is_regression);
if has_regression {
use colored::Colorize;
eprintln!();
eprintln!(
"{}",
format!(
"FAILED: Performance regression detected (threshold: {}%)",
config.threshold
)
.red()
.bold()
);
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
fn create_test_result(name: &str) -> BenchResult {
BenchResult {
name: name.to_string(),
module: "test_module".to_string(),
samples: 10,
percentiles: Percentiles {
p50: Duration::from_millis(5),
p90: Duration::from_millis(10),
p99: Duration::from_millis(15),
mean: Duration::from_millis(8),
},
all_timings: vec![Duration::from_millis(5); 10],
cpu_samples: vec![],
..Default::default()
}
}
#[test]
fn test_baseline_data_conversion() {
let result = create_test_result("test_bench");
let machine_id = "0123456789abcdef".to_string();
let baseline = BaselineData::from_bench_result(&result, machine_id.clone(), false);
assert_eq!(baseline.benchmark_name, "test_bench");
assert_eq!(baseline.module, "test_module");
assert_eq!(baseline.machine_id, machine_id);
assert_eq!(baseline.statistics.sample_count, 10);
assert_eq!(baseline.samples.len(), 10);
let converted = baseline.to_bench_result();
assert_eq!(converted.name, result.name);
assert_eq!(converted.module, result.module);
assert_eq!(converted.percentiles.p90, result.percentiles.p90);
}
#[test]
fn test_save_and_load_baseline() {
let temp_dir = TempDir::new().unwrap();
let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
let result = create_test_result("test_bench");
manager.save_baseline("my_crate", &result, false).unwrap();
let loaded = manager.load_baseline("my_crate", "test_bench").unwrap();
assert!(loaded.is_some());
let baseline = loaded.unwrap();
assert_eq!(baseline.benchmark_name, "test_bench");
assert_eq!(baseline.module, "test_module");
assert!(baseline.percentiles.is_some());
assert_eq!(baseline.percentiles.unwrap().p90, Duration::from_millis(10));
}
#[test]
fn test_load_nonexistent_baseline() {
let temp_dir = TempDir::new().unwrap();
let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
let loaded = manager.load_baseline("my_crate", "nonexistent").unwrap();
assert!(loaded.is_none());
}
#[test]
fn test_has_baseline() {
let temp_dir = TempDir::new().unwrap();
let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
let result = create_test_result("test_bench");
assert!(!manager.has_baseline("my_crate", "test_bench"));
manager.save_baseline("my_crate", &result, false).unwrap();
assert!(manager.has_baseline("my_crate", "test_bench"));
}
#[test]
fn test_list_baselines() {
let temp_dir = TempDir::new().unwrap();
let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
let result1 = create_test_result("bench1");
let result2 = create_test_result("bench2");
manager.save_baseline("my_crate", &result1, false).unwrap();
manager.save_baseline("my_crate", &result2, false).unwrap();
let mut baselines = manager.list_baselines("my_crate").unwrap();
baselines.sort();
assert_eq!(baselines, vec!["bench1", "bench2"]);
}
#[test]
fn test_get_primary_mac_address() {
let result = get_primary_mac_address();
assert!(result.is_ok(), "Failed to get machine ID: {:?}", result);
let machine_id = result.unwrap();
assert_eq!(
machine_id.len(),
16,
"Machine ID should be 16 characters: {}",
machine_id
);
assert_eq!(
machine_id,
machine_id.to_lowercase(),
"Machine ID should be lowercase"
);
assert!(
machine_id.chars().all(|c| c.is_ascii_hexdigit()),
"Machine ID should contain only hex digits"
);
}
#[test]
fn test_mac_address_format() {
let manager_result = BaselineManager::new();
assert!(
manager_result.is_ok(),
"Failed to create BaselineManager: {:?}",
manager_result
);
let manager = manager_result.unwrap();
assert_eq!(
manager.machine_id.len(),
16,
"Machine ID should be 16 characters"
);
assert_eq!(manager.machine_id, manager.machine_id.to_lowercase());
assert!(manager.machine_id.chars().all(|c| c.is_ascii_hexdigit()));
}
}