use std::path::{Path, PathBuf};
pub(crate) const DEFAULT_BASELINE_PATH: &str = "benchmarks/RESULTS.md";
pub(crate) const REGRESSION_PCT: f64 = 50.0;
#[derive(Debug)]
pub(crate) struct BaselineEntry {
pub(crate) name: String,
pub(crate) avg_us: f64,
}
pub(crate) fn default_baseline_path() -> PathBuf {
PathBuf::from(DEFAULT_BASELINE_PATH)
}
pub(crate) fn load_baseline_from(path: &Path) -> Result<Vec<BaselineEntry>, String> {
if !path.exists() {
return Err(format!(
"--compare: no baseline at {} — run `forjar bench` and record its table there first",
path.display()
));
}
let content = std::fs::read_to_string(path)
.map_err(|e| format!("--compare: cannot read baseline {}: {e}", path.display()))?;
let entries = parse_baseline_table(&content);
if entries.is_empty() {
return Err(format!(
"--compare: baseline {} has no parseable rows — expected a markdown table between \
<!-- BENCH-TABLE-START --> and <!-- BENCH-TABLE-END --> with columns \
| Operation | Target | Last Run | p50 | p95 | Status |",
path.display()
));
}
Ok(entries)
}
fn is_baseline_data_row(line: &str) -> bool {
line.starts_with('|') && !line.contains("---") && !line.contains("Operation")
}
fn parse_baseline_row(line: &str) -> Option<BaselineEntry> {
let cols: Vec<&str> = line.split('|').collect();
if cols.len() < 5 {
return None;
}
Some(BaselineEntry {
name: cols[1].trim().to_string(),
avg_us: parse_duration_to_us(cols[3].trim())?,
})
}
pub(crate) fn parse_baseline_table(content: &str) -> Vec<BaselineEntry> {
let mut entries = Vec::new();
let mut in_table = false;
for line in content.lines() {
if line.contains("BENCH-TABLE-START") {
in_table = true;
continue;
}
if line.contains("BENCH-TABLE-END") {
break;
}
if in_table && is_baseline_data_row(line) {
entries.extend(parse_baseline_row(line));
}
}
entries
}
pub(crate) fn parse_duration_to_us(s: &str) -> Option<f64> {
let s = s.trim();
if let Some(v) = s.strip_suffix("µs").or_else(|| s.strip_suffix("us")) {
v.trim().parse().ok()
} else if let Some(v) = s.strip_suffix("ms") {
v.trim().parse::<f64>().ok().map(|v| v * 1000.0)
} else if let Some(v) = s.strip_suffix('s') {
v.trim().parse::<f64>().ok().map(|v| v * 1_000_000.0)
} else {
None
}
}
#[derive(Debug)]
pub(crate) struct BenchOutcome {
pub(crate) passed: usize,
pub(crate) total: usize,
pub(crate) regressions: Vec<String>,
}
pub(crate) fn bench_verdict(outcome: &BenchOutcome) -> Result<(), String> {
if !outcome.regressions.is_empty() {
return Err(format!(
"benchmark regression vs baseline: {}",
outcome.regressions.join("; ")
));
}
if outcome.passed < outcome.total {
return Err(format!(
"{}/{} benchmark targets met",
outcome.passed, outcome.total
));
}
Ok(())
}
#[cfg(test)]
pub(crate) fn bench_routed(result: &Result<(), String>) -> bool {
match result {
Ok(()) => true,
Err(e) => e.contains("targets met"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD_TABLE: &str = "<!-- BENCH-TABLE-START -->\n\
| Operation | Target | Last Run | p50 | p95 | Status |\n\
|---|---|---|---|---|---|\n\
| validate (3m, 20r) | < 10ms | 148.7µs | 139.0µs | 214.0µs | pass |\n\
<!-- BENCH-TABLE-END -->\n";
#[test]
fn missing_baseline_is_refused_not_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
let err = load_baseline_from(&tmp.path().join("nope.md")).expect_err("must refuse");
assert!(err.contains("no baseline"), "{err}");
}
#[test]
fn unparseable_baseline_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let p = tmp.path().join("RESULTS.md");
std::fs::write(&p, "| validate (3m, 20r) | 1.0µs |\n").expect("write");
let err = load_baseline_from(&p).expect_err("must refuse");
assert!(err.contains("no parseable rows"), "{err}");
}
#[test]
fn directory_baseline_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let p = tmp.path().join("RESULTS.md");
std::fs::create_dir(&p).expect("mkdir");
assert!(load_baseline_from(&p).is_err());
}
#[test]
fn good_baseline_parses() {
let tmp = tempfile::tempdir().expect("tempdir");
let p = tmp.path().join("RESULTS.md");
std::fs::write(&p, GOOD_TABLE).expect("write");
let entries = load_baseline_from(&p).expect("parse");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "validate (3m, 20r)");
assert!((entries[0].avg_us - 148.7).abs() < 1e-6);
}
#[test]
fn verdict_fails_on_unmet_targets() {
let outcome = BenchOutcome {
passed: 0,
total: 6,
regressions: Vec::new(),
};
let err = bench_verdict(&outcome).expect_err("0/6 must not be success");
assert!(err.contains("0/6"), "{err}");
}
#[test]
fn verdict_fails_on_regression() {
let outcome = BenchOutcome {
passed: 6,
total: 6,
regressions: vec!["validate (3m, 20r) +18000%".to_string()],
};
assert!(bench_verdict(&outcome).is_err());
}
#[test]
fn verdict_passes_when_all_targets_met() {
let outcome = BenchOutcome {
passed: 6,
total: 6,
regressions: Vec::new(),
};
assert!(bench_verdict(&outcome).is_ok());
}
#[test]
fn duration_parser_handles_units() {
assert_eq!(parse_duration_to_us("2s"), Some(2_000_000.0));
assert_eq!(parse_duration_to_us("1.5ms"), Some(1500.0));
assert_eq!(parse_duration_to_us("3µs"), Some(3.0));
assert_eq!(parse_duration_to_us("nope"), None);
}
}