hopper-cli 0.2.1

Command-line tooling for Hopper account inspection, schema export, and migration planning
use std::fs;
use std::path::PathBuf;
use std::process;

use hopper_schema::ProgramManifest;

pub fn cmd_test_gen(args: &[String]) {
    if args.first().map(String::as_str) != Some("security") {
        usage_and_exit();
    }
    let mut program_arg = None;
    let mut out_path = PathBuf::from("tests/hopper_security_matrix.rs");
    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--program" => {
                i += 1;
                if i >= args.len() {
                    usage_and_exit();
                }
                program_arg = Some(args[i].clone());
            }
            "--out" => {
                i += 1;
                if i >= args.len() {
                    usage_and_exit();
                }
                out_path = PathBuf::from(&args[i]);
            }
            "--help" | "-h" => usage_and_exit(),
            other => {
                eprintln!("Unknown test-gen argument: {other}");
                usage_and_exit();
            }
        }
        i += 1;
    }
    let program_arg = program_arg.unwrap_or_else(|| usage_and_exit());
    let manifest = crate::load_program_manifest(&program_arg);
    if let Some(parent) = out_path.parent() {
        fs::create_dir_all(parent).unwrap_or_else(|err| {
            eprintln!("Failed to create {}: {err}", parent.display());
            process::exit(1);
        });
    }
    fs::write(&out_path, security_matrix(&manifest)).unwrap_or_else(|err| {
        eprintln!("Failed to write {}: {err}", out_path.display());
        process::exit(1);
    });
    println!(
        "Generated security test matrix for {} at {}",
        manifest.name,
        out_path.display()
    );
}

fn usage_and_exit() -> ! {
    eprintln!("Usage: hopper test-gen security --program <manifest> [--out tests/hopper_security_matrix.rs]");
    process::exit(1);
}

fn security_matrix(manifest: &ProgramManifest) -> String {
    let mut out = String::new();
    out.push_str("//! Generated by `hopper test-gen security`.\n");
    out.push_str("//! Fill the fixture builders with your local program-test harness.\n\n");
    out.push_str("#[derive(Clone, Copy, Debug, PartialEq, Eq)]\n");
    out.push_str("enum SecurityCase { MissingSigner, WrongOwner, WrongPda, WrongLayout, NonWritableMut, WrongTokenExtension }\n\n");
    out.push_str("#[test]\nfn generated_security_matrix_is_not_empty() {\n");
    out.push_str("    let cases = security_cases();\n    assert!(!cases.is_empty());\n}\n\n");
    out.push_str("fn security_cases() -> &'static [(&'static str, SecurityCase)] {\n");
    out.push_str("    &[\n");
    for ix in manifest.instructions {
        let base = sanitize(ix.name);
        out.push_str(&format!(
            "        (\"{base}:missing-signer\", SecurityCase::MissingSigner),\n"
        ));
        out.push_str(&format!(
            "        (\"{base}:wrong-owner\", SecurityCase::WrongOwner),\n"
        ));
        out.push_str(&format!(
            "        (\"{base}:wrong-pda\", SecurityCase::WrongPda),\n"
        ));
        out.push_str(&format!(
            "        (\"{base}:wrong-layout\", SecurityCase::WrongLayout),\n"
        ));
        out.push_str(&format!(
            "        (\"{base}:non-writable-mut\", SecurityCase::NonWritableMut),\n"
        ));
        if ix
            .capabilities
            .iter()
            .any(|cap| cap.contains("token") || cap.contains("Token"))
        {
            out.push_str(&format!(
                "        (\"{base}:wrong-token-extension\", SecurityCase::WrongTokenExtension),\n"
            ));
        }
    }
    out.push_str("    ]\n}\n");
    out
}

fn sanitize(value: &str) -> String {
    value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '_' {
                ch
            } else {
                '-'
            }
        })
        .collect()
}