josie / alder-tools

//! alderbench — CLI entry point + arg dispatch.
//!
//! Modes: no args = TUI picker (default); `--tui` = TUI with the loaded
//! config; `--preset NAME` = TUI straight into a preset sweep;
//! `--headless` = scripted sweep, stdout only; `--smoke-workload NAME` =
//! run one workload 3s + print the verdict; `--report PATH` = print a
//! past run's summary; `--list` = enumerate workloads.

mod config;
mod inproc;
mod presets;
mod report;
mod run;
mod ui;
mod workload;

use std::io::IsTerminal;

const USAGE: &str = "\
usage: alderbench [OPTION]...

  (no option)              pick a preset or edit a custom sweep (TUI)
  --tui                    TUI picker (loaded config = Custom fallback)
  --preset NAME            TUI straight into a preset: stability | bench |
                           core-cycle
  --headless               run the configured sweep without a TUI
  --smoke-workload NAME    run one workload for 3s + print verdict/score
  --report PATH            print the summary of a past run's JSON report
  --list                   enumerate available workload names
  --version                print version and exit
  --help                   show this help";

const OPTION_FLAGS: &[&str] = &[
    "--tui",
    "--preset",
    "--headless",
    "--smoke-workload",
    "--report",
    "--list",
    "--version",
    "--help",
];

fn main() {
    let args: Vec<String> = std::env::args().collect();
    for a in &args[1..] {
        if a.starts_with('-') && !OPTION_FLAGS.contains(&a.as_str()) {
            // A leading-dash token that is a *value* (e.g. `--preset -x`)
            // is caught here too — acceptable: our values never start
            // with '-'.
            eprintln!("alderbench: unknown option '{a}'\n{USAGE}");
            std::process::exit(2);
        }
    }
    if args.iter().any(|a| a == "--help") {
        println!("{USAGE}");
        return;
    }
    if args.iter().any(|a| a == "--version") {
        println!("alderbench {}", env!("CARGO_PKG_VERSION"));
        return;
    }
    if args.iter().any(|a| a == "--list") {
        for name in presets::AVAILABLE_WORKLOADS {
            println!("{name}");
        }
        return;
    }
    if let Some(idx) = args.iter().position(|a| a == "--report") {
        match args.get(idx + 1) {
            Some(path) => report_print(path),
            None => {
                eprintln!("alderbench: --report needs a path\n{USAGE}");
                std::process::exit(2);
            }
        }
        return;
    }
    if let Some(idx) = args.iter().position(|a| a == "--smoke-workload") {
        match args.get(idx + 1) {
            Some(name) => std::process::exit(smoke_workload(name, &config::load())),
            None => {
                eprintln!("alderbench: --smoke-workload needs a name\n{USAGE}");
                std::process::exit(2);
            }
        }
    }
    if args.iter().any(|a| a == "--headless") {
        headless_run(config::load());
        return;
    }
    if let Some(idx) = args.iter().position(|a| a == "--preset") {
        match args.get(idx + 1).map(|s| s.as_str()) {
            Some("stability") => require_tty_then(|| ui::run_tui(presets::stability())),
            Some("bench") => require_tty_then(|| ui::run_tui(presets::bench())),
            Some("core-cycle") => {
                require_tty_then(|| ui::run_tui(presets::stability_core_cycle(60)));
            }
            Some(other) => {
                eprintln!("unknown preset: {other} (available: stability, bench, core-cycle)");
                std::process::exit(1);
            }
            None => {
                eprintln!("alderbench: --preset needs a name\n{USAGE}");
                std::process::exit(2);
            }
        }
        return;
    }
    if args.iter().any(|a| a == "--tui") {
        require_tty_then(|| ui::run_tui(config::load()));
        return;
    }
    // Default mode = TUI picker (lets the user choose a preset or custom).
    require_tty_then(|| ui::run_tui(config::load()));
}

/// ratatui panics without a real pty — fail with a clear message instead.
fn require_tty_then(run: impl FnOnce()) {
    if !std::io::stdout().is_terminal() {
        eprintln!(
            "alderbench: the TUI needs a real terminal (stdout is not a tty) — try --headless"
        );
        std::process::exit(1);
    }
    run();
}

/// `--report PATH`: print the human summary of a past run's JSON report.
fn report_print(path: &str) {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("alderbench: can't read {path}: {e}");
            std::process::exit(1);
        }
    };
    match report::summary_from_json(&text) {
        Some(s) => println!("{s}"),
        None => {
            eprintln!("alderbench: {path} is not an alderbench report (schema v2)");
            std::process::exit(1);
        }
    }
}

/// `--headless` mode: drive the run loop without a TUI. Prints one line
/// per workload (verdict + score) at the end. Useful for CI / scripts
/// and for verifying run.rs end-to-end before the TUI is wired.
fn headless_run(cfg: config::Config) {
    use std::thread;
    let mut run = run::Run::new(cfg);
    for (name, why) in run.skipped() {
        eprintln!("skipped: {name} ({why})");
    }
    if run.total_workloads() == 0 {
        eprintln!("no workloads to run");
        return;
    }
    let poll_ms = config::load().poll_ms;
    let poll = std::time::Duration::from_millis(poll_ms);
    let mut count = 0usize;
    loop {
        match run.tick() {
            Ok(run::TickOutcome::WorkloadStarted {
                idx,
                name,
                duration_secs,
            }) => {
                println!("[{idx}] starting {name} ({duration_secs}s)");
            }
            Ok(run::TickOutcome::WorkloadFinished {
                idx,
                name,
                verdict,
                score,
                report_path,
            }) => {
                println!("[{idx}] finished {name}: {:?}", verdict);
                println!("  score:  {}", format_score(&score));
                println!("  report: {}", report_path.display());
                count += 1;
            }
            Ok(run::TickOutcome::SweepDone) => {
                println!("sweep complete: {count} workloads");
                break;
            }
            Ok(run::TickOutcome::Idle) => {}
            Err(e) => {
                eprintln!("run error: {e}");
                break;
            }
        }
        thread::sleep(poll);
    }
}

/// One-line score formatter for the headless + smoke modes. Mirrors what
/// `report::human_summary` does for the score, but standalone so the
/// headless loop can print it without reconstructing a Report.
fn format_score(s: &workload::Score) -> String {
    use workload::Score;
    match s {
        Score::None => "(no score)".to_string(),
        Score::SevenZip {
            rating_mips,
            r_u_mips,
            usage_pct,
            compress_mips,
            decompress_mips,
        } => {
            format!(
                "rating={rating_mips} MIPS  r/u={r_u_mips}  usage={usage_pct}%  \
                 compress={compress_mips} MIPS  decompress={decompress_mips} MIPS"
            )
        }
        Score::YCruncher { tests } => {
            if tests.is_empty() {
                return "(no tests recorded)".to_string();
            }
            tests
                .iter()
                .map(|t| {
                    let gbps = t.bits_per_sec / 1e9;
                    format!(
                        "{}={} {:.2} Gb/s",
                        t.tag,
                        if t.passed { "pass" } else { "FAIL" },
                        gbps
                    )
                })
                .collect::<Vec<_>>()
                .join("  ")
        }
        Score::C2c { pairs } => {
            if pairs.is_empty() {
                return "(no pairs measured)".to_string();
            }
            let min = pairs
                .iter()
                .min_by(|x, y| x.ns_per_hop.total_cmp(&y.ns_per_hop))
                .unwrap();
            let max = pairs
                .iter()
                .max_by(|x, y| x.ns_per_hop.total_cmp(&y.ns_per_hop))
                .unwrap();
            format!(
                "{} pairs  min {:.1} ns (cpu {}<->{}), max {:.1} ns (cpu {}<->{})",
                pairs.len(),
                min.ns_per_hop,
                min.cpu_a,
                min.cpu_b,
                max.ns_per_hop,
                max.cpu_a,
                max.cpu_b
            )
        }
        Score::Dram { levels } => levels
            .iter()
            .map(|l| format!("{} KiB={:.1} ns", l.size_kb, l.ns_per_access))
            .collect::<Vec<_>>()
            .join("  "),
    }
}

fn smoke_workload(name: &str, cfg: &config::Config) -> i32 {
    use std::thread;
    use std::time::Duration;
    // Build a 3-second spec for the requested workload, regardless of the
    // configured duration — this is a smoke test, not a real run.
    let spec = config::WorkloadSpec {
        name: name.to_string(),
        duration_secs: 3,
        cores: "0-15".to_string(),
    };
    let Some(mut w) = workload::from_spec(&spec, &cfg.bin_dir) else {
        eprintln!("unknown or not-yet-wired workload: {name}");
        return 1;
    };
    println!("smoke: {}  params={}", w.name(), w.params());
    let verdict = match w.start() {
        Ok(()) => {
            // Poll is_running every 250ms; stop after the spec's duration
            // plus a 1s grace (some workloads self-exit right at the
            // boundary; the grace avoids a Stopped verdict when the
            // workload actually finished on its own).
            let ticks = spec.duration_secs * 4 + 4;
            let mut elapsed = 0u64;
            while elapsed < ticks {
                thread::sleep(Duration::from_millis(250));
                elapsed += 1;
                if !w.is_running() {
                    println!("smoke: exited after {:.1}s", elapsed as f64 / 4.0);
                    break;
                }
            }
            if w.is_running() {
                println!("smoke: still running — calling stop()");
                let _ = w.stop();
            }
            w.wait()
        }
        Err(e) => {
            println!("smoke: start failed: {e}");
            return 1;
        }
    };
    let score = w.score();
    println!("smoke: verdict = {verdict:?}");
    println!("smoke: score   = {}", format_score(&score));
    // Stopped is expected when a longer workload (7z) is cut at the smoke
    // window — only real failures fail the exit code.
    match verdict {
        workload::Verdict::Clean | workload::Verdict::Stopped => 0,
        _ => 1,
    }
}