greentic-component 0.5.2

High-level component loader and store for Greentic components
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use assert_cmd::Command;
use greentic_component::cmd::component_world::canonical_component_world;
use serde_json::Value;

const ARTIFACT_ROOT: &str = "target/contract-artifacts";

pub struct WorldContract {
    pub id: &'static str,
    pub fixture_dir: PathBuf,
    pub operation: &'static str,
}

pub fn registry() -> Vec<WorldContract> {
    vec![WorldContract {
        id: canonical_component_world(),
        fixture_dir: PathBuf::from("tests/contract/fixtures/component_v0_6_0"),
        operation: "handle_message",
    }]
}

pub fn run_contract_suite(world: &WorldContract) {
    let valid_inputs = load_inputs(&world.fixture_dir.join("valid_inputs"));
    let invalid_inputs = load_inputs(&world.fixture_dir.join("invalid_inputs"));
    for (name, input) in valid_inputs.iter() {
        run_case(world, name, input, false);
        for (idx, mutated) in mutate_inputs(input).into_iter().enumerate() {
            let case_name = format!("{name}-mutated-{idx}");
            run_case(world, &case_name, &mutated, true);
        }
    }
    for (name, input) in invalid_inputs.iter() {
        run_case(world, name, input, true);
    }
}

fn run_case(world: &WorldContract, name: &str, input: &Value, expects_invalid: bool) {
    let output = run_harness_once(world, input);
    let status = output
        .get("status")
        .and_then(|value| value.as_str())
        .unwrap_or("unknown");
    let diagnostics = output
        .get("diagnostics")
        .and_then(|value| value.as_array())
        .cloned()
        .unwrap_or_default();

    if !expects_invalid && status != "ok" {
        write_artifacts(world, name, input, &output);
        panic!("expected status ok for {}, got {status}", world.id);
    }
    if expects_invalid {
        match status {
            "error" => {
                if diagnostics.is_empty() {
                    write_artifacts(world, name, input, &output);
                    panic!("expected diagnostics for error case {} {}", world.id, name);
                }
            }
            "ok" => {
                if output.get("result").is_none() {
                    write_artifacts(world, name, input, &output);
                    panic!(
                        "expected result payload for non-failing invalid case {} {}",
                        world.id, name
                    );
                }
            }
            _ => {
                write_artifacts(world, name, input, &output);
                panic!(
                    "unexpected status '{status}' for invalid case {} {}",
                    world.id, name
                );
            }
        }
    }
    let diag_size = serde_json::to_string(&diagnostics)
        .map(|s| s.len())
        .unwrap_or(0);
    if diag_size > 64 * 1024 {
        write_artifacts(world, name, input, &output);
        panic!(
            "diagnostics too large ({diag_size} bytes) for {} {}",
            world.id, name
        );
    }
}

pub fn run_harness_once(world: &WorldContract, input: &Value) -> Value {
    let wasm_path = world.fixture_dir.join("component.wasm");
    let manifest_path = world.fixture_dir.join("component.manifest.json");
    let temp = tempfile::TempDir::new().expect("temp dir");
    let input_path = temp.path().join("input.json");
    fs::write(
        &input_path,
        serde_json::to_string(input).expect("input json"),
    )
    .expect("write input");

    let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("greentic-component"));
    cmd.arg("test")
        .arg("--wasm")
        .arg(&wasm_path)
        .arg("--manifest")
        .arg(&manifest_path)
        .arg("--op")
        .arg(world.operation)
        .arg("--input")
        .arg(&input_path);

    let output = cmd.output().expect("run greentic-component test");
    let stdout = String::from_utf8_lossy(&output.stdout);
    serde_json::from_str(&stdout).unwrap_or_else(|_| {
        serde_json::json!({
            "status": "error",
            "diagnostics": [{
                "severity": "error",
                "code": "contract.parse",
                "message": "failed to parse harness output"
            }],
            "raw": stdout,
        })
    })
}

fn load_inputs(dir: &Path) -> Vec<(String, Value)> {
    let mut cases = Vec::new();
    if !dir.exists() {
        return cases;
    }
    for entry in fs::read_dir(dir).expect("read input dir") {
        let entry = entry.expect("input entry");
        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
            continue;
        }
        let path = entry.path();
        let name = path
            .file_stem()
            .and_then(|stem| stem.to_str())
            .unwrap_or("input")
            .to_string();
        let contents = fs::read_to_string(&path).expect("input file");
        let value: Value = serde_json::from_str(&contents).expect("input json");
        cases.push((name, value));
    }
    cases
}

fn mutate_inputs(input: &Value) -> Vec<Value> {
    let mut mutations = Vec::new();
    mutations.push(Value::Null);
    mutations.push(Value::Array(Vec::new()));
    if let Value::Object(map) = input {
        let mut removed = map.clone();
        if let Some(first_key) = removed.keys().next().cloned() {
            removed.remove(&first_key);
            mutations.push(Value::Object(removed));
        }
        let mut wrong_type = map.clone();
        wrong_type.insert("unexpected".to_string(), Value::Bool(true));
        mutations.push(Value::Object(wrong_type));
        let mut extra = map.clone();
        extra.insert("extra_field".to_string(), Value::String("noise".into()));
        mutations.push(Value::Object(extra));
    }
    mutations
}

fn write_artifacts(world: &WorldContract, name: &str, input: &Value, output: &Value) {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|dur| dur.as_secs())
        .unwrap_or(0);
    let sanitized_world = world.id.replace([':', '/', '@'], "_");
    let dir = PathBuf::from(ARTIFACT_ROOT)
        .join(sanitized_world)
        .join(format!("{timestamp}-{name}"));
    if fs::create_dir_all(&dir).is_err() {
        return;
    }
    let _ = fs::write(
        dir.join("input.json"),
        serde_json::to_string_pretty(input).unwrap(),
    );
    let _ = fs::write(dir.join("config.json"), "{}");
    let _ = fs::write(dir.join("secrets.json"), "{}");
    let _ = fs::write(
        dir.join("output.json"),
        serde_json::to_string_pretty(output).unwrap(),
    );
}