perfgate-cli 0.18.1

CLI for perfgate performance budgets and baseline diffs
//! Integration tests for `perfgate calibrate`.

use std::fs;
use tempfile::tempdir;

mod common;
use common::perfgate_cmd;

fn write_config(root: &std::path::Path) -> std::path::PathBuf {
    let config_path = root.join("perfgate.toml");
    fs::write(
        &config_path,
        r#"
[defaults]
repeat = 5
warmup = 0
threshold = 0.20
out_dir = "artifacts/perfgate"

[[bench]]
name = "parser"
command = ["echo", "parser"]
"#,
    )
    .expect("write config");
    config_path
}

fn write_run_receipt(path: &std::path::Path, bench_name: &str, mean: f64, stddev: f64) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create receipt parent");
    }
    let receipt = serde_json::json!({
        "schema": "perfgate.run.v1",
        "tool": {
            "name": "perfgate",
            "version": "0.18.0"
        },
        "run": {
            "id": "run-id",
            "started_at": "2026-01-01T00:00:00Z",
            "ended_at": "2026-01-01T00:00:01Z",
            "host": {
                "os": "linux",
                "arch": "x86_64"
            }
        },
        "bench": {
            "name": bench_name,
            "command": ["echo", "parser"],
            "repeat": 3,
            "warmup": 0
        },
        "samples": [
            {"wall_ms": 100, "exit_code": 0, "warmup": false, "timed_out": false},
            {"wall_ms": 104, "exit_code": 0, "warmup": false, "timed_out": false},
            {"wall_ms": 96, "exit_code": 0, "warmup": false, "timed_out": false}
        ],
        "stats": {
            "wall_ms": {
                "median": 100,
                "min": 96,
                "max": 104,
                "mean": mean,
                "stddev": stddev
            }
        }
    });
    fs::write(path, serde_json::to_string_pretty(&receipt).unwrap()).expect("write receipt");
}

#[test]
fn calibrate_suggests_thresholds_from_existing_run_receipt() {
    let temp_dir = tempdir().expect("temp dir");
    let config_path = write_config(temp_dir.path());
    let out_dir = temp_dir.path().join("artifacts").join("perfgate");
    let run_path = out_dir.join("parser").join("run.json");
    write_run_receipt(&run_path, "parser", 100.0, 4.0);
    write_run_receipt(
        &temp_dir.path().join("baselines").join("parser.json"),
        "parser",
        100.0,
        4.0,
    );
    let original_config = fs::read_to_string(&config_path).expect("read config");

    let mut cmd = perfgate_cmd();
    cmd.current_dir(temp_dir.path())
        .arg("calibrate")
        .arg("--config")
        .arg(&config_path)
        .arg("--bench")
        .arg("parser");

    let output = cmd.output().expect("run calibrate");
    assert!(
        output.status.success(),
        "calibrate should succeed: stderr {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Bench: parser"), "stdout: {stdout}");
    assert!(
        stdout.contains("Samples: 3 measured samples"),
        "stdout: {stdout}"
    );
    assert!(stdout.contains("CV: 4.0%"), "stdout: {stdout}");
    assert!(
        stdout.contains("Suggested fail threshold: 10.0%"),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("Suggested warn threshold: 5.0%"),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("noise_policy = \"warn\""),
        "stdout: {stdout}"
    );
    assert!(stdout.contains("run with --emit-patch"), "stdout: {stdout}");
    assert!(
        stdout.contains("Advisory only: no config was written."),
        "stdout: {stdout}"
    );
    assert_eq!(
        fs::read_to_string(&config_path).expect("read config after calibrate"),
        original_config,
        "calibrate must not edit config in the advisory-only version"
    );
}

#[test]
fn calibrate_emit_patch_prints_reviewable_toml_without_editing_config() {
    let temp_dir = tempdir().expect("temp dir");
    let config_path = write_config(temp_dir.path());
    let out_dir = temp_dir.path().join("artifacts").join("perfgate");
    let run_path = out_dir.join("parser").join("run.json");
    write_run_receipt(&run_path, "parser", 100.0, 4.0);
    let original_config = fs::read_to_string(&config_path).expect("read config");

    let mut cmd = perfgate_cmd();
    cmd.current_dir(temp_dir.path())
        .arg("calibrate")
        .arg("--config")
        .arg(&config_path)
        .arg("--bench")
        .arg("parser")
        .arg("--emit-patch");

    let output = cmd.output().expect("run calibrate");
    assert!(
        output.status.success(),
        "calibrate should succeed: stderr {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Reviewable TOML patch:"),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("# Suggested from 3 measured samples on linux-x86_64."),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("# CV: 4.0%; review before applying"),
        "stdout: {stdout}"
    );
    assert!(stdout.contains("repeat = 10"), "stdout: {stdout}");
    assert!(stdout.contains("Reasons:"), "stdout: {stdout}");
    assert!(stdout.contains("When not to apply:"), "stdout: {stdout}");
    assert!(
        stdout.contains("benchmark is not the workload reviewers want to gate"),
        "stdout: {stdout}"
    );
    assert_eq!(
        fs::read_to_string(&config_path).expect("read config after calibrate"),
        original_config,
        "calibrate --emit-patch must not edit config"
    );
}

#[test]
fn calibrate_handles_missing_run_receipt_with_next_check_command() {
    let temp_dir = tempdir().expect("temp dir");
    let config_path = write_config(temp_dir.path());

    let mut cmd = perfgate_cmd();
    cmd.current_dir(temp_dir.path())
        .arg("calibrate")
        .arg("--config")
        .arg(&config_path)
        .arg("--bench")
        .arg("parser");

    let output = cmd.output().expect("run calibrate");
    assert!(
        output.status.success(),
        "calibrate should succeed without receipts: stderr {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Samples: unavailable"), "stdout: {stdout}");
    assert!(stdout.contains("CV: unavailable"), "stdout: {stdout}");
    assert!(
        stdout.contains("perfgate check --config"),
        "stdout should include the next check command: {stdout}"
    );
    assert!(
        stdout.contains("Advisory only: no config was written."),
        "stdout: {stdout}"
    );
}