runx-parser 0.6.19

Pure Rust parser parity crate for runx skills, graphs, and tool manifests.
Documentation
use std::collections::BTreeMap;

use runx_contracts::{JsonObject, JsonValue};
use serde::{Deserialize, Serialize};

use crate::skill::{
    CatalogMetadata, RunnerHarnessManifest, SkillRunnerDefinition, validate_catalog_metadata,
    validate_harness_manifest, validate_runner_definition,
};
use crate::{
    ParseError, ValidationError, assert_execution_profile_yaml_subset,
    json_fields::{self, JsonFieldReader},
};

const FIELDS: JsonFieldReader = JsonFieldReader::new("runner_manifest");
const MANIFEST_FIELDS: &[&str] = &[
    "skill", "version", "runx", "policy", "emits", "catalog", "runners", "harness",
];

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RawRunnerManifestIr {
    pub document: JsonObject,
    pub raw: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SkillRunnerManifest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub skill: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub runx: Option<runx_contracts::JsonObject>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub policy: Option<runx_contracts::JsonValue>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub emits: Option<runx_contracts::JsonValue>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub catalog: Option<CatalogMetadata>,
    pub runners: BTreeMap<String, SkillRunnerDefinition>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub harness: Option<RunnerHarnessManifest>,
    pub raw: RawRunnerManifestIr,
}

pub fn parse_runner_manifest_yaml(yaml: &str) -> Result<RawRunnerManifestIr, ParseError> {
    assert_execution_profile_yaml_subset("runner_manifest", yaml)?;
    let parsed: JsonValue =
        serde_norway::from_str(yaml).map_err(|error| ParseError::InvalidYaml {
            field: "runner_manifest".to_owned(),
            message: error.to_string(),
        })?;
    let JsonValue::Object(document) = parsed else {
        return Err(ParseError::InvalidDocument {
            field: "runner_manifest".to_owned(),
            message: "Runner manifest YAML must parse to an object.".to_owned(),
        });
    };
    Ok(RawRunnerManifestIr {
        document,
        raw: yaml.to_owned(),
    })
}

pub fn validate_runner_manifest(
    raw: RawRunnerManifestIr,
) -> Result<SkillRunnerManifest, ValidationError> {
    FIELDS.reject_unknown_fields(&raw.document, "runner_manifest", MANIFEST_FIELDS)?;
    let runners_record = FIELDS.required_object(raw.document.get("runners"), "runners")?;
    let mut runners = BTreeMap::new();
    for (name, value) in runners_record {
        let JsonValue::Object(runner) = value else {
            return Err(FIELDS.validation_error(format!("runners.{name} must be an object.")));
        };
        runners.insert(
            name.clone(),
            validate_runner_definition(name, runner.clone())?,
        );
    }

    let harness = validate_harness_manifest(
        FIELDS.optional_object(raw.document.get("harness"), "harness")?,
        "harness",
    )?;
    validate_harness_runners(&harness, &runners)?;

    Ok(SkillRunnerManifest {
        skill: FIELDS.optional_string(raw.document.get("skill"), "skill")?,
        version: FIELDS.optional_string(raw.document.get("version"), "version")?,
        runx: FIELDS.optional_object(raw.document.get("runx"), "runx")?,
        policy: raw.document.get("policy").cloned(),
        emits: raw.document.get("emits").cloned(),
        catalog: validate_catalog_metadata(
            FIELDS.optional_object(raw.document.get("catalog"), "catalog")?,
            "catalog",
        )?,
        runners,
        harness,
        raw,
    })
}

pub fn resolve_post_run_reflect_policy(
    runx: Option<&JsonObject>,
    field: &str,
) -> Result<String, ValidationError> {
    let post_run = FIELDS.optional_object(
        json_fields::field_value(runx, "post_run"),
        &format!("{field}.post_run"),
    )?;
    let reflect = FIELDS
        .optional_string(
            json_fields::field_value(post_run.as_ref(), "reflect"),
            &format!("{field}.post_run.reflect"),
        )?
        .unwrap_or_else(|| "never".to_owned());
    if matches!(reflect.as_str(), "auto" | "always" | "never") {
        return Ok(reflect);
    }
    Err(FIELDS.validation_error(format!(
        "{field}.post_run.reflect must be auto, always, or never."
    )))
}

fn validate_harness_runners(
    harness: &Option<RunnerHarnessManifest>,
    runners: &BTreeMap<String, SkillRunnerDefinition>,
) -> Result<(), ValidationError> {
    for entry in harness.iter().flat_map(|harness| harness.cases.iter()) {
        if let Some(runner) = &entry.runner {
            if !runners.contains_key(runner) {
                return Err(FIELDS.validation_error(format!(
                    "harness.cases runner {runner} is not declared in runners."
                )));
            }
        }
    }
    Ok(())
}