#![forbid(unsafe_code)]
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use zenbench::daemon;
#[derive(Parser)]
#[command(name = "zenbench", about = "Interleaved microbenchmarking harness")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run {
bench: String,
#[arg(short, long, default_value = ".")]
project: PathBuf,
#[arg(long)]
wait: bool,
#[arg(long, default_value = "600")]
timeout: u64,
#[arg(long)]
cargo_args: Option<String>,
},
List {
#[arg(short, long, default_value = ".")]
project: PathBuf,
},
Status {
run_id: String,
#[arg(short, long, default_value = ".")]
project: PathBuf,
},
Kill {
target: String,
#[arg(short, long, default_value = ".")]
project: PathBuf,
},
Results {
run_id: String,
#[arg(short, long, default_value = ".")]
project: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
markdown: bool,
#[arg(long)]
csv: bool,
#[arg(long, value_name = "DIR")]
save_charts: Option<PathBuf>,
#[cfg(feature = "charts")]
#[arg(long, value_name = "DIR")]
publish_charts: Option<PathBuf>,
#[cfg(feature = "charts")]
#[arg(long, default_value = "light")]
chart_theme: String,
#[cfg(feature = "charts")]
#[arg(long)]
vertical: bool,
},
Compare {
baseline: PathBuf,
candidate: PathBuf,
},
SelfCompare {
#[arg(long)]
bench: String,
#[arg(long, name = "ref")]
git_ref: Option<String>,
#[arg(long)]
cargo_args: Option<String>,
},
Clean {
#[arg(short, long, default_value = ".")]
project: PathBuf,
#[arg(long, default_value = "168")]
max_age_hours: u64,
},
Baseline {
#[command(subcommand)]
action: BaselineAction,
},
}
#[derive(Subcommand)]
enum BaselineAction {
List,
Show {
name: String,
},
Delete {
name: String,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Run {
bench,
project,
wait,
timeout,
cargo_args,
} => cmd_run(&project, &bench, wait, timeout, cargo_args.as_deref()),
Commands::List { project } => cmd_list(&project),
Commands::Status { run_id, project } => cmd_status(&project, &run_id),
Commands::Kill { target, project } => cmd_kill(&project, &target),
Commands::Results {
run_id,
project,
json,
markdown,
csv,
save_charts,
#[cfg(feature = "charts")]
publish_charts,
#[cfg(feature = "charts")]
chart_theme,
#[cfg(feature = "charts")]
vertical,
} => {
#[cfg(feature = "charts")]
let pub_config = publish_charts.as_deref().map(|d| {
let config = zenbench::charts::ChartConfig {
theme: chart_theme.clone(),
orientation: if vertical {
zenbench::charts::ChartOrientation::Vertical
} else {
zenbench::charts::ChartOrientation::Horizontal
},
..Default::default()
};
(d, config)
});
#[cfg(not(feature = "charts"))]
let pub_config: Option<(&Path, ())> = None;
cmd_results(
&project,
&run_id,
json,
markdown,
csv,
save_charts.as_deref(),
pub_config,
)
}
Commands::Compare {
baseline,
candidate,
} => cmd_compare(&baseline, &candidate),
Commands::SelfCompare {
bench,
git_ref,
cargo_args,
} => cmd_self_compare(&bench, git_ref.as_deref(), cargo_args.as_deref()),
Commands::Clean {
project,
max_age_hours,
} => cmd_clean(&project, max_age_hours),
Commands::Baseline { action } => match action {
BaselineAction::List => {
let names = zenbench::baseline::list_baselines();
if names.is_empty() {
println!("No baselines saved. Use: cargo bench -- --save-baseline=<name>");
} else {
for name in &names {
let path = format!(".zenbench/baselines/{name}.json");
let meta = std::fs::metadata(&path);
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
println!(" {name:<20} ({size} bytes)");
}
}
}
BaselineAction::Show { name } => match zenbench::baseline::load_baseline(&name) {
Ok(result) => {
println!("Baseline: {name}");
println!(" git: {}", result.git_hash.as_deref().unwrap_or("unknown"));
println!(" timestamp: {}", result.timestamp);
for comp in &result.comparisons {
println!(" group: {}", comp.group_name);
for bench in &comp.benchmarks {
println!(
" {}: mean={:.1}ns min={:.1}ns",
bench.name, bench.summary.mean, bench.summary.min
);
}
}
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
},
BaselineAction::Delete { name } => match zenbench::baseline::delete_baseline(&name) {
Ok(()) => println!("Deleted baseline '{name}'"),
Err(e) => {
eprintln!("Failed to delete baseline '{name}': {e}");
std::process::exit(1);
}
},
},
}
}
fn cmd_run(
project: &Path,
bench_name: &str,
wait: bool,
timeout_secs: u64,
cargo_args: Option<&str>,
) {
let mut args = vec!["bench", "--bench", bench_name];
let extra_args: Vec<&str>;
if let Some(extra) = cargo_args {
extra_args = extra.split_whitespace().collect();
args.extend(&extra_args);
}
let str_args: Vec<&str> = args.to_vec();
match daemon::spawn_detached(project, "cargo", &str_args) {
Ok((run_id, mut child)) => {
eprintln!("[zenbench] run {run_id} spawned");
if wait {
let _exit_status = child.wait();
eprintln!("[zenbench] waiting for completion (timeout: {timeout_secs}s)...");
match daemon::wait_for_run(
project,
&run_id,
std::time::Duration::from_secs(2),
std::time::Duration::from_secs(timeout_secs),
) {
Ok(state) => match &state.status {
daemon::RunStatus::Completed => {
eprintln!("[zenbench] run {run_id} completed");
if let Some(result_path) = &state.result_path {
match zenbench::SuiteResult::load(result_path) {
Ok(result) => result.print_report(),
Err(e) => eprintln!("Error loading results: {e}"),
}
}
}
daemon::RunStatus::Failed(msg) => {
eprintln!("[zenbench] run {run_id} failed: {msg}");
let stderr_path =
daemon::runs_dir(project).join(format!("{run_id}.stderr.log"));
if let Ok(log) = std::fs::read_to_string(&stderr_path) {
let trimmed = log.trim();
if !trimmed.is_empty() {
eprintln!("--- stderr ---");
let start = trimmed.len().saturating_sub(2000);
eprintln!("{}", &trimmed[start..]);
eprintln!("--- end stderr ---");
}
}
std::process::exit(1);
}
daemon::RunStatus::Killed => {
eprintln!("[zenbench] run {run_id} was killed");
std::process::exit(1);
}
_ => {
eprintln!(
"[zenbench] run {run_id} in unexpected state: {:?}",
state.status
);
std::process::exit(1);
}
},
Err(e) => {
eprintln!("[zenbench] error: {e}");
std::process::exit(1);
}
}
} else {
drop(child);
eprintln!("[zenbench] use `zenbench status {run_id}` to check progress");
eprintln!("[zenbench] use `zenbench results {run_id}` when complete");
}
}
Err(e) => {
eprintln!("[zenbench] error spawning benchmark: {e}");
std::process::exit(1);
}
}
}
fn cmd_list(project: &Path) {
match daemon::list_runs(project) {
Ok(runs) => {
if runs.is_empty() {
eprintln!("No benchmark runs found.");
return;
}
println!(
"{:<24} {:<12} {:<8} {:<10} COMMAND",
"ID", "STATUS", "PID", "GIT"
);
for run in runs {
let status = match &run.status {
daemon::RunStatus::Queued => "queued".to_string(),
daemon::RunStatus::Running => {
if daemon::is_process_alive(run.pid) {
"running".to_string()
} else {
"running?".to_string() }
}
daemon::RunStatus::Completed => "completed".to_string(),
daemon::RunStatus::Failed(_) => "failed".to_string(),
daemon::RunStatus::Killed => "killed".to_string(),
};
let git = run.git_hash.as_deref().unwrap_or("-");
let git_short = if git.len() > 8 { &git[..8] } else { git };
println!(
"{:<24} {:<12} {:<8} {:<10} {}",
run.id, status, run.pid, git_short, run.command
);
}
}
Err(e) => eprintln!("Error listing runs: {e}"),
}
}
fn cmd_status(project: &Path, run_id: &str) {
match daemon::load_run_state(project, run_id) {
Ok(state) => {
let alive = daemon::is_process_alive(state.pid);
println!("Run: {}", state.id);
println!("Status: {:?}", state.status);
println!(
"PID: {} ({})",
state.pid,
if alive { "alive" } else { "dead" }
);
println!("Git: {}", state.git_hash.as_deref().unwrap_or("unknown"));
println!("Command: {}", state.command);
if let Some(path) = &state.result_path {
let exists = path.exists();
println!(
"Results: {} ({})",
path.display(),
if exists { "exists" } else { "pending" }
);
}
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let elapsed = now.saturating_sub(state.started_at);
if let Some(finished) = state.finished_at {
let duration = finished.saturating_sub(state.started_at);
println!("Duration: {duration}s");
} else {
println!("Elapsed: {elapsed}s");
}
}
Err(e) => eprintln!("Error loading run {run_id}: {e}"),
}
}
fn cmd_kill(project: &Path, target: &str) {
if target == "stale" {
let hash = zenbench::platform::git_commit_hash().unwrap_or_default();
if hash.is_empty() {
eprintln!("Cannot determine current git hash. Use a specific run ID.");
return;
}
match daemon::kill_stale_runs(project, &hash) {
Ok(n) => eprintln!("Killed {n} stale run(s)."),
Err(e) => eprintln!("Error: {e}"),
}
} else {
match daemon::kill_run(project, target) {
Ok(true) => eprintln!("Killed run {target}."),
Ok(false) => eprintln!("Run {target} was not running."),
Err(e) => eprintln!("Error: {e}"),
}
}
}
fn cmd_results(
project: &Path,
run_id: &str,
json: bool,
markdown: bool,
csv: bool,
save_charts: Option<&Path>,
#[cfg(feature = "charts")] publish_config: Option<(&Path, zenbench::charts::ChartConfig)>,
#[cfg(not(feature = "charts"))] publish_config: Option<(&Path, ())>,
) {
let save_all_charts = |result: &zenbench::SuiteResult| {
if let Some(dir) = save_charts {
if let Err(e) = result.save_charts(dir) {
eprintln!("Error saving charts: {e}");
} else {
eprintln!("Charts saved to {}", dir.display());
}
}
#[cfg(feature = "charts")]
if let Some((dir, ref config)) = publish_config {
if let Err(e) = result.save_publication_charts(dir, config) {
eprintln!("Error saving publication charts: {e}");
} else {
eprintln!(
"Publication charts ({}, {:?}) saved to {}",
config.theme,
config.orientation,
dir.display()
);
}
}
#[cfg(not(feature = "charts"))]
let _ = publish_config;
};
let file_path = PathBuf::from(run_id);
if file_path.exists() && file_path.extension().is_some_and(|e| e == "json") {
match zenbench::SuiteResult::load(&file_path) {
Ok(result) => {
output_result(&result, json, markdown, csv);
save_all_charts(&result);
return;
}
Err(e) => {
eprintln!("Error loading results from {}: {e}", file_path.display());
return;
}
}
}
let actual_id = if run_id == "latest" {
match daemon::find_latest_with_results(project) {
Ok(Some(state)) => state.id,
Ok(None) => {
eprintln!("No completed runs with results found.");
return;
}
Err(e) => {
eprintln!("Error: {e}");
return;
}
}
} else {
run_id.to_string()
};
match daemon::load_run_state(project, &actual_id) {
Ok(state) => {
if let Some(result_path) = &state.result_path {
match zenbench::SuiteResult::load(result_path) {
Ok(result) => {
output_result(&result, json, markdown, csv);
save_all_charts(&result);
}
Err(e) => {
eprintln!("Error loading results from {}: {e}", result_path.display());
if !result_path.exists() {
eprintln!(
"Run {} status: {:?}. Results file does not exist.",
actual_id, state.status
);
}
}
}
} else {
eprintln!(
"Run {} has no results path (status: {:?})",
actual_id, state.status
);
}
}
Err(e) => eprintln!("Error: {e}"),
}
}
fn output_result(result: &zenbench::SuiteResult, json: bool, markdown: bool, csv: bool) {
if json {
println!("{}", serde_json::to_string_pretty(result).unwrap());
} else if markdown {
print!("{}", result.to_markdown());
} else if csv {
print!("{}", result.to_csv());
} else {
result.print_report();
}
}
fn cmd_compare(baseline_path: &Path, candidate_path: &Path) {
let baseline = match zenbench::SuiteResult::load(baseline_path) {
Ok(r) => r,
Err(e) => {
eprintln!("Error loading baseline: {e}");
return;
}
};
let candidate = match zenbench::SuiteResult::load(candidate_path) {
Ok(r) => r,
Err(e) => {
eprintln!("Error loading candidate: {e}");
return;
}
};
print_comparison(&baseline, &candidate);
}
fn cmd_self_compare(bench_name: &str, git_ref: Option<&str>, cargo_args: Option<&str>) {
let reference = match git_ref {
Some(r) => r.to_string(),
None => match find_last_version_tag() {
Some(tag) => {
eprintln!("[zenbench] comparing against tag: {tag}");
tag
}
None => {
eprintln!("Error: no version tags found and no --ref specified.");
eprintln!(" Create a tag first (git tag v0.1.0) or specify --ref <commit>");
std::process::exit(1);
}
},
};
if !git_ref_exists(&reference) {
eprintln!("Error: git ref '{reference}' does not exist.");
std::process::exit(1);
}
let current_hash = zenbench::platform::git_short_hash().unwrap_or_else(|| "HEAD".to_string());
eprintln!("[zenbench] self-compare: {reference} (baseline) vs {current_hash} (candidate)");
let tmp_dir = std::env::temp_dir().join("zenbench-self-compare");
std::fs::create_dir_all(&tmp_dir).unwrap_or_else(|e| {
eprintln!("Error creating temp dir: {e}");
std::process::exit(1);
});
let baseline_result_path = tmp_dir.join("baseline.json");
let candidate_result_path = tmp_dir.join("candidate.json");
let worktree_path = tmp_dir.join("worktree");
eprintln!("[zenbench] creating worktree at {reference}...");
if worktree_path.exists() {
run_git(&[
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
]);
}
if !run_git(&[
"worktree",
"add",
"--detach",
worktree_path.to_str().unwrap(),
&reference,
]) {
eprintln!("Error: failed to create git worktree at {reference}.");
std::process::exit(1);
}
eprintln!("[zenbench] building and running baseline ({reference})...");
let baseline_ok = run_bench_in_dir(
&worktree_path,
bench_name,
&baseline_result_path,
cargo_args,
);
eprintln!("[zenbench] building and running candidate ({current_hash})...");
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let candidate_ok =
run_bench_in_dir(¤t_dir, bench_name, &candidate_result_path, cargo_args);
eprintln!("[zenbench] cleaning up worktree...");
run_git(&[
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
]);
if !baseline_ok {
eprintln!("Error: baseline benchmark failed to produce results.");
eprintln!(" Make sure the benchmark uses zenbench::main!() macro.");
std::process::exit(1);
}
if !candidate_ok {
eprintln!("Error: candidate benchmark failed to produce results.");
std::process::exit(1);
}
let baseline = match zenbench::SuiteResult::load(&baseline_result_path) {
Ok(r) => r,
Err(e) => {
eprintln!("Error loading baseline results: {e}");
std::process::exit(1);
}
};
let candidate = match zenbench::SuiteResult::load(&candidate_result_path) {
Ok(r) => r,
Err(e) => {
eprintln!("Error loading candidate results: {e}");
std::process::exit(1);
}
};
eprintln!();
print_comparison(&baseline, &candidate);
let _ = std::fs::remove_file(&baseline_result_path);
let _ = std::fs::remove_file(&candidate_result_path);
}
fn find_last_version_tag() -> Option<String> {
let output = std::process::Command::new("git")
.args(["tag", "--sort=-version:refname", "--list", "v*"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().next().map(|s| s.trim().to_string())
}
fn git_ref_exists(git_ref: &str) -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--verify", git_ref])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn run_git(args: &[&str]) -> bool {
std::process::Command::new("git")
.args(args)
.status()
.is_ok_and(|s| s.success())
}
fn run_bench_in_dir(
dir: &Path,
bench_name: &str,
result_path: &Path,
cargo_args: Option<&str>,
) -> bool {
let mut cmd = std::process::Command::new("cargo");
cmd.current_dir(dir)
.args(["bench", "--bench", bench_name])
.env("ZENBENCH_RESULT_PATH", result_path);
let our_pid = std::process::id();
let launcher_pids = match std::env::var("ZENBENCH_LAUNCHER_PIDS") {
Ok(existing) => format!("{existing},{our_pid}"),
Err(_) => our_pid.to_string(),
};
cmd.env("ZENBENCH_LAUNCHER_PIDS", &launcher_pids);
if let Some(args) = cargo_args {
for arg in args.split_whitespace() {
cmd.arg(arg);
}
}
let status = cmd.status();
match status {
Ok(s) if s.success() => result_path.exists(),
Ok(s) => {
eprintln!(
" cargo bench exited with status {} in {}",
s,
dir.display()
);
result_path.exists() }
Err(e) => {
eprintln!(" failed to run cargo bench in {}: {e}", dir.display());
false
}
}
}
fn format_ns(ns: f64) -> String {
zenbench::format_ns(ns)
}
fn print_comparison(baseline: &zenbench::SuiteResult, candidate: &zenbench::SuiteResult) {
let base_git = baseline
.git_hash
.as_deref()
.map(|h| if h.len() > 8 { &h[..8] } else { h })
.unwrap_or("?");
let cand_git = candidate
.git_hash
.as_deref()
.map(|h| if h.len() > 8 { &h[..8] } else { h })
.unwrap_or("?");
eprintln!("═══════════════════════════════════════════════════════════════");
eprintln!(" zenbench comparison");
eprintln!(" baseline: {} (git: {})", baseline.run_id, base_git);
eprintln!(" candidate: {} (git: {})", candidate.run_id, cand_git);
eprintln!("═══════════════════════════════════════════════════════════════");
for cand_group in &candidate.comparisons {
if let Some(base_group) = baseline
.comparisons
.iter()
.find(|g| g.group_name == cand_group.group_name)
{
let is_single = cand_group.benchmarks.len() == 1
&& cand_group.benchmarks[0].name == cand_group.group_name;
if !is_single {
eprintln!();
eprintln!(" group: {}", cand_group.group_name);
eprintln!(" ───────────────────────────────────────────────────────────");
}
for cand_bench in &cand_group.benchmarks {
if let Some(base_bench) = base_group
.benchmarks
.iter()
.find(|b| b.name == cand_bench.name)
{
print_bench_diff(
&cand_bench.name,
base_bench.summary.mean,
cand_bench.summary.mean,
);
} else {
eprintln!(
" {:<30} {:>10} (new)",
cand_bench.name,
format_ns(cand_bench.summary.mean)
);
}
}
}
}
eprintln!();
eprintln!("═══════════════════════════════════════════════════════════════");
eprintln!();
}
fn print_bench_diff(name: &str, base_mean: f64, cand_mean: f64) {
let pct = if base_mean.abs() > f64::EPSILON {
(cand_mean - base_mean) / base_mean * 100.0
} else {
0.0
};
let (arrow, reset) = if pct < -1.0 {
("\x1b[32m", "\x1b[0m") } else if pct > 1.0 {
("\x1b[31m", "\x1b[0m") } else {
("", "") };
eprintln!(
" {:<30} {:>10} → {:>10} {}{:+.2}%{}",
name,
format_ns(base_mean),
format_ns(cand_mean),
arrow,
pct,
reset,
);
}
fn cmd_clean(project: &Path, max_age_hours: u64) {
let max_age_secs = max_age_hours * 3600;
match daemon::cleanup_old_runs(project, max_age_secs) {
Ok(n) => eprintln!("Cleaned up {n} old run(s)."),
Err(e) => eprintln!("Error: {e}"),
}
}
use std::time::SystemTime;