mabi-cli 1.7.0

Mabinogion - Industrial Protocol Simulator CLI
Documentation
use serde::Deserialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

#[derive(Debug, Deserialize)]
struct LocalRunnerContract {
    schema_version: u16,
    contract_version: String,
    envelope_version: String,
    ownership_boundary: OwnershipBoundary,
    machine_output: MachineOutput,
    exit_code_policy: Vec<ExitCodePolicy>,
    runner_facing_commands: Vec<RunnerFacingCommand>,
    contract_versions_reported_by_version: Vec<String>,
    future_trial_run_execution_spec: FutureTrialRunExecutionSpec,
}

#[derive(Debug, Deserialize)]
struct OwnershipBoundary {
    mabinogion_owns: Vec<String>,
    not_owned_by_mabinogion: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct MachineOutput {
    formats: Vec<String>,
    required_envelope_fields: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct ExitCodePolicy {
    code: i32,
    category: String,
}

#[derive(Debug, Deserialize)]
struct RunnerFacingCommand {
    command: String,
    stable_machine_output: bool,
}

#[derive(Debug, Deserialize)]
struct FutureTrialRunExecutionSpec {
    command_status: String,
    command: String,
    owner_of_trial_definition: String,
    evidence_output_contract: String,
    artifact_contract: String,
    required_fields: Vec<String>,
}

fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("workspace root should resolve")
}

fn load_contract() -> LocalRunnerContract {
    let path = repo_root().join("docs/cli/local-runner-contract.yaml");
    let contents = fs::read_to_string(&path)
        .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
    serde_yaml::from_str(&contents).expect("local runner contract YAML should parse")
}

fn mabi(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_mabi"))
        .args(args)
        .output()
        .expect("mabi command should run")
}

fn parse_stdout(output: &Output) -> Value {
    serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
        panic!(
            "stdout should be JSON: {error}\nstdout:\n{}\nstderr:\n{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        )
    })
}

#[test]
fn local_runner_contract_yaml_defines_runner_boundary_and_envelope() {
    let contract = load_contract();

    assert_eq!(contract.schema_version, 1);
    assert_eq!(contract.contract_version, "local-runner-contract-v1");
    assert_eq!(contract.envelope_version, "cli-output-envelope-v1");
    assert!(contract
        .ownership_boundary
        .mabinogion_owns
        .iter()
        .any(|item| item == "machine_readable_runner_output"));
    assert!(contract
        .ownership_boundary
        .not_owned_by_mabinogion
        .iter()
        .any(|item| item == "certification_issuance"));

    let formats = contract
        .machine_output
        .formats
        .iter()
        .map(String::as_str)
        .collect::<HashSet<_>>();
    assert_eq!(formats, HashSet::from(["json", "yaml", "compact"]));

    for field in [
        "contract_version",
        "envelope_version",
        "command",
        "status",
        "exit_code",
        "exit_category",
        "generated_at",
        "engine_version",
        "data",
        "warnings",
        "errors",
    ] {
        assert!(
            contract
                .machine_output
                .required_envelope_fields
                .iter()
                .any(|documented| documented == field),
            "envelope field {field} should be documented"
        );
    }
}

#[test]
fn local_runner_contract_yaml_defines_exit_codes_and_commands() {
    let contract = load_contract();

    let exit_codes = contract
        .exit_code_policy
        .iter()
        .map(|entry| (entry.code, entry.category.as_str()))
        .collect::<HashMap<_, _>>();
    assert_eq!(exit_codes.get(&0), Some(&"success"));
    assert_eq!(exit_codes.get(&2), Some(&"input_contract_error"));
    assert_eq!(exit_codes.get(&6), Some(&"validation_failure"));
    assert_eq!(exit_codes.get(&9), Some(&"runtime_failure"));
    assert_eq!(exit_codes.get(&124), Some(&"timeout"));
    assert_eq!(exit_codes.get(&130), Some(&"interrupted"));
    assert_eq!(exit_codes.get(&1), Some(&"internal_failure"));

    let commands = contract
        .runner_facing_commands
        .iter()
        .map(|entry| {
            assert!(
                entry.stable_machine_output,
                "{} should be stable machine output",
                entry.command
            );
            entry.command.as_str()
        })
        .collect::<HashSet<_>>();
    for command in [
        "doctor",
        "inspect protocols",
        "inspect schema",
        "inspect status",
        "inspect modbus-config",
        "inspect opcua-config",
        "validate scenario",
        "validate config",
        "validate modbus-config",
        "validate opcua-config",
        "version",
    ] {
        assert!(
            commands.contains(command),
            "{command} should be runner-facing"
        );
    }

    for version in [
        "local-runner-contract-v1",
        "cli-output-envelope-v1",
        "runtime-contract-v1",
        "snapshot-metadata-v1",
        "unified-readiness-contract-v1",
        "run-evidence-schema-v1",
        "trial-artifact-contract-v1",
        "version-metadata-contract-v1",
    ] {
        assert!(
            contract
                .contract_versions_reported_by_version
                .iter()
                .any(|documented| documented == version),
            "{version} should be reported by mabi version"
        );
    }
}

#[test]
fn future_trial_run_contract_is_documented_without_claiming_ownership() {
    let contract = load_contract();
    let spec = contract.future_trial_run_execution_spec;

    assert_eq!(spec.command_status, "documented_future_contract_only");
    assert_eq!(spec.command, "mabi trial run");
    assert_eq!(spec.owner_of_trial_definition, "mabinogion-trials");
    assert_eq!(spec.evidence_output_contract, "run-evidence-schema-v1");
    assert_eq!(spec.artifact_contract, "trial-artifact-contract-v1");
    for field in [
        "execution_spec_version",
        "run_id",
        "trial_suite_version",
        "protocol",
        "profile_id",
        "config_path",
        "session",
        "readiness_timeout_ms",
        "artifact_dir",
        "output_format",
        "evidence_requirements",
    ] {
        assert!(
            spec.required_fields
                .iter()
                .any(|documented| documented == field),
            "future trial run field {field} should be documented"
        );
    }
}

#[test]
fn inspect_protocols_json_uses_local_runner_envelope() {
    let output = mabi(&["--format", "json", "inspect", "protocols"]);
    assert!(
        output.status.success(),
        "inspect protocols failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let envelope = parse_stdout(&output);

    assert_eq!(envelope["contract_version"], "local-runner-contract-v1");
    assert_eq!(envelope["envelope_version"], "cli-output-envelope-v1");
    assert_eq!(envelope["command"], "inspect protocols");
    assert_eq!(envelope["exit_code"], 0);
    assert_eq!(envelope["exit_category"], "success");
    assert_eq!(
        envelope["data"]["protocols"]
            .as_array()
            .expect("protocols should be an array")
            .len(),
        4
    );
    assert_eq!(
        envelope["data"]["contract_versions"]["unified_readiness_contract"],
        "unified-readiness-contract-v1"
    );
}

#[test]
fn inspect_config_json_failure_is_enveloped() {
    let temp = tempfile::tempdir().expect("tempdir should be created");
    let invalid_path = temp.path().join("invalid-modbus.yaml");
    fs::write(&invalid_path, "{").expect("invalid fixture should be written");

    let output = mabi(&[
        "--format",
        "json",
        "inspect",
        "modbus-config",
        invalid_path
            .to_str()
            .expect("invalid fixture path should be UTF-8"),
    ]);
    assert_eq!(output.status.code(), Some(2));
    let envelope = parse_stdout(&output);
    assert_eq!(envelope["contract_version"], "local-runner-contract-v1");
    assert_eq!(envelope["command"], "inspect modbus-config");
    assert_eq!(envelope["status"], "failure");
    assert_eq!(envelope["exit_code"], 2);
    assert_eq!(envelope["exit_category"], "input_contract_error");
    assert!(!envelope["errors"]
        .as_array()
        .expect("errors should be an array")
        .is_empty());
}

#[test]
fn validate_config_json_success_and_failure_are_enveloped() {
    let temp = tempfile::tempdir().expect("tempdir should be created");
    let valid_path = temp.path().join("valid.yaml");
    fs::write(&valid_path, "answer: 42\n").expect("valid fixture should be written");

    let valid_output = mabi(&[
        "--format",
        "json",
        "validate",
        "config",
        valid_path
            .to_str()
            .expect("valid fixture path should be UTF-8"),
    ]);
    assert!(
        valid_output.status.success(),
        "validate config valid failed: {}",
        String::from_utf8_lossy(&valid_output.stderr)
    );
    let valid = parse_stdout(&valid_output);
    assert_eq!(valid["contract_version"], "local-runner-contract-v1");
    assert_eq!(valid["command"], "validate config");
    assert_eq!(valid["status"], "success");
    assert_eq!(valid["data"]["total_files"], 1);
    assert_eq!(valid["data"]["valid_files"], 1);

    let invalid_path = temp.path().join("invalid.json");
    fs::write(&invalid_path, "{").expect("invalid fixture should be written");
    let invalid_output = mabi(&[
        "--format",
        "json",
        "validate",
        "config",
        invalid_path
            .to_str()
            .expect("invalid fixture path should be UTF-8"),
    ]);
    assert_eq!(invalid_output.status.code(), Some(6));
    let invalid = parse_stdout(&invalid_output);
    assert_eq!(invalid["contract_version"], "local-runner-contract-v1");
    assert_eq!(invalid["command"], "validate config");
    assert_eq!(invalid["status"], "failure");
    assert_eq!(invalid["exit_code"], 6);
    assert_eq!(invalid["exit_category"], "validation_failure");
    assert!(!invalid["errors"]
        .as_array()
        .expect("errors should be an array")
        .is_empty());
}