1use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use runx_contracts::{ClosureDisposition, JsonObject, ReceiptSchema};
8use serde::Deserialize;
9use thiserror::Error;
10
11const RETIRED_RECEIPT_FIELDS: &[&str] =
12 &["kind", "skill_name", "source_type", "graph_name", "owner"];
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct HarnessFixtureCase {
16 pub name: &'static str,
17 pub fixture_path: &'static str,
18 pub root_oracle_path: &'static str,
19 pub step_oracles: &'static [HarnessFixtureStepOracle],
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct HarnessFixtureStepOracle {
24 pub step_id: &'static str,
25 pub oracle_path: &'static str,
26}
27
28const HARNESS_FIXTURE_CASES: &[HarnessFixtureCase] = &[
29 HarnessFixtureCase {
30 name: "echo-skill",
31 fixture_path: "fixtures/harness/echo-skill.yaml",
32 root_oracle_path: "fixtures/harness/oracle/echo-skill.receipt.json",
33 step_oracles: &[],
34 },
35 HarnessFixtureCase {
36 name: "sequential-graph",
37 fixture_path: "fixtures/harness/sequential-graph.yaml",
38 root_oracle_path: "fixtures/harness/oracle/sequential-graph.receipt.json",
39 step_oracles: &[
40 HarnessFixtureStepOracle {
41 step_id: "first",
42 oracle_path: "fixtures/harness/oracle/sequential-graph.first.json",
43 },
44 HarnessFixtureStepOracle {
45 step_id: "second",
46 oracle_path: "fixtures/harness/oracle/sequential-graph.second.json",
47 },
48 ],
49 },
50];
51
52#[must_use]
53pub fn list_cases() -> &'static [HarnessFixtureCase] {
54 HARNESS_FIXTURE_CASES
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum HarnessFixtureKind {
60 Skill,
61 Graph,
62 Mcp,
63 A2a,
64 Agent,
65 #[serde(rename = "agent_task")]
66 AgentStep,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HarnessExpectedStatus {
72 Sealed,
73 Failure,
74 NeedsAgent,
75 PolicyDenied,
76 Escalated,
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub struct HarnessFixture {
81 pub name: String,
82 pub kind: HarnessFixtureKind,
83 pub target: String,
84 pub runner: Option<String>,
85 pub inputs: JsonObject,
86 pub env: BTreeMap<String, String>,
87 pub caller: JsonObject,
88 pub expect: HarnessExpectation,
89 pub metadata: JsonObject,
90}
91
92#[derive(Clone, Debug, Default, PartialEq)]
93pub struct HarnessExpectation {
94 pub status: Option<HarnessExpectedStatus>,
95 pub receipt: Option<ReceiptExpectation>,
96 pub steps: Vec<String>,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct ReceiptExpectation {
101 pub schema: ReceiptSchema,
102 pub body_digest: Option<String>,
103 pub receipt_id: Option<String>,
104 pub receipt_digest: Option<String>,
105 pub harness_id: Option<String>,
106 pub state: Option<String>,
107 pub disposition: Option<ClosureDisposition>,
108 pub reason_code: Option<String>,
109 pub act_ids: Vec<String>,
110 pub decision_ids: Vec<String>,
111 pub child_receipt_refs: Vec<String>,
112 pub verification_refs: Vec<String>,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct RawHarnessFixture {
118 name: String,
119 kind: HarnessFixtureKind,
120 #[serde(default)]
121 target: Option<String>,
122 runner: Option<String>,
123 #[serde(default)]
124 inputs: JsonObject,
125 #[serde(default)]
126 env: BTreeMap<String, String>,
127 #[serde(default)]
128 caller: JsonObject,
129 #[serde(default)]
130 expect: RawHarnessExpectation,
131 #[serde(default)]
132 metadata: JsonObject,
133}
134
135#[derive(Debug, Default, Deserialize)]
136#[serde(deny_unknown_fields)]
137struct RawHarnessExpectation {
138 status: Option<HarnessExpectedStatus>,
139 receipt: Option<RawReceiptExpectation>,
140 #[serde(default)]
141 steps: Vec<String>,
142}
143
144#[derive(Debug, Deserialize)]
145struct RawReceiptExpectation {
146 #[serde(default = "default_receipt_schema")]
147 schema: ReceiptSchema,
148 body_digest: Option<String>,
149 receipt_id: Option<String>,
150 receipt_digest: Option<String>,
151 harness_id: Option<String>,
152 state: Option<String>,
153 disposition: Option<ClosureDisposition>,
154 reason_code: Option<String>,
155 #[serde(default)]
156 act_ids: Vec<String>,
157 #[serde(default)]
158 decision_ids: Vec<String>,
159 #[serde(default)]
160 child_receipt_refs: Vec<String>,
161 #[serde(default)]
162 verification_refs: Vec<String>,
163 #[serde(flatten)]
164 extra: BTreeMap<String, serde::de::IgnoredAny>,
165}
166
167#[derive(Debug, Error)]
168pub enum HarnessFixtureError {
169 #[error("failed to read harness fixture {path}: {source}")]
170 Read {
171 path: PathBuf,
172 source: std::io::Error,
173 },
174 #[error("failed to parse harness fixture YAML: {0}")]
175 Parse(#[from] serde_norway::Error),
176 #[error("harness fixture {field} is required")]
177 Required { field: String },
178 #[error("harness fixture {field} must not be empty")]
179 Empty { field: &'static str },
180 #[error("retired receipt expectation field {field_path}")]
181 RetiredReceiptField { field_path: String },
182 #[error("unknown receipt expectation field {field_path}")]
183 UnknownReceiptField { field_path: String },
184 #[error("harness fixture mode {mode} at {field_path} is not yet supported by the Rust harness")]
185 UnsupportedFixtureMode { mode: String, field_path: String },
186}
187
188pub fn load_harness_fixture(path: impl AsRef<Path>) -> Result<HarnessFixture, HarnessFixtureError> {
189 let path = path.as_ref();
190 let contents = fs::read_to_string(path).map_err(|source| HarnessFixtureError::Read {
191 path: path.to_path_buf(),
192 source,
193 })?;
194 parse_harness_fixture(&contents)
195}
196
197pub fn parse_harness_fixture(contents: &str) -> Result<HarnessFixture, HarnessFixtureError> {
198 let fixture = serde_norway::from_str::<RawHarnessFixture>(contents)?;
199 validate_fixture(fixture)
200}
201
202fn validate_fixture(fixture: RawHarnessFixture) -> Result<HarnessFixture, HarnessFixtureError> {
203 require_non_empty(&fixture.name, "name")?;
204 validate_supported_fixture_kind(&fixture.kind, "kind")?;
205 let target = fixture.target.unwrap_or_default();
206 if !matches!(fixture.kind, HarnessFixtureKind::AgentStep) {
207 require_non_empty(&target, "target")?;
208 }
209 if let Some(runner) = &fixture.runner {
210 require_non_empty(runner, "runner")?;
211 }
212 Ok(HarnessFixture {
213 name: fixture.name,
214 kind: fixture.kind,
215 target,
216 runner: fixture.runner,
217 inputs: fixture.inputs,
218 env: fixture.env,
219 caller: fixture.caller,
220 expect: validate_expectation(fixture.expect)?,
221 metadata: fixture.metadata,
222 })
223}
224
225fn validate_expectation(
226 expectation: RawHarnessExpectation,
227) -> Result<HarnessExpectation, HarnessFixtureError> {
228 Ok(HarnessExpectation {
229 status: expectation.status,
230 receipt: expectation
231 .receipt
232 .map(validate_receipt_expectation)
233 .transpose()?,
234 steps: expectation.steps,
235 })
236}
237
238fn validate_receipt_expectation(
239 receipt: RawReceiptExpectation,
240) -> Result<ReceiptExpectation, HarnessFixtureError> {
241 if let Some(field) = receipt.extra.keys().next() {
242 let field_path = format!("expect.receipt.{field}");
243 if is_retired_receipt_field(field) {
244 return Err(HarnessFixtureError::RetiredReceiptField { field_path });
245 }
246 return Err(HarnessFixtureError::UnknownReceiptField { field_path });
247 }
248 Ok(ReceiptExpectation {
249 schema: receipt.schema,
250 body_digest: receipt.body_digest,
251 receipt_id: receipt.receipt_id,
252 receipt_digest: receipt.receipt_digest,
253 harness_id: receipt.harness_id,
254 state: receipt.state,
255 disposition: receipt.disposition,
256 reason_code: receipt.reason_code,
257 act_ids: receipt.act_ids,
258 decision_ids: receipt.decision_ids,
259 child_receipt_refs: receipt.child_receipt_refs,
260 verification_refs: receipt.verification_refs,
261 })
262}
263
264fn is_retired_receipt_field(field: &str) -> bool {
265 RETIRED_RECEIPT_FIELDS.contains(&field)
266 || field == retired_execution_receipt_field("skill")
267 || field == retired_execution_receipt_field("graph")
268}
269
270fn retired_execution_receipt_field(prefix: &str) -> String {
271 format!("{prefix}_{}", "execution")
272}
273
274fn validate_supported_fixture_kind(
275 kind: &HarnessFixtureKind,
276 field_path: &'static str,
277) -> Result<(), HarnessFixtureError> {
278 match kind {
279 HarnessFixtureKind::Skill
280 | HarnessFixtureKind::Graph
281 | HarnessFixtureKind::A2a
282 | HarnessFixtureKind::Agent
283 | HarnessFixtureKind::AgentStep => Ok(()),
284 HarnessFixtureKind::Mcp => Err(HarnessFixtureError::UnsupportedFixtureMode {
285 mode: fixture_kind_name(kind).to_owned(),
286 field_path: field_path.to_owned(),
287 }),
288 }
289}
290
291pub(crate) fn fixture_kind_name(kind: &HarnessFixtureKind) -> &'static str {
292 match kind {
293 HarnessFixtureKind::Skill => "skill",
294 HarnessFixtureKind::Graph => "graph",
295 HarnessFixtureKind::Mcp => "mcp",
296 HarnessFixtureKind::A2a => "a2a",
297 HarnessFixtureKind::Agent => "agent",
298 HarnessFixtureKind::AgentStep => "agent_task",
299 }
300}
301
302fn require_non_empty(value: &str, field: &'static str) -> Result<(), HarnessFixtureError> {
303 if value.is_empty() {
304 Err(HarnessFixtureError::Empty { field })
305 } else {
306 Ok(())
307 }
308}
309
310fn default_receipt_schema() -> ReceiptSchema {
311 ReceiptSchema::V1
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{
317 HarnessFixtureError, HarnessFixtureKind, ReceiptSchema, parse_harness_fixture,
318 retired_execution_receipt_field,
319 };
320
321 #[test]
322 fn parses_post_cutover_receipt_expectation() -> Result<(), HarnessFixtureError> {
323 let fixture = parse_harness_fixture(
324 r#"
325name: echo-skill
326kind: skill
327target: ../skills/echo
328inputs:
329 message: hello
330expect:
331 status: sealed
332 receipt:
333 schema: runx.receipt.v1
334 body_digest: sha256:test
335 harness_id: echo-skill
336 state: sealed
337 disposition: closed
338 reason_code: harness_replay_passed
339 act_ids:
340 - echo
341"#,
342 )?;
343
344 assert_eq!(fixture.kind, HarnessFixtureKind::Skill);
345 let receipt = fixture
346 .expect
347 .receipt
348 .ok_or(HarnessFixtureError::Required {
349 field: "expect.receipt".to_owned(),
350 })?;
351 assert_eq!(receipt.schema, ReceiptSchema::V1);
352 assert_eq!(receipt.state.as_deref(), Some("sealed"));
353 assert_eq!(receipt.act_ids, vec!["echo"]);
354 Ok(())
355 }
356
357 #[test]
358 fn rejects_retired_receipt_expectation_fields() {
359 for field in [
360 "kind".to_owned(),
361 retired_execution_receipt_field("skill"),
362 retired_execution_receipt_field("graph"),
363 ] {
364 let result = parse_harness_fixture(&format!(
365 r#"
366name: old
367kind: skill
368target: ../skills/echo
369expect:
370 receipt:
371 {field}: value
372"#,
373 ));
374
375 assert!(matches!(
376 result,
377 Err(HarnessFixtureError::RetiredReceiptField { field_path })
378 if field_path == format!("expect.receipt.{field}")
379 ));
380 }
381 }
382
383 #[test]
384 fn rejects_unsupported_fixture_modes_with_stable_diagnostic() {
385 let result = parse_harness_fixture(
386 r#"
387name: old
388kind: mcp
389target: ../skills/echo
390expect:
391 status: sealed
392"#,
393 );
394
395 assert!(matches!(
396 result,
397 Err(HarnessFixtureError::UnsupportedFixtureMode { mode, field_path })
398 if mode == "mcp" && field_path == "kind"
399 ));
400 }
401
402 #[test]
403 fn rejects_unknown_receipt_expectation_fields() {
404 let result = parse_harness_fixture(
405 r#"
406name: old
407kind: skill
408target: ../skills/echo
409expect:
410 receipt:
411 unexpected: value
412"#,
413 );
414
415 assert!(matches!(
416 result,
417 Err(HarnessFixtureError::UnknownReceiptField { field_path })
418 if field_path == "expect.receipt.unexpected"
419 ));
420 }
421}