use std::{
fs::{self, File},
io::{BufReader, BufWriter, Write},
path::{Path, PathBuf},
};
use anyhow::Context;
use chrono::Utc;
use crate::{cli::BenchCli, histogram::PERCENTAGES, report::BenchReport, util::rate};
use super::{
Baseline, BaselineMetadata, BaselineName, BenchConfig, Latency, LatencyStats, RateSummary, SCHEMA_VERSION,
SerializableReport, Summary,
};
pub fn resolve_baseline_dir(cli_dir: Option<&Path>) -> PathBuf {
if let Some(dir) = cli_dir {
return dir.to_path_buf();
}
std::env::var("RLT_BASELINE_DIR")
.map(PathBuf::from)
.or_else(|_| std::env::var("CARGO_TARGET_DIR").map(|d| PathBuf::from(d).join("rlt/baselines")))
.unwrap_or_else(|_| PathBuf::from("target/rlt/baselines"))
}
pub fn load(baseline_dir: &Path, name: &BaselineName) -> anyhow::Result<Baseline> {
let path = baseline_dir.join(format!("{}.json", name));
load_file(&path)
}
pub fn load_file(path: &Path) -> anyhow::Result<Baseline> {
let file = File::open(path).with_context(|| format!("Failed to open baseline file: {}", path.display()))?;
let reader = BufReader::new(file);
let baseline: Baseline = serde_json::from_reader(reader).with_context(|| {
format!(
"Failed to parse baseline file: {}. Expected a baseline JSON generated by --save-baseline.",
path.display()
)
})?;
#[cfg(feature = "tracing")]
if baseline.schema_version > SCHEMA_VERSION {
log::warn!(
"Baseline was created with a newer schema version ({}), attempting best-effort parsing",
baseline.schema_version
);
}
Ok(baseline)
}
pub fn save(baseline_dir: &Path, name: &BaselineName, report: &BenchReport, cli: &BenchCli) -> anyhow::Result<()> {
let name = name.as_str();
fs::create_dir_all(baseline_dir)
.with_context(|| format!("Failed to create baseline directory: {}", baseline_dir.display()))?;
let baseline = create_baseline(name, report, cli);
let path = baseline_dir.join(format!("{}.json", name));
let temp_path = baseline_dir.join(format!("{}.json.tmp", name));
{
let file = File::create(&temp_path)
.with_context(|| format!("Failed to create temporary file: {}", temp_path.display()))?;
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &baseline)
.with_context(|| format!("Failed to serialize baseline: {}", temp_path.display()))?;
writer.flush()?;
writer.get_ref().sync_all()?;
}
fs::rename(&temp_path, &path)
.with_context(|| format!("Failed to rename {} to {}", temp_path.display(), path.display()))?;
Ok(())
}
fn create_baseline(name: &str, report: &BenchReport, cli: &BenchCli) -> Baseline {
let elapsed = report.elapsed.as_secs_f64();
let counter = &report.stats.counter;
let summary = Summary {
success_ratio: report.success_ratio(),
total_time: elapsed,
concurrency: report.concurrency,
iters: RateSummary { total: counter.iters, rate: rate(counter.iters, elapsed) },
items: RateSummary { total: counter.items, rate: rate(counter.items, elapsed) },
bytes: RateSummary { total: counter.bytes, rate: rate(counter.bytes, elapsed) },
};
let latency = if report.hist.is_empty() {
None
} else {
Some(Latency {
stats: LatencyStats {
min: report.hist.min().as_secs_f64(),
max: report.hist.max().as_secs_f64(),
mean: report.hist.mean().as_secs_f64(),
median: report.hist.median().as_secs_f64(),
stdev: report.hist.stdev().as_secs_f64(),
},
percentiles: report
.hist
.percentiles(PERCENTAGES)
.map(|(p, v)| (format!("p{p}"), v.as_secs_f64()))
.collect(),
histogram: report
.hist
.quantiles()
.map(|(k, v)| (k.as_secs_f64().to_string(), v))
.collect(),
})
};
let serializable_report = SerializableReport {
summary,
latency,
status: report.status_dist.iter().map(|(k, &v)| (k.to_string(), v)).collect(),
errors: report.error_dist.iter().map(|(k, &v)| (k.clone(), v)).collect(),
};
let bench_config = BenchConfig {
concurrency: cli.concurrency.get(),
duration_secs: cli.duration.map(|d| d.as_secs_f64()),
iterations: cli.iterations.map(|n| n.get()),
warmup: cli.warmup,
#[cfg(feature = "rate_limit")]
rate_limit: cli.rate.map(|r| r.get()),
#[cfg(not(feature = "rate_limit"))]
rate_limit: None,
actual_duration_secs: elapsed,
};
Baseline {
schema_version: SCHEMA_VERSION,
metadata: BaselineMetadata {
name: name.to_string(),
created_at: Utc::now(),
rlt_version: env!("CARGO_PKG_VERSION").to_string(),
bench_config,
},
report: serializable_report,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::baseline::BaselineName;
#[test]
fn test_baseline_name_valid() {
assert!("v1.0".parse::<BaselineName>().is_ok());
assert!("main".parse::<BaselineName>().is_ok());
assert!("feature-branch".parse::<BaselineName>().is_ok());
assert!("release_2.0".parse::<BaselineName>().is_ok());
assert!("test123".parse::<BaselineName>().is_ok());
assert!("a".parse::<BaselineName>().is_ok());
}
#[test]
fn test_baseline_name_invalid() {
assert!("".parse::<BaselineName>().is_err());
assert!("foo/bar".parse::<BaselineName>().is_err());
assert!("../escape".parse::<BaselineName>().is_err());
assert!("name with spaces".parse::<BaselineName>().is_err());
assert!("special@char".parse::<BaselineName>().is_err());
}
#[test]
fn test_resolve_baseline_dir_cli_override() {
let cli_dir = PathBuf::from("/custom/path");
let dir = resolve_baseline_dir(Some(&cli_dir));
assert_eq!(dir, cli_dir);
}
}