use crate::error::Result;
use crate::scenarios::ScenarioResult;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub name: String,
#[serde(with = "duration_serde")]
pub duration: Duration,
#[serde(with = "duration_serde")]
pub std_dev: Duration,
pub sample_count: usize,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub commit_hash: Option<String>,
pub metadata: HashMap<String, String>,
}
mod duration_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.as_secs_f64().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = f64::deserialize(deserializer)?;
Ok(Duration::from_secs_f64(secs))
}
}
impl Baseline {
pub fn from_result(result: &ScenarioResult) -> Self {
Self {
name: result.name.clone(),
duration: result.execution_duration,
std_dev: Duration::ZERO,
sample_count: 1,
timestamp: chrono::Utc::now(),
commit_hash: None,
metadata: HashMap::new(),
}
}
pub fn from_results(name: String, results: &[ScenarioResult]) -> Option<Self> {
if results.is_empty() {
return None;
}
let durations: Vec<f64> = results
.iter()
.map(|r| r.execution_duration.as_secs_f64())
.collect();
let mean = durations.iter().sum::<f64>() / durations.len() as f64;
let variance =
durations.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / durations.len() as f64;
let std_dev = variance.sqrt();
Some(Self {
name,
duration: Duration::from_secs_f64(mean),
std_dev: Duration::from_secs_f64(std_dev),
sample_count: results.len(),
timestamp: chrono::Utc::now(),
commit_hash: None,
metadata: HashMap::new(),
})
}
pub fn with_commit_hash<S: Into<String>>(mut self, hash: S) -> Self {
self.commit_hash = Some(hash.into());
self
}
pub fn with_metadata<K, V>(mut self, key: K, value: V) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.metadata.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineStore {
pub baselines: HashMap<String, Baseline>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl BaselineStore {
pub fn new() -> Self {
let now = chrono::Utc::now();
Self {
baselines: HashMap::new(),
created_at: now,
updated_at: now,
}
}
pub fn add_baseline(&mut self, baseline: Baseline) {
self.baselines.insert(baseline.name.clone(), baseline);
self.updated_at = chrono::Utc::now();
}
pub fn get_baseline(&self, name: &str) -> Option<&Baseline> {
self.baselines.get(name)
}
pub fn remove_baseline(&mut self, name: &str) -> Option<Baseline> {
let result = self.baselines.remove(name);
if result.is_some() {
self.updated_at = chrono::Utc::now();
}
result
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let file = File::create(path.as_ref())?;
serde_json::to_writer_pretty(file, self)?;
Ok(())
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = File::open(path.as_ref())?;
let store = serde_json::from_reader(file)?;
Ok(store)
}
pub fn merge(&mut self, other: BaselineStore) {
for (name, baseline) in other.baselines {
self.baselines.insert(name, baseline);
}
self.updated_at = chrono::Utc::now();
}
}
impl Default for BaselineStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct RegressionConfig {
pub max_slowdown: f64,
pub std_dev_threshold: f64,
pub min_samples: usize,
}
impl Default for RegressionConfig {
fn default() -> Self {
Self {
max_slowdown: 1.1,
std_dev_threshold: 2.0,
min_samples: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegressionResult {
pub benchmark_name: String,
pub is_regression: bool,
#[serde(with = "duration_serde")]
pub current_duration: Duration,
#[serde(with = "duration_serde")]
pub baseline_duration: Duration,
pub slowdown_ratio: f64,
pub significance: f64,
pub severity: RegressionSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RegressionSeverity {
None,
Minor,
Moderate,
Severe,
}
impl RegressionResult {
pub fn new(
benchmark_name: String,
current_duration: Duration,
baseline: &Baseline,
config: &RegressionConfig,
) -> Self {
let slowdown_ratio = current_duration.as_secs_f64() / baseline.duration.as_secs_f64();
let significance = if baseline.std_dev.as_secs_f64() > 0.0 {
(current_duration.as_secs_f64() - baseline.duration.as_secs_f64())
/ baseline.std_dev.as_secs_f64()
} else {
0.0
};
let is_regression =
slowdown_ratio > config.max_slowdown || significance > config.std_dev_threshold;
let severity = if !is_regression {
RegressionSeverity::None
} else if slowdown_ratio < config.max_slowdown * 1.2 {
RegressionSeverity::Minor
} else if slowdown_ratio < config.max_slowdown * 1.5 {
RegressionSeverity::Moderate
} else {
RegressionSeverity::Severe
};
Self {
benchmark_name,
is_regression,
current_duration,
baseline_duration: baseline.duration,
slowdown_ratio,
significance,
severity,
}
}
}
pub struct RegressionDetector {
baseline_store: BaselineStore,
config: RegressionConfig,
baseline_path: PathBuf,
}
impl RegressionDetector {
pub fn new<P: Into<PathBuf>>(baseline_path: P, config: RegressionConfig) -> Result<Self> {
let baseline_path = baseline_path.into();
let baseline_store = if baseline_path.exists() {
BaselineStore::load(&baseline_path)?
} else {
BaselineStore::new()
};
Ok(Self {
baseline_store,
config,
baseline_path,
})
}
pub fn with_defaults<P: Into<PathBuf>>(baseline_path: P) -> Result<Self> {
Self::new(baseline_path, RegressionConfig::default())
}
pub fn detect(&self, results: &[ScenarioResult]) -> Vec<RegressionResult> {
let mut regression_results = Vec::new();
for result in results {
if !result.success {
continue;
}
if let Some(baseline) = self.baseline_store.get_baseline(&result.name) {
let regression = RegressionResult::new(
result.name.clone(),
result.execution_duration,
baseline,
&self.config,
);
regression_results.push(regression);
}
}
regression_results
}
pub fn update_baselines(&mut self, results: &[ScenarioResult]) -> Result<()> {
for result in results {
if !result.success {
continue;
}
let baseline = Baseline::from_result(result);
self.baseline_store.add_baseline(baseline);
}
self.save_baselines()?;
Ok(())
}
pub fn save_baselines(&self) -> Result<()> {
if let Some(parent) = self.baseline_path.parent() {
std::fs::create_dir_all(parent)?;
}
self.baseline_store.save(&self.baseline_path)?;
Ok(())
}
pub fn baseline_store(&self) -> &BaselineStore {
&self.baseline_store
}
pub fn baseline_store_mut(&mut self) -> &mut BaselineStore {
&mut self.baseline_store
}
}
pub struct RegressionReport {
results: Vec<RegressionResult>,
}
impl RegressionReport {
pub fn new(results: Vec<RegressionResult>) -> Self {
Self { results }
}
pub fn regressions(&self) -> Vec<&RegressionResult> {
self.results.iter().filter(|r| r.is_regression).collect()
}
pub fn regressions_by_severity(&self, severity: RegressionSeverity) -> Vec<&RegressionResult> {
self.results
.iter()
.filter(|r| r.is_regression && r.severity == severity)
.collect()
}
pub fn has_regressions(&self) -> bool {
self.results.iter().any(|r| r.is_regression)
}
pub fn generate_summary(&self) -> String {
let mut output = String::new();
let regressions = self.regressions();
if regressions.is_empty() {
output.push_str("No performance regressions detected.\n");
} else {
output.push_str(&format!(
"Detected {} performance regression(s):\n\n",
regressions.len()
));
for result in regressions {
output.push_str(&format!(
" {} [{:?}]:\n",
result.benchmark_name, result.severity
));
output.push_str(&format!(
" Current: {:.6}s\n",
result.current_duration.as_secs_f64()
));
output.push_str(&format!(
" Baseline: {:.6}s\n",
result.baseline_duration.as_secs_f64()
));
output.push_str(&format!(
" Slowdown: {:.2}x ({:.1}% slower)\n",
result.slowdown_ratio,
(result.slowdown_ratio - 1.0) * 100.0
));
output.push_str(&format!(
" Significance: {:.2} std dev\n\n",
result.significance
));
}
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_baseline_creation() {
let result = ScenarioResult {
name: "test_bench".to_string(),
setup_duration: Duration::from_secs(1),
execution_duration: Duration::from_secs(5),
teardown_duration: Duration::from_secs(1),
total_duration: Duration::from_secs(7),
success: true,
error_message: None,
metrics: HashMap::new(),
};
let baseline = Baseline::from_result(&result);
assert_eq!(baseline.name, "test_bench");
assert_eq!(baseline.duration, Duration::from_secs(5));
}
#[test]
fn test_baseline_store() {
let mut store = BaselineStore::new();
let baseline = Baseline {
name: "bench1".to_string(),
duration: Duration::from_secs(2),
std_dev: Duration::from_millis(100),
sample_count: 10,
timestamp: chrono::Utc::now(),
commit_hash: None,
metadata: HashMap::new(),
};
store.add_baseline(baseline);
assert!(store.get_baseline("bench1").is_some());
assert!(store.get_baseline("bench2").is_none());
}
#[test]
fn test_regression_detection() {
let baseline = Baseline {
name: "test_bench".to_string(),
duration: Duration::from_secs(1),
std_dev: Duration::from_millis(50),
sample_count: 10,
timestamp: chrono::Utc::now(),
commit_hash: None,
metadata: HashMap::new(),
};
let config = RegressionConfig::default();
let result1 = RegressionResult::new(
"test_bench".to_string(),
Duration::from_secs(1),
&baseline,
&config,
);
assert!(!result1.is_regression);
let result2 = RegressionResult::new(
"test_bench".to_string(),
Duration::from_millis(1500),
&baseline,
&config,
);
assert!(result2.is_regression);
assert_eq!(result2.severity, RegressionSeverity::Moderate);
}
#[test]
fn test_regression_report() {
let results = vec![
RegressionResult {
benchmark_name: "bench1".to_string(),
is_regression: true,
current_duration: Duration::from_secs(2),
baseline_duration: Duration::from_secs(1),
slowdown_ratio: 2.0,
significance: 5.0,
severity: RegressionSeverity::Severe,
},
RegressionResult {
benchmark_name: "bench2".to_string(),
is_regression: false,
current_duration: Duration::from_secs(1),
baseline_duration: Duration::from_secs(1),
slowdown_ratio: 1.0,
significance: 0.0,
severity: RegressionSeverity::None,
},
];
let report = RegressionReport::new(results);
assert!(report.has_regressions());
assert_eq!(report.regressions().len(), 1);
}
}