mod analysis;
mod metrics;
mod types;
pub use analysis::{MetricRegression, RegressionAnalysis};
pub use metrics::{BenchmarkMetric, BenchmarkResults};
pub use types::{
GitRef, PipelineConfig, PipelineError, PipelineResult, PipelineStatus, StatusCheck,
};
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct RegressionPipeline {
config: PipelineConfig,
status: PipelineStatus,
start_time: Option<Instant>,
baseline_cache: HashMap<String, BenchmarkResults>,
history: Vec<RegressionAnalysis>,
}
impl RegressionPipeline {
pub fn new(config: PipelineConfig) -> Self {
Self {
config,
status: PipelineStatus::Pending,
start_time: None,
baseline_cache: HashMap::new(),
history: Vec::new(),
}
}
pub fn run(&mut self, git_ref: &GitRef) -> PipelineResult<RegressionAnalysis> {
self.status = PipelineStatus::Running;
self.start_time = Some(Instant::now());
let baseline = self.get_or_create_baseline()?;
let current = self.run_benchmarks(git_ref)?;
let analysis = self.analyze_regressions(&baseline, ¤t);
self.status = analysis.status;
self.history.push(analysis.clone());
if let Some(pr_num) = self.extract_pr_number(git_ref) {
let _ = self.update_pr_status(pr_num, &analysis);
}
Ok(analysis)
}
fn get_or_create_baseline(&mut self) -> PipelineResult<BenchmarkResults> {
let base_branch = self.config.base_branch.clone();
if let Some(cached) = self.baseline_cache.get(&base_branch) {
return Ok(cached.clone());
}
let baseline = self.run_benchmarks(&GitRef::Branch(base_branch.clone()))?;
self.baseline_cache.insert(base_branch, baseline.clone());
Ok(baseline)
}
fn run_benchmarks(&self, git_ref: &GitRef) -> PipelineResult<BenchmarkResults> {
let start = Instant::now();
let commit = match git_ref {
GitRef::Commit(sha) => sha.clone(),
GitRef::Branch(name) => format!("{}-head", name),
GitRef::Tag(name) => format!("tag-{}", name),
GitRef::PullRequest(num) => format!("pr-{}", num),
};
let branch = match git_ref {
GitRef::Branch(name) => name.clone(),
GitRef::Commit(_) | GitRef::Tag(_) | GitRef::PullRequest(_) => "detached".to_string(),
};
let mut results = BenchmarkResults::new(commit, branch);
results.add_metric(BenchmarkMetric::new(
"latency_p50",
vec![100.0, 102.0, 98.0, 101.0, 99.0],
"\u{03bc}s",
));
results.add_metric(BenchmarkMetric::new(
"latency_p99",
vec![200.0, 210.0, 195.0, 205.0, 198.0],
"\u{03bc}s",
));
results.add_metric(BenchmarkMetric::new(
"throughput",
vec![10000.0, 10100.0, 9900.0, 10050.0, 9950.0],
"ops/s",
));
results.duration_ms = start.elapsed().as_millis() as u64;
Ok(results)
}
fn analyze_regressions(
&self,
baseline: &BenchmarkResults,
current: &BenchmarkResults,
) -> RegressionAnalysis {
let start = Instant::now();
let mut regressions = Vec::new();
for baseline_metric in &baseline.metrics {
if let Some(current_metric) = current.get_metric(&baseline_metric.name) {
let regression = MetricRegression::from_metrics(
baseline_metric,
current_metric,
self.config.regression_threshold_percent,
self.config.warning_threshold_percent,
);
regressions.push(regression);
}
}
let has_regressions = regressions.iter().any(|r| r.is_regression);
let has_warnings = regressions.iter().any(|r| r.is_warning);
let status = if has_regressions {
PipelineStatus::Failed
} else if has_warnings {
PipelineStatus::Warning
} else {
PipelineStatus::Passed
};
let summary = self.build_summary(®ressions, status);
RegressionAnalysis {
baseline: baseline.clone(),
current: current.clone(),
regressions,
status,
analysis_duration_ms: start.elapsed().as_millis() as u64,
summary,
}
}
fn build_summary(&self, regressions: &[MetricRegression], status: PipelineStatus) -> String {
let regression_count = regressions.iter().filter(|r| r.is_regression).count();
let warning_count = regressions.iter().filter(|r| r.is_warning).count();
match status {
PipelineStatus::Passed => {
"All benchmarks passed. No performance regressions detected.".to_string()
}
PipelineStatus::Warning => {
format!(
"Performance warnings detected: {} metrics show minor degradation ({:.1}%-{:.1}%)",
warning_count,
self.config.warning_threshold_percent,
self.config.regression_threshold_percent
)
}
PipelineStatus::Failed => {
format!(
"Performance regression detected: {} metrics degraded by >{:.1}%",
regression_count, self.config.regression_threshold_percent
)
}
PipelineStatus::Pending
| PipelineStatus::Running
| PipelineStatus::Cancelled
| PipelineStatus::Error => "Pipeline status unknown".to_string(),
}
}
fn extract_pr_number(&self, git_ref: &GitRef) -> Option<u64> {
match git_ref {
GitRef::PullRequest(num) => Some(*num),
GitRef::Branch(_) | GitRef::Commit(_) | GitRef::Tag(_) => None,
}
}
fn update_pr_status(&self, pr_num: u64, analysis: &RegressionAnalysis) -> PipelineResult<()> {
let _status_check = StatusCheck {
name: "Performance Regression Check".to_string(),
state: analysis.status,
description: analysis.summary.clone(),
target_url: self.config.artifact_path.clone().into(),
context: "cbtop/regression".to_string(),
};
let _ = pr_num;
Ok(())
}
pub fn status(&self) -> PipelineStatus {
self.status
}
pub fn elapsed(&self) -> Option<Duration> {
self.start_time.map(|t| t.elapsed())
}
pub fn config(&self) -> &PipelineConfig {
&self.config
}
pub fn history(&self) -> &[RegressionAnalysis] {
&self.history
}
pub fn clear_baseline_cache(&mut self) {
self.baseline_cache.clear();
}
pub fn store_artifact(&self, analysis: &RegressionAnalysis) -> PipelineResult<String> {
let artifact_id = format!(
"{}-{}",
analysis.current.commit, analysis.current.timestamp_ns
);
Ok(artifact_id)
}
pub fn generate_report(&self, analysis: &RegressionAnalysis) -> String {
let mut report = String::new();
report.push_str("# Performance Regression Report\n\n");
report.push_str(&format!("## Summary\n\n{}\n\n", analysis.summary));
let badge = match analysis.status {
PipelineStatus::Passed => "",
PipelineStatus::Warning => {
""
}
PipelineStatus::Failed => "",
PipelineStatus::Pending
| PipelineStatus::Running
| PipelineStatus::Cancelled
| PipelineStatus::Error => {
""
}
};
report.push_str(&format!("{}\n\n", badge));
report.push_str("## Metric Comparison\n\n");
report.push_str("| Metric | Baseline | Current | Change | Status |\n");
report.push_str("|--------|----------|---------|--------|--------|\n");
for regression in &analysis.regressions {
let status = if regression.is_regression {
"\u{274c} Regression"
} else if regression.is_warning {
"\u{26a0}\u{fe0f} Warning"
} else if regression.percent_change.abs() > 1.0 {
"\u{2705} Improved"
} else {
"\u{2796} Stable"
};
report.push_str(&format!(
"| {} | {:.2} {} | {:.2} {} | {:+.1}% | {} |\n",
regression.name,
regression.baseline_mean,
regression.unit,
regression.current_mean,
regression.unit,
regression.percent_change,
status
));
}
report.push('\n');
report.push_str("## Details\n\n");
report.push_str(&format!(
"- **Baseline commit:** {}\n",
analysis.baseline.commit
));
report.push_str(&format!(
"- **Current commit:** {}\n",
analysis.current.commit
));
report.push_str(&format!(
"- **Analysis time:** {}ms\n",
analysis.analysis_duration_ms
));
report
}
}
pub const DEFAULT_TIMEOUT_SEC: u64 = 600;
pub const DEFAULT_REGRESSION_THRESHOLD: f64 = 5.0;
pub const DEFAULT_WARNING_THRESHOLD: f64 = 2.0;
#[cfg(test)]
mod tests;