use std::fs;
use std::io;
use colored::{Color, ColoredString, Colorize};
use crate::measure::Accounting;
use crate::stats::BenchResult;
pub fn format_duration_secs(secs: f64) -> String {
if secs < 1.0 {
format!("{:.1} ms", secs * 1000.0)
} else {
format!("{secs:.2} s")
}
}
pub fn format_bytes(bytes: f64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
format!("{value:.2} {}", UNITS[unit])
}
fn accounting_note(accounting: Accounting) -> &'static str {
match accounting {
Accounting::Cgroup => "",
Accounting::Sampled => " ~",
}
}
fn pad(text: &str, width: usize) -> String {
format!("{text:<width$}")
}
pub fn print_single(result: &BenchResult) {
let note = accounting_note(result.accounting());
println!("{} {}", "Benchmark".dimmed(), result.command.bold().cyan());
println!();
metric_row(
"Time",
format!(
"{} {} {}",
format_duration_secs(result.mean_wall_secs()).bold().green(),
"\u{b1}".dimmed(),
format_duration_secs(result.stddev_wall_secs()).dimmed()
),
);
metric_row("CPU", format!("{:.1} %", result.mean_cpu_percent()).bold());
metric_row(
"CPU time",
format_duration_secs(result.mean_cpu_secs()).bold(),
);
metric_row(
"Peak memory",
with_note(format_bytes(result.mean_peak_memory_bytes()), note),
);
metric_row(
"Disk read",
with_note(format_bytes(result.mean_disk_read_bytes()), note),
);
metric_row(
"Disk write",
with_note(format_bytes(result.mean_disk_write_bytes()), note),
);
metric_row(
"Processes",
format!("{:.0}", result.mean_max_processes()).bold(),
);
metric_row(
"Threads",
format!("{:.0}", result.mean_max_threads()).bold(),
);
println!();
println!(" {}", format!("{} runs", result.runs.len()).dimmed());
if !note.is_empty() {
println!("\n{}", sampling_footnote());
}
}
fn with_note(value: String, note: &str) -> ColoredString {
if note.is_empty() {
value.bold()
} else {
format!("{value}{}", note.yellow()).bold()
}
}
fn metric_row(label: &str, value: impl std::fmt::Display) {
println!(" {} {value}", pad(label, 13).dimmed());
}
fn sampling_footnote() -> ColoredString {
"~ estimated via sampling (no cgroup v2 delegation available)"
.italic()
.dimmed()
}
const LABEL_WIDTH: usize = 14;
pub fn print_comparison(results: &[BenchResult]) {
let fastest_idx = results
.iter()
.enumerate()
.min_by(|a, b| a.1.mean_wall_secs().total_cmp(&b.1.mean_wall_secs()))
.map(|(i, _)| i)
.unwrap_or(0);
let col_width = results
.iter()
.map(|r| r.command.len())
.max()
.unwrap_or(10)
.max(12);
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
line.push_str(&"\u{2500}".repeat(LABEL_WIDTH + 2));
for _ in results {
line.push(mid);
line.push_str(&"\u{2500}".repeat(col_width + 2));
}
line.push(right);
line
};
println!("{}", border('\u{250c}', '\u{252c}', '\u{2510}').dimmed());
let header: Vec<String> = results
.iter()
.map(|r| pad(&r.command, col_width).bold().to_string())
.collect();
print_table_row(&pad("", LABEL_WIDTH), &header);
println!("{}", border('\u{251c}', '\u{253c}', '\u{2524}').dimmed());
print_row(results, col_width, "Time", Some(true), |r| {
(r.mean_wall_secs(), format_duration_secs(r.mean_wall_secs()))
});
print_row(results, col_width, "CPU", None, |r| {
(
r.mean_cpu_percent(),
format!("{:.1} %", r.mean_cpu_percent()),
)
});
print_row(results, col_width, "CPU time", Some(true), |r| {
(r.mean_cpu_secs(), format_duration_secs(r.mean_cpu_secs()))
});
print_row(results, col_width, "Peak memory", Some(true), |r| {
let value = r.mean_peak_memory_bytes();
(
value,
format!("{}{}", format_bytes(value), accounting_note(r.accounting())),
)
});
print_row(results, col_width, "Disk read", Some(true), |r| {
let value = r.mean_disk_read_bytes();
(
value,
format!("{}{}", format_bytes(value), accounting_note(r.accounting())),
)
});
print_row(results, col_width, "Disk write", Some(true), |r| {
let value = r.mean_disk_write_bytes();
(
value,
format!("{}{}", format_bytes(value), accounting_note(r.accounting())),
)
});
print_row(results, col_width, "Processes", Some(true), |r| {
let value = r.mean_max_processes();
(value, format!("{value:.0}"))
});
println!("{}", border('\u{2514}', '\u{2534}', '\u{2518}').dimmed());
let fastest = &results[fastest_idx];
println!();
println!(
"{} {}",
"\u{2713}".bold().green(),
fastest.command.bold().green()
);
for (i, r) in results.iter().enumerate() {
if i == fastest_idx {
continue;
}
let speed_ratio = r.mean_wall_secs() / fastest.mean_wall_secs().max(f64::EPSILON);
println!(
" {} than {}",
format!("{speed_ratio:.2}\u{d7} faster").bold().green(),
r.command.dimmed()
);
print_delta_line(
"memory",
fastest.mean_peak_memory_bytes(),
r.mean_peak_memory_bytes(),
);
print_delta_line(
"disk reads",
fastest.mean_disk_read_bytes(),
r.mean_disk_read_bytes(),
);
print_delta_line(
"disk writes",
fastest.mean_disk_write_bytes(),
r.mean_disk_write_bytes(),
);
}
if results
.iter()
.any(|r| r.accounting() == Accounting::Sampled)
{
println!("\n{}", sampling_footnote());
}
}
fn print_row(
results: &[BenchResult],
col_width: usize,
label: &str,
highlight: Option<bool>,
value_and_text: impl Fn(&BenchResult) -> (f64, String),
) {
let cells: Vec<(f64, String)> = results.iter().map(&value_and_text).collect();
let winner = highlight.and_then(|lower_is_better| winning_index(&cells, lower_is_better));
let winner_value = winner.map(|i| cells[i].0);
let values: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, (value, text))| {
let cell = pad(text, col_width);
if winner == Some(i) {
cell.bold().on_truecolor(20, 70, 40).to_string()
} else if let (Some(lower_is_better), Some(best)) = (highlight, winner_value) {
match severity(*value, best, lower_is_better) {
Severity::Close => cell,
Severity::Behind => cell.yellow().to_string(),
Severity::FarBehind => cell.red().to_string(),
}
} else {
cell
}
})
.collect();
print_table_row(&pad(label, LABEL_WIDTH).dimmed().to_string(), &values);
}
enum Severity {
Close,
Behind,
FarBehind,
}
fn severity(value: f64, best: f64, lower_is_better: bool) -> Severity {
if best <= 0.0 {
return if value > 0.0 {
Severity::FarBehind
} else {
Severity::Close
};
}
let fraction_behind = if lower_is_better {
(value - best) / best
} else {
(best - value) / best
};
if fraction_behind < 0.10 {
Severity::Close
} else if fraction_behind < 0.50 {
Severity::Behind
} else {
Severity::FarBehind
}
}
fn print_table_row(label_cell: &str, value_cells: &[String]) {
print!("{} {label_cell} ", "\u{2502}".dimmed());
for cell in value_cells {
print!("{} {cell} ", "\u{2502}".dimmed());
}
println!("{}", "\u{2502}".dimmed());
}
fn winning_index(cells: &[(f64, String)], lower_is_better: bool) -> Option<usize> {
if cells.len() < 2 {
return None;
}
let (best_idx, (_, best_text)) =
cells
.iter()
.enumerate()
.min_by(|(_, (a, _)), (_, (b, _))| {
let cmp = a.total_cmp(b);
if lower_is_better { cmp } else { cmp.reverse() }
})?;
let all_render_the_same = cells.iter().all(|(_, text)| text == best_text);
(!all_render_the_same).then_some(best_idx)
}
fn print_delta_line(label: &str, ours: f64, theirs: f64) {
let (text, color) = describe_delta(label, ours, theirs);
println!(" {}", text.color(color));
}
fn describe_delta(label: &str, ours: f64, theirs: f64) -> (String, Color) {
if ours <= 0.0 && theirs <= 0.0 {
return (format!("same {label}"), Color::White);
}
if theirs <= 0.0 {
return (format!("uses {label}, baseline used none"), Color::Yellow);
}
if ours <= theirs {
let percent = 100.0 * (1.0 - ours / theirs);
(format!("{percent:.0}% less {label}"), Color::Green)
} else {
(
format!("{:.2}\u{d7} more {label}", ours / theirs),
Color::Yellow,
)
}
}
pub fn export_json(results: &[BenchResult], path: &str) -> io::Result<()> {
let commands: Vec<_> = results
.iter()
.map(|r| {
serde_json::json!({
"command": r.command,
"runs": r.runs.len(),
"accounting": match r.accounting() {
Accounting::Cgroup => "cgroup",
Accounting::Sampled => "sampled",
},
"mean_wall_time_secs": r.mean_wall_secs(),
"stddev_wall_time_secs": r.stddev_wall_secs(),
"mean_cpu_time_secs": r.mean_cpu_secs(),
"mean_cpu_percent": r.mean_cpu_percent(),
"mean_peak_memory_bytes": r.mean_peak_memory_bytes(),
"mean_disk_read_bytes": r.mean_disk_read_bytes(),
"mean_disk_write_bytes": r.mean_disk_write_bytes(),
"mean_max_processes": r.mean_max_processes(),
"mean_max_threads": r.mean_max_threads(),
})
})
.collect();
let value = serde_json::json!({ "results": commands });
fs::write(path, serde_json::to_string_pretty(&value)?)
}
pub fn export_csv(results: &[BenchResult], path: &str) -> io::Result<()> {
let mut out = String::from(
"command,runs,accounting,mean_wall_time_secs,stddev_wall_time_secs,mean_cpu_time_secs,mean_cpu_percent,mean_peak_memory_bytes,mean_disk_read_bytes,mean_disk_write_bytes,mean_max_processes,mean_max_threads\n",
);
for r in results {
out.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
csv_escape(&r.command),
r.runs.len(),
match r.accounting() {
Accounting::Cgroup => "cgroup",
Accounting::Sampled => "sampled",
},
r.mean_wall_secs(),
r.stddev_wall_secs(),
r.mean_cpu_secs(),
r.mean_cpu_percent(),
r.mean_peak_memory_bytes(),
r.mean_disk_read_bytes(),
r.mean_disk_write_bytes(),
r.mean_max_processes(),
r.mean_max_threads(),
));
}
fs::write(path, out)
}
fn csv_escape(value: &str) -> String {
if value.contains(',') || value.contains('"') || value.contains('\n') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
pub fn export_markdown(results: &[BenchResult], path: &str) -> io::Result<()> {
let mut out = String::from(
"| Command | Time | CPU | CPU time | Peak memory | Disk read | Disk write | Processes |\n|---|---|---|---|---|---|---|---|\n",
);
for r in results {
out.push_str(&format!(
"| {} | {} \u{b1} {} | {:.1}% | {} | {}{} | {}{} | {}{} | {:.0} |\n",
r.command,
format_duration_secs(r.mean_wall_secs()),
format_duration_secs(r.stddev_wall_secs()),
r.mean_cpu_percent(),
format_duration_secs(r.mean_cpu_secs()),
format_bytes(r.mean_peak_memory_bytes()),
accounting_note(r.accounting()),
format_bytes(r.mean_disk_read_bytes()),
accounting_note(r.accounting()),
format_bytes(r.mean_disk_write_bytes()),
accounting_note(r.accounting()),
r.mean_max_processes(),
));
}
fs::write(path, out)
}
#[cfg(test)]
mod tests {
use super::{Severity, severity, winning_index};
#[test]
fn severity_close_under_ten_percent_behind() {
assert!(matches!(severity(105.0, 100.0, true), Severity::Close));
}
#[test]
fn severity_behind_between_ten_and_fifty_percent() {
assert!(matches!(severity(130.0, 100.0, true), Severity::Behind));
}
#[test]
fn severity_far_behind_over_fifty_percent() {
assert!(matches!(severity(260.0, 100.0, true), Severity::FarBehind));
}
#[test]
fn severity_respects_higher_is_better() {
assert!(matches!(severity(95.0, 100.0, false), Severity::Close));
assert!(matches!(severity(40.0, 100.0, false), Severity::FarBehind));
}
#[test]
fn severity_zero_baseline_treats_any_positive_value_as_far_behind() {
assert!(matches!(severity(1.0, 0.0, true), Severity::FarBehind));
assert!(matches!(severity(0.0, 0.0, true), Severity::Close));
}
#[test]
fn no_winner_when_rendered_text_matches_despite_float_noise() {
let cells = vec![
(2.97, "3".to_string()),
(3.02, "3".to_string()),
(3.0, "3".to_string()),
];
assert_eq!(winning_index(&cells, true), None);
}
#[test]
fn picks_lower_when_rendered_text_differs() {
let cells = vec![(1.19, "1.19 s".to_string()), (1.39, "1.39 s".to_string())];
assert_eq!(winning_index(&cells, true), Some(0));
}
#[test]
fn picks_higher_when_higher_is_better() {
let cells = vec![
(157.0, "157.0 %".to_string()),
(159.3, "159.3 %".to_string()),
];
assert_eq!(winning_index(&cells, false), Some(1));
}
#[test]
fn no_winner_with_a_single_column() {
let cells = vec![(1.0, "1.00 s".to_string())];
assert_eq!(winning_index(&cells, true), None);
}
}