Skip to main content

runx_parser/skill/
fixtures.rs

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}