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}