cu-profiler-cli 0.1.2

Command-line interface for cu-profiler
//! End-to-end CLI tests: invoke the built `cu-profiler` binary in a scratch
//! directory and assert on its stdout and exit codes.

use std::path::PathBuf;
use std::process::Command;

/// Path to the compiled binary, provided by Cargo to integration tests.
const BIN: &str = env!("CARGO_BIN_EXE_cu-profiler");

fn scratch_dir(tag: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("cu-profiler-it-{}-{tag}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn run(dir: &PathBuf, args: &[&str]) -> std::process::Output {
    Command::new(BIN)
        .args(args)
        .current_dir(dir)
        .output()
        .expect("binary runs")
}

#[test]
fn init_then_run_reports_table_and_exits_zero() {
    let dir = scratch_dir("run");
    let init = run(&dir, &["init"]);
    assert!(init.status.success(), "init failed: {init:?}");

    let out = run(&dir, &["run"]);
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("swap_exact_in"), "stdout: {stdout}");
    assert!(stdout.contains("Status"));
}

#[test]
fn baseline_save_then_regression_compare_exits_one() {
    let dir = scratch_dir("regress");
    assert!(run(&dir, &["init"]).status.success());
    assert!(run(&dir, &["baseline", "save"]).status.success());

    // Introduce a regression by rewriting the swap log above budget.
    let log = dir.join(".cu/logs/swap_exact_in.log");
    let text = std::fs::read_to_string(&log)
        .unwrap()
        .replace("96812", "120000");
    std::fs::write(&log, text).unwrap();

    let out = run(&dir, &["compare"]);
    assert_eq!(
        out.status.code(),
        Some(1),
        "expected budget/regression exit code 1"
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("FAIL"));
}

#[test]
fn compare_without_baseline_is_missing_baseline_exit_four() {
    let dir = scratch_dir("nobaseline");
    assert!(run(&dir, &["init"]).status.success());
    // No `baseline save` was run, so .cu/baseline.json does not exist.
    let out = run(&dir, &["compare"]);
    assert_eq!(
        out.status.code(),
        Some(4),
        "expected missing-baseline exit code 4"
    );
}

#[test]
fn missing_config_is_config_error_exit_two() {
    let dir = scratch_dir("noconfig");
    let out = run(&dir, &["run"]);
    assert_eq!(
        out.status.code(),
        Some(2),
        "expected config error exit code 2"
    );
}

#[test]
fn json_output_then_inspect_round_trips() {
    let dir = scratch_dir("inspect");
    assert!(run(&dir, &["init"]).status.success());
    let out = run(
        &dir,
        &["run", "--format", "json", "--output", "report.json"],
    );
    assert!(out.status.success());

    let inspect = run(&dir, &["inspect", "report.json"]);
    assert!(inspect.status.success());
    let stdout = String::from_utf8_lossy(&inspect.stdout);
    assert!(stdout.contains("swap_exact_in"));
}

#[test]
fn demo_fixtures_warn_on_stderr_not_stdout() {
    let dir = scratch_dir("demo-warn");
    assert!(run(&dir, &["init"]).status.success());

    let out = run(&dir, &["run"]);
    assert!(out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stderr.contains("DEMO"),
        "expected demo warning on stderr: {stderr}"
    );
    // The report (stdout) must stay clean — no warning leaking into machine output.
    assert!(
        !stdout.contains("DEMO"),
        "warning leaked into stdout: {stdout}"
    );
}

#[test]
fn real_logs_emit_no_demo_warning() {
    let dir = scratch_dir("real-nowarn");
    assert!(run(&dir, &["init"]).status.success());
    // Replace a scaffolded log with a real (unmarked) one.
    std::fs::write(
        dir.join(".cu/logs/swap_exact_in.log"),
        "Program P invoke [1]\nProgram P consumed 1234 of 200000 compute units\nProgram P success\n",
    )
    .unwrap();
    let out = run(&dir, &["run", "--scenario", "swap_exact_in"]);
    assert!(out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("DEMO"),
        "unexpected demo warning for real logs: {stderr}"
    );
}

#[test]
fn live_mode_warns_but_still_runs() {
    let dir = scratch_dir("livemode");
    assert!(run(&dir, &["init"]).status.success());
    let cfg = dir.join("cu-profiler.toml");
    let text = std::fs::read_to_string(&cfg)
        .unwrap()
        .replace("mode = \"recorded\"", "mode = \"program-test\"");
    std::fs::write(&cfg, text).unwrap();

    let out = run(&dir, &["run"]);
    assert!(out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("program-test") && stderr.contains("not executed by the CLI"),
        "expected live-mode note on stderr: {stderr}"
    );
    // The note must not leak into the report (stdout).
    assert!(!String::from_utf8_lossy(&out.stdout).contains("not executed"));
}

#[test]
fn import_real_tx_json_then_run_measures_it() {
    let dir = scratch_dir("import");
    assert!(run(&dir, &["init"]).status.success());

    // A getTransaction-shaped JSON with real-looking logMessages (nested under
    // result.meta, like an RPC response).
    let tx = r#"{"result":{"meta":{"logMessages":[
        "Program Vote111 invoke [1]",
        "Program Vote111 consumed 4321 of 200000 compute units",
        "Program Vote111 success"
    ]}}}"#;
    std::fs::write(dir.join("tx.json"), tx).unwrap();

    let imp = run(&dir, &["import", "tx.json", "--name", "real_vote"]);
    assert!(imp.status.success(), "import failed: {imp:?}");
    assert!(dir.join(".cu/logs/real_vote.log").exists());

    // Point a scenario at the imported log and run it.
    let cfg = dir.join("cu-profiler.toml");
    let mut text = std::fs::read_to_string(&cfg).unwrap();
    text.push_str("\n[scenario.real_vote]\nbudget = 200000\n");
    std::fs::write(&cfg, text).unwrap();

    let out = run(&dir, &["run", "--scenario", "real_vote"]);
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("4,321"),
        "expected imported CU in report: {stdout}"
    );
    // Imported real logs carry no demo marker → no warning.
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("DEMO"),
        "unexpected demo warning: {stderr}"
    );
}

#[test]
fn import_rejects_path_traversal_name() {
    let dir = scratch_dir("traversal");
    assert!(run(&dir, &["init"]).status.success());
    std::fs::write(
        dir.join("tx.json"),
        r#"{"result":{"meta":{"logMessages":["Program P invoke [1]","Program P success"]}}}"#,
    )
    .unwrap();

    let out = run(&dir, &["import", "tx.json", "--name", "../../ESCAPED"]);
    assert!(!out.status.success(), "traversal name should be rejected");
    assert!(String::from_utf8_lossy(&out.stderr).contains("invalid"));
    // Nothing was written outside the logs dir.
    assert!(!dir.parent().unwrap().join("ESCAPED.log").exists());
}

#[test]
fn run_rejects_path_traversal_scenario_name() {
    let dir = scratch_dir("traversal-cfg");
    assert!(run(&dir, &["init"]).status.success());
    let cfg = dir.join("cu-profiler.toml");
    let mut text = std::fs::read_to_string(&cfg).unwrap();
    text.push_str("\n[scenario.\"../../../etc/evil\"]\nbudget = 100000\n");
    std::fs::write(&cfg, text).unwrap();

    let out = run(&dir, &["run", "--scenario", "../../../etc/evil"]);
    assert!(
        !out.status.success(),
        "traversal scenario name should be rejected"
    );
    assert!(String::from_utf8_lossy(&out.stderr).contains("invalid"));
}

/// A one-shot local HTTP/1.1 server that returns `body` to the first client.
/// Used to exercise the real `import --signature` fetch path without a live RPC.
#[cfg(feature = "remote")]
fn serve_once_http(body: &'static str) -> (String, std::thread::JoinHandle<()>) {
    use std::io::{Read, Write};
    use std::net::TcpListener;

    let listener = TcpListener::bind("127.0.0.1:0").expect("bind local server");
    let url = format!("http://{}", listener.local_addr().unwrap());
    let handle = std::thread::spawn(move || {
        if let Ok((mut stream, _)) = listener.accept() {
            let mut buf = [0u8; 4096];
            let _ = stream.read(&mut buf); // drain the request; we don't need it
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = stream.write_all(response.as_bytes());
            let _ = stream.flush();
        }
    });
    (url, handle)
}

#[cfg(feature = "remote")]
#[test]
fn import_signature_fetches_logs_over_http_then_runs() {
    let dir = scratch_dir("import-rpc");
    assert!(run(&dir, &["init"]).status.success());

    // A real getTransaction-shaped JSON-RPC response.
    let response = r#"{"jsonrpc":"2.0","id":1,"result":{"meta":{"logMessages":[
        "Program Vote111 invoke [1]",
        "Program Vote111 consumed 7777 of 200000 compute units",
        "Program Vote111 success"
    ]}}}"#;
    let (url, handle) = serve_once_http(response);

    let out = run(
        &dir,
        &[
            "import",
            "--signature",
            "TestSig1111111111111111111111111111",
            "--rpc",
            &url,
            "--name",
            "live_tx",
        ],
    );
    handle.join().unwrap();
    assert!(out.status.success(), "import --signature failed: {out:?}");
    assert!(dir.join(".cu/logs/live_tx.log").exists());

    // Profile the fetched real logs.
    let cfg = dir.join("cu-profiler.toml");
    let mut text = std::fs::read_to_string(&cfg).unwrap();
    text.push_str("\n[scenario.live_tx]\nbudget = 200000\n");
    std::fs::write(&cfg, text).unwrap();

    let report = run(&dir, &["run", "--scenario", "live_tx"]);
    assert!(report.status.success());
    let stdout = String::from_utf8_lossy(&report.stdout);
    assert!(
        stdout.contains("7,777"),
        "expected fetched CU in report: {stdout}"
    );
}

#[cfg(feature = "remote")]
#[test]
fn import_signature_reports_not_found() {
    let dir = scratch_dir("import-rpc-404");
    assert!(run(&dir, &["init"]).status.success());
    let (url, handle) = serve_once_http(r#"{"jsonrpc":"2.0","id":1,"result":null}"#);
    let out = run(
        &dir,
        &["import", "--signature", "Missing111", "--rpc", &url],
    );
    handle.join().unwrap();
    assert!(!out.status.success(), "expected failure for missing tx");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("not found"), "stderr: {stderr}");
}

#[cfg(feature = "anchor")]
#[test]
fn anchor_idl_labels_program_in_report() {
    let dir = scratch_dir("anchor");
    assert!(run(&dir, &["init"]).status.success());

    // IDL whose address matches the program ID in the example swap log.
    std::fs::write(
        dir.join("amm.idl.json"),
        r#"{"address":"SwapPRogram1111111111111111111111111111","metadata":{"name":"amm"},"instructions":[],"errors":[]}"#,
    )
    .unwrap();

    // Point the config at the IDL.
    let cfg = dir.join("cu-profiler.toml");
    let mut text = std::fs::read_to_string(&cfg).unwrap();
    text.push_str("\n[anchor]\nidl = \"amm.idl.json\"\n");
    std::fs::write(&cfg, text).unwrap();

    let out = run(
        &dir,
        &["run", "--scenario", "swap_exact_in", "--format", "json"],
    );
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("\"label\": \"amm\""),
        "IDL label missing:\n{stdout}"
    );
}