hyperfoot 0.2.0

Benchmark the resource footprint of commands
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 => " ~",
    }
}

/// Pads plain text to `width` *before* colorizing, since ANSI escape codes
/// would otherwise be counted as visible characters by `{:<width$}` and
/// throw off column alignment.
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)
        + 3;

    print!("{}", pad("", LABEL_WIDTH));
    for r in results {
        print!("{}", pad(&r.command, col_width).bold());
    }
    println!();
    println!(
        "{}",
        "\u{2500}"
            .repeat(LABEL_WIDTH + col_width * results.len())
            .dimmed()
    );

    // `highlight: Some(lower_is_better)` marks a metric where one column is
    // objectively better, so its winning cell (not necessarily the overall
    // fastest command's column) gets a background tint. `None` leaves a
    // metric like CPU% unhighlighted since more or less isn't inherently
    // better or worse.
    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}"))
    });

    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());
    }
}

/// Prints one metric row, tinting the background of whichever column wins
/// that specific metric (not necessarily the same column that's fastest
/// overall). `highlight` is `Some(lower_is_better)` for metrics where one
/// direction is objectively better, or `None` for metrics like CPU% where
/// neither direction is inherently good or bad. Ties (including a row where
/// every command scored identically) are left unhighlighted.
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));

    print!("{}", pad(label, LABEL_WIDTH).dimmed());
    for (i, (_, text)) in cells.iter().enumerate() {
        let cell = pad(text, col_width);
        if winner == Some(i) {
            print!("{}", cell.bold().on_truecolor(20, 70, 40));
        } else {
            print!("{cell}");
        }
    }
    println!();
}

fn winning_index(cells: &[(f64, String)], lower_is_better: bool) -> Option<usize> {
    let (min, max) = cells
        .iter()
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (v, _)| {
            (lo.min(*v), hi.max(*v))
        });
    if cells.len() < 2 || min >= max {
        return None;
    }
    let target = if lower_is_better { min } else { max };
    cells.iter().position(|(v, _)| *v == target)
}

/// Prints one comparison bullet, colored green when the baseline is ahead
/// (uses less) and yellow when it's behind (uses more) — always phrased as
/// a bounded percentage or an explicit multiplier, never both mixed.
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)
}