1use std::collections::BTreeMap;
2
3use runx_contracts::{JsonObject, JsonValue};
4use serde::{Deserialize, Serialize};
5
6use crate::ValidationError;
7
8use super::FIELDS;
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct HarnessCallerFixture {
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub answers: Option<JsonObject>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub approvals: Option<BTreeMap<String, bool>>,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ReceiptExpectation {
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub kind: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub status: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub skill_name: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub source_type: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub graph_name: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub owner: Option<String>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct HarnessExpectation {
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub status: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub receipt: Option<ReceiptExpectation>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub steps: Option<Vec<String>>,
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45pub struct RunnerHarnessCase {
46 pub name: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub runner: Option<String>,
49 pub inputs: JsonObject,
50 pub env: BTreeMap<String, String>,
51 pub caller: HarnessCallerFixture,
52 pub expect: HarnessExpectation,
53}
54
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56pub struct RunnerHarnessManifest {
57 pub cases: Vec<RunnerHarnessCase>,
58}
59
60pub(crate) fn validate_harness_manifest(
61 value: Option<JsonObject>,
62 field: &str,
63) -> Result<Option<RunnerHarnessManifest>, ValidationError> {
64 let Some(value) = value else {
65 return Ok(None);
66 };
67 let cases = FIELDS
68 .required_plain_array(value.get("cases"), &format!("{field}.cases"))?
69 .iter()
70 .enumerate()
71 .map(|(index, entry)| {
72 validate_harness_case(
73 FIELDS.required_object(Some(entry), &format!("{field}.cases[{index}]"))?,
74 &format!("{field}.cases[{index}]"),
75 )
76 })
77 .collect::<Result<Vec<_>, _>>()?;
78 Ok(Some(RunnerHarnessManifest { cases }))
79}
80
81fn validate_harness_case(
82 value: &JsonObject,
83 field: &str,
84) -> Result<RunnerHarnessCase, ValidationError> {
85 Ok(RunnerHarnessCase {
86 name: FIELDS.required_string(value.get("name"), &format!("{field}.name"))?,
87 runner: FIELDS
88 .optional_non_empty_string(value.get("runner"), &format!("{field}.runner"))?,
89 inputs: FIELDS
90 .optional_object(value.get("inputs"), &format!("{field}.inputs"))?
91 .unwrap_or_default(),
92 env: validate_string_object(
93 FIELDS
94 .optional_object(value.get("env"), &format!("{field}.env"))?
95 .unwrap_or_default(),
96 &format!("{field}.env"),
97 )?,
98 caller: validate_harness_caller(
99 FIELDS
100 .optional_object(value.get("caller"), &format!("{field}.caller"))?
101 .unwrap_or_default(),
102 &format!("{field}.caller"),
103 )?,
104 expect: validate_harness_expectation(
105 FIELDS.required_object(value.get("expect"), &format!("{field}.expect"))?,
106 &format!("{field}.expect"),
107 )?,
108 })
109}
110
111fn validate_string_object(
112 value: JsonObject,
113 field: &str,
114) -> Result<BTreeMap<String, String>, ValidationError> {
115 value
116 .into_iter()
117 .map(|(key, value)| match value {
118 JsonValue::String(value) => Ok((key, value)),
119 _ => Err(FIELDS.validation_error(format!("{field}.{key} must be a string."))),
120 })
121 .collect()
122}
123
124fn validate_harness_caller(
125 value: JsonObject,
126 field: &str,
127) -> Result<HarnessCallerFixture, ValidationError> {
128 Ok(HarnessCallerFixture {
129 answers: FIELDS.optional_object(value.get("answers"), &format!("{field}.answers"))?,
130 approvals: Some(validate_bool_object(
131 FIELDS
132 .optional_object(value.get("approvals"), &format!("{field}.approvals"))?
133 .unwrap_or_default(),
134 &format!("{field}.approvals"),
135 )?),
136 })
137}
138
139fn validate_bool_object(
140 value: JsonObject,
141 field: &str,
142) -> Result<BTreeMap<String, bool>, ValidationError> {
143 value
144 .into_iter()
145 .map(|(key, value)| match value {
146 JsonValue::Bool(value) => Ok((key, value)),
147 _ => Err(FIELDS.validation_error(format!("{field}.{key} must be a boolean."))),
148 })
149 .collect()
150}
151
152fn validate_harness_expectation(
153 value: &JsonObject,
154 field: &str,
155) -> Result<HarnessExpectation, ValidationError> {
156 Ok(HarnessExpectation {
157 status: optional_harness_status(value.get("status"), &format!("{field}.status"))?,
158 receipt: validate_receipt_expectation(
159 FIELDS.optional_object(value.get("receipt"), &format!("{field}.receipt"))?,
160 &format!("{field}.receipt"),
161 )?,
162 steps: FIELDS.optional_string_array(value.get("steps"), &format!("{field}.steps"))?,
163 })
164}
165
166fn validate_receipt_expectation(
167 value: Option<JsonObject>,
168 field: &str,
169) -> Result<Option<ReceiptExpectation>, ValidationError> {
170 let Some(value) = value else {
171 return Ok(None);
172 };
173 Ok(Some(ReceiptExpectation {
174 kind: optional_receipt_kind(value.get("kind"), &format!("{field}.kind"))?,
175 status: optional_receipt_status(value.get("status"), &format!("{field}.status"))?,
176 skill_name: FIELDS
177 .optional_string(value.get("skill_name"), &format!("{field}.skill_name"))?,
178 source_type: FIELDS
179 .optional_string(value.get("source_type"), &format!("{field}.source_type"))?,
180 graph_name: FIELDS
181 .optional_string(value.get("graph_name"), &format!("{field}.graph_name"))?,
182 owner: FIELDS.optional_string(value.get("owner"), &format!("{field}.owner"))?,
183 }))
184}
185
186fn optional_harness_status(
187 value: Option<&JsonValue>,
188 field: &str,
189) -> Result<Option<String>, ValidationError> {
190 validate_enum(
191 value,
192 field,
193 &[
194 "sealed",
195 "failure",
196 "needs_agent",
197 "policy_denied",
198 "escalated",
199 ],
200 )
201}
202
203fn optional_receipt_status(
204 value: Option<&JsonValue>,
205 field: &str,
206) -> Result<Option<String>, ValidationError> {
207 validate_enum(value, field, &["sealed", "failure"])
208}
209
210fn optional_receipt_kind(
211 value: Option<&JsonValue>,
212 field: &str,
213) -> Result<Option<String>, ValidationError> {
214 validate_enum(value, field, &["receipt"])
215}
216
217fn validate_enum(
218 value: Option<&JsonValue>,
219 field: &str,
220 allowed: &[&str],
221) -> Result<Option<String>, ValidationError> {
222 let Some(value) = FIELDS.optional_string(value, field)? else {
223 return Ok(None);
224 };
225 if allowed.iter().any(|allowed| *allowed == value) {
226 return Ok(Some(value));
227 }
228 Err(FIELDS.validation_error(format!("{field} must be {}.", allowed.join(", "))))
229}