powerio-cli 0.5.0

Command line interface and ratatui TUI for powerio: parse and convert power system case files and emit matrices.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

fn bin() -> &'static str {
    env!("CARGO_BIN_EXE_powerio")
}

fn repo_file(path: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(path)
}

fn run(args: &[&str]) -> Output {
    Command::new(bin()).args(args).output().unwrap()
}

fn assert_success(out: &Output) {
    assert!(
        out.status.success(),
        "expected success\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
}

fn assert_failure(out: &Output) {
    assert!(
        !out.status.success(),
        "expected failure\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn help_lists_user_facing_commands() {
    let out = run(&["--help"]);
    assert_success(&out);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("Convert a case file"), "{stdout}");
    assert!(stdout.contains("summary"), "{stdout}");
    assert!(stdout.contains("gridfm"), "{stdout}");
}

#[test]
fn convert_to_stdout_keeps_text_on_stdout() {
    let case = repo_file("tests/data/case9.m");
    let out = run(&[
        "convert",
        case.to_str().unwrap(),
        "--to",
        "matpower",
        "-o",
        "-",
    ]);
    assert_success(&out);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("mpc.bus = ["), "{stdout}");
    assert!(
        !String::from_utf8_lossy(&out.stderr).contains("wrote "),
        "stdout target should not report a file write: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn convert_overwrites_existing_text_output() {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let out_path = std::env::temp_dir().join(format!("powerio-cli-convert-{stamp}.m"));
    std::fs::write(&out_path, "sentinel").unwrap();

    let case = repo_file("tests/data/case9.m");
    let out = run(&[
        "convert",
        case.to_str().unwrap(),
        "--to",
        "matpower",
        "-o",
        out_path.to_str().unwrap(),
    ]);
    assert_success(&out);

    let text = std::fs::read_to_string(&out_path).unwrap();
    assert!(text.contains("mpc.bus = ["), "{text}");
    assert!(!text.contains("sentinel"), "{text}");

    let _ = std::fs::remove_file(out_path);
}

#[test]
fn summary_outputs_machine_readable_json() {
    let case = repo_file("tests/data/case9.m");
    let out = run(&["summary", case.to_str().unwrap()]);
    assert_success(&out);
    let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(value["schema"], "powerio.summary");
    assert_eq!(value["domain"], "transmission");
    assert_eq!(value["elements"]["buses"], 9);
    assert_eq!(value["topology"]["connected_components"], 1);
}

#[test]
fn package_overwrites_existing_output_file() {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let out_path = std::env::temp_dir().join(format!("powerio-cli-package-{stamp}.pio.json"));
    std::fs::write(&out_path, "sentinel").unwrap();

    let case = repo_file("tests/data/case9.m");
    let out = run(&[
        "package",
        case.to_str().unwrap(),
        "-o",
        out_path.to_str().unwrap(),
    ]);
    assert_success(&out);

    let text = std::fs::read_to_string(&out_path).unwrap();
    let value: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert_eq!(
        value["schema"],
        "https://powerio.dev/schema/pio-package/0.1"
    );
    assert!(!text.contains("sentinel"), "{text}");

    let _ = std::fs::remove_file(out_path);
}

#[test]
fn batch_writes_requested_matrices_rhs_and_metadata() {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let out_dir = std::env::temp_dir().join(format!("powerio-cli-batch-{stamp}"));

    let case = repo_file("tests/data/case9.m");
    let out = run(&[
        "batch",
        "-i",
        case.to_str().unwrap(),
        "-o",
        out_dir.to_str().unwrap(),
        "--matrices",
        "ybus_real,ybus_imag",
        "--rhs",
        "injection",
        "--scheme",
        "xb",
    ]);
    assert_success(&out);

    for name in [
        "case9_ybus_real.mtx",
        "case9_ybus_imag.mtx",
        "case9_ybus_real_rhs.mtx",
        "case9_ybus_imag_rhs.mtx",
        "case9_shunt.mtx",
        "case9_meta.json",
    ] {
        assert!(out_dir.join(name).is_file(), "missing {name}");
    }

    let meta: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(out_dir.join("case9_meta.json")).unwrap())
            .unwrap();
    assert_eq!(meta["case_name"], "case9");
    assert_eq!(meta["n_buses"], 9);
    assert_eq!(meta["n_branches"], 9);
    assert_eq!(meta["matrices"].as_array().unwrap().len(), 2);
    assert_eq!(meta["matrices"][0]["kind"], "ybus_real");
    assert_eq!(meta["matrices"][1]["kind"], "ybus_imag");
    assert!(meta["source_sha256"].as_str().unwrap().len() == 64);

    let _ = std::fs::remove_dir_all(out_dir);
}

#[test]
fn pypsa_directory_target_requires_output_directory() {
    let case = repo_file("tests/data/case9.m");
    let out = run(&["convert", case.to_str().unwrap(), "--to", "pypsa-csv"]);
    assert_failure(&out);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("requires `-o <output-dir>`"),
        "stderr:\n{stderr}"
    );
}

#[test]
fn family_mismatch_exits_before_writing_output() {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let out_path = std::env::temp_dir().join(format!("powerio-cli-family-{stamp}.m"));
    let case = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
    let out = run(&[
        "convert",
        case.to_str().unwrap(),
        "--to",
        "matpower",
        "-o",
        out_path.to_str().unwrap(),
    ]);
    assert_failure(&out);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("no conversion path"), "stderr:\n{stderr}");
    assert!(
        !out_path.exists(),
        "failed conversion wrote {}",
        out_path.display()
    );
}