Skip to main content

runx_parser/
runner.rs

1use std::collections::BTreeMap;
2
3use runx_contracts::{JsonObject, JsonValue};
4use serde::{Deserialize, Serialize};
5
6use crate::skill::{
7    CatalogMetadata, RunnerHarnessManifest, SkillRunnerDefinition, validate_catalog_metadata,
8    validate_harness_manifest, validate_runner_definition,
9};
10use crate::{
11    ParseError, ValidationError, assert_execution_profile_yaml_subset,
12    json_fields::{self, JsonFieldReader},
13};
14
15const FIELDS: JsonFieldReader = JsonFieldReader::new("runner_manifest");
16const MANIFEST_FIELDS: &[&str] = &[
17    "skill", "version", "runx", "policy", "emits", "catalog", "runners", "harness",
18];
19
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21pub struct RawRunnerManifestIr {
22    pub document: JsonObject,
23    pub raw: String,
24}
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub struct SkillRunnerManifest {
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub skill: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub version: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub runx: Option<runx_contracts::JsonObject>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub policy: Option<runx_contracts::JsonValue>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub emits: Option<runx_contracts::JsonValue>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub catalog: Option<CatalogMetadata>,
40    pub runners: BTreeMap<String, SkillRunnerDefinition>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub harness: Option<RunnerHarnessManifest>,
43    pub raw: RawRunnerManifestIr,
44}
45
46pub fn parse_runner_manifest_yaml(yaml: &str) -> Result<RawRunnerManifestIr, ParseError> {
47    assert_execution_profile_yaml_subset("runner_manifest", yaml)?;
48    let parsed: JsonValue =
49        serde_norway::from_str(yaml).map_err(|error| ParseError::InvalidYaml {
50            field: "runner_manifest".to_owned(),
51            message: error.to_string(),
52        })?;
53    let JsonValue::Object(document) = parsed else {
54        return Err(ParseError::InvalidDocument {
55            field: "runner_manifest".to_owned(),
56            message: "Runner manifest YAML must parse to an object.".to_owned(),
57        });
58    };
59    Ok(RawRunnerManifestIr {
60        document,
61        raw: yaml.to_owned(),
62    })
63}
64
65pub fn validate_runner_manifest(
66    raw: RawRunnerManifestIr,
67) -> Result<SkillRunnerManifest, ValidationError> {
68    FIELDS.reject_unknown_fields(&raw.document, "runner_manifest", MANIFEST_FIELDS)?;
69    let runners_record = FIELDS.required_object(raw.document.get("runners"), "runners")?;
70    let mut runners = BTreeMap::new();
71    for (name, value) in runners_record {
72        let JsonValue::Object(runner) = value else {
73            return Err(FIELDS.validation_error(format!("runners.{name} must be an object.")));
74        };
75        runners.insert(
76            name.clone(),
77            validate_runner_definition(name, runner.clone())?,
78        );
79    }
80
81    let harness = validate_harness_manifest(
82        FIELDS.optional_object(raw.document.get("harness"), "harness")?,
83        "harness",
84    )?;
85    validate_harness_runners(&harness, &runners)?;
86
87    Ok(SkillRunnerManifest {
88        skill: FIELDS.optional_string(raw.document.get("skill"), "skill")?,
89        version: FIELDS.optional_string(raw.document.get("version"), "version")?,
90        runx: FIELDS.optional_object(raw.document.get("runx"), "runx")?,
91        policy: raw.document.get("policy").cloned(),
92        emits: raw.document.get("emits").cloned(),
93        catalog: validate_catalog_metadata(
94            FIELDS.optional_object(raw.document.get("catalog"), "catalog")?,
95            "catalog",
96        )?,
97        runners,
98        harness,
99        raw,
100    })
101}
102
103pub fn resolve_post_run_reflect_policy(
104    runx: Option<&JsonObject>,
105    field: &str,
106) -> Result<String, ValidationError> {
107    let post_run = FIELDS.optional_object(
108        json_fields::field_value(runx, "post_run"),
109        &format!("{field}.post_run"),
110    )?;
111    let reflect = FIELDS
112        .optional_string(
113            json_fields::field_value(post_run.as_ref(), "reflect"),
114            &format!("{field}.post_run.reflect"),
115        )?
116        .unwrap_or_else(|| "never".to_owned());
117    if matches!(reflect.as_str(), "auto" | "always" | "never") {
118        return Ok(reflect);
119    }
120    Err(FIELDS.validation_error(format!(
121        "{field}.post_run.reflect must be auto, always, or never."
122    )))
123}
124
125fn validate_harness_runners(
126    harness: &Option<RunnerHarnessManifest>,
127    runners: &BTreeMap<String, SkillRunnerDefinition>,
128) -> Result<(), ValidationError> {
129    for entry in harness.iter().flat_map(|harness| harness.cases.iter()) {
130        if let Some(runner) = &entry.runner {
131            if !runners.contains_key(runner) {
132                return Err(FIELDS.validation_error(format!(
133                    "harness.cases runner {runner} is not declared in runners."
134                )));
135            }
136        }
137    }
138    Ok(())
139}