1#![expect(
19 clippy::disallowed_types,
20 reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
21 exchanges), whose shapes belong to the artifacts and the SUT"
22)]
23
24use std::collections::BTreeMap;
25
26use reqwest::StatusCode;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::exec::assertions::{CorpusGround, ExchangeFacts, ReplayJudgement, replay_judgement};
31use crate::exec::outcome::StepJudgement;
32use crate::exec::resolve::Resolver;
33use crate::exec::state::{Captured, VarStore};
34use crate::exec::{StepDriver, StepObservation, outcome};
35use crate::ids::{CaptureName, CaseId, CorpusKey, SmOperationRef, ViewName};
36use crate::model::assertion::{Assertion, PostconditionRole};
37use crate::model::binding::{HeaderMatcher, WireExpectation, WireFrom};
38use crate::model::case::{CaseCore, FlowStep};
39use crate::vocab::{FormatName, OutcomeKind};
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct RecordedResponse {
45 pub status: u16,
47 pub headers: BTreeMap<String, String>,
49 #[serde(default)]
51 pub body: Option<Value>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct RecordedRequest {
58 pub method: String,
60 pub path: String,
62 #[serde(default)]
64 pub body_digest: Option<String>,
65 #[serde(default)]
76 pub accept: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct TranscriptStep {
83 pub step: u32,
85 pub request: RecordedRequest,
87 pub response: RecordedResponse,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct TranscriptEntry {
95 pub case: CaseId,
97 #[serde(default)]
99 pub format: Option<FormatName>,
100 pub row: usize,
102 pub steps: Vec<TranscriptStep>,
104 pub expected_verdict: ExpectedVerdict,
106 pub adjudication_ref: String,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ExpectedVerdict {
114 Passed,
116 Failed,
118 Errored,
120 NotApplicable,
122 Skipped,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct Transcript {
130 pub schedule_release: String,
132 pub entries: Vec<TranscriptEntry>,
134}
135
136struct PlayerCorpus<'a> {
143 resolver: Resolver<'a>,
144 manifest: &'a crate::model::corpus::CorpusManifest,
145}
146
147impl CorpusGround for PlayerCorpus<'_> {
148 fn data_set(&mut self, key: &CorpusKey, view: Option<&ViewName>) -> Result<Value, String> {
149 match view {
150 None => self.resolver.data_set(key).map_err(|e| e.to_string()),
151 Some(view) => self.resolver.view(key, view).map_err(|e| e.to_string()),
152 }
153 }
154
155 fn corpus_format(&self, key: &CorpusKey) -> Option<crate::vocab::CorpusFormat> {
156 self.manifest.get(key).map(|entry| entry.format)
157 }
158}
159
160#[derive(Debug)]
162pub struct TranscriptPlayer<'a> {
163 set: &'a crate::artifacts::ArtifactSet,
164 entry: &'a TranscriptEntry,
165 cursor: usize,
166}
167
168impl<'a> TranscriptPlayer<'a> {
169 #[must_use]
171 pub fn new(set: &'a crate::artifacts::ArtifactSet, entry: &'a TranscriptEntry) -> Self {
172 Self {
173 set,
174 entry,
175 cursor: 0,
176 }
177 }
178
179 fn corpus(&self) -> Option<PlayerCorpus<'a>> {
182 let (_, manifest) = self.set.corpus.as_ref()?;
183 let corpus_dir = self.set.corpus_dir.as_deref()?;
184 Some(PlayerCorpus {
185 resolver: Resolver::new(manifest, corpus_dir, None),
186 manifest,
187 })
188 }
189
190 fn binding_for(
195 &self,
196 case: &CaseCore,
197 call: &str,
198 variant: Option<&str>,
199 ) -> Option<&'a crate::model::binding::OperationBinding> {
200 let op = if call.contains('.') {
201 SmOperationRef::parse(call).ok()?
202 } else {
203 case.sm_operation.as_ref()?.sibling(call)
204 };
205 let mut bindings = self.set.bindings.iter().map(|(_, b)| b);
206 if let Some(v) = variant
207 && let Some(exact) = bindings
208 .clone()
209 .find(|b| b.sm_operation == op && b.variant.as_deref() == Some(v))
210 {
211 return Some(exact);
212 }
213 bindings.find(|b| b.sm_operation == op && b.variant.is_none())
214 }
215
216 fn refuse_ungrounded_headers(
226 case: &CaseCore,
227 step: &FlowStep,
228 expectation: &WireExpectation,
229 recorded: &TranscriptStep,
230 ) -> Result<(), String> {
231 if recorded.request.accept.is_some() {
232 return Ok(());
233 }
234 let ungrounded: Vec<&str> = expectation
235 .headers
236 .as_deref()
237 .unwrap_or_default()
238 .iter()
239 .filter(|(_, declarations)| {
240 declarations
241 .all()
242 .iter()
243 .any(|h| matches!(h.matcher, HeaderMatcher::Negotiated))
244 })
245 .map(|(name, _)| name.as_str())
246 .collect();
247 if ungrounded.is_empty() {
248 return Ok(());
249 }
250 Err(format!(
251 "case {} step {} declares a negotiated matcher on {}, and the recorded request \
252 carries no `accept`; a pack entry may not claim a verdict over a header the replay \
253 cannot judge",
254 case.id,
255 step.step,
256 ungrounded.join(", ")
257 ))
258 }
259
260 fn refuse_unrecorded(case: &CaseCore, step: &FlowStep) -> Result<(), String> {
269 let unjudgeable: Vec<&str> = step
270 .assertions
271 .iter()
272 .filter(|assertion| replay_judgement(assertion) == ReplayJudgement::Unrecorded)
273 .map(Assertion::family)
274 .collect();
275 if unjudgeable.is_empty() {
276 return Ok(());
277 }
278 Err(format!(
279 "case {} step {} asserts families the transcript replay cannot judge ({}); a pack \
280 entry may not claim a verdict over assertions the replay never evaluated",
281 case.id,
282 step.step,
283 unjudgeable.join(", ")
284 ))
285 }
286
287 fn bind_recorded_captures(
294 &self,
295 step: &FlowStep,
296 binding: &crate::model::binding::OperationBinding,
297 recorded: &TranscriptStep,
298 kind: OutcomeKind,
299 vars: &mut VarStore,
300 ) {
301 for (name, source) in step.captures() {
302 if source.outcome != kind {
303 continue;
304 }
305 match &source.field {
306 crate::refgrammar::CaptureField::Body => {
307 if let Some(b) = &recorded.response.body {
308 vars.set(name.clone(), Captured::Body(b.clone()));
309 }
310 }
311 crate::refgrammar::CaptureField::CommitTime => {
312 #[expect(
313 clippy::expect_used,
314 reason = "the cursor indexes the transcript's in-memory step vector, so \
315 it is bounded orders of magnitude below i64::MAX / 1000; a \
316 failed widening or multiplication is logically impossible and \
317 should fail loud, never substitute an instant"
318 )]
319 let ms = i64::try_from(self.cursor)
320 .ok()
321 .and_then(|seconds| seconds.checked_mul(1_000))
322 .expect("the transcript cursor should fit an i64 millisecond instant");
323 vars.set(name.clone(), Captured::InstantMs { lo: ms, hi: ms });
324 }
325 crate::refgrammar::CaptureField::Field { name: field, list } => {
326 let Some(spec) = binding
327 .captures
328 .as_deref()
329 .unwrap_or_default()
330 .iter()
331 .find(|(n, _)| n == field)
332 .map(|(_, s)| s)
333 else {
334 continue;
335 };
336 if *list {
337 if let (Some(body), WireFrom::Body { path }) =
338 (&recorded.response.body, &spec.from)
339 {
340 let items = extract_list(body, path);
341 vars.set(name.clone(), Captured::List(items));
342 }
343 } else if let Some(value) = extract_scalar(&recorded.response, spec, vars) {
344 vars.set(name.clone(), Captured::Scalar(value));
345 }
346 }
347 }
348 }
349 }
350}
351
352fn case_reads_capture(case: &CaseCore, handle: &CaptureName) -> bool {
355 let matches_handle = |reference: &crate::refgrammar::ValueRef| matches!(reference, crate::refgrammar::ValueRef::Capture { name, .. } if name == handle);
356 case.flow.iter().any(|step| {
357 step.with_entries()
358 .iter()
359 .any(|(_, value)| value.refs().iter().any(|r| matches_handle(r)))
360 || step
361 .scope_templates()
362 .iter()
363 .any(|template| template.refs().iter().any(|r| matches_handle(r)))
364 || step.assertions.iter().any(|a| {
365 crate::model::assertion::assertion_refs(a)
366 .iter()
367 .any(matches_handle)
368 })
369 }) || case.postconditions.iter().any(|a| {
370 crate::model::assertion::assertion_refs(a)
371 .iter()
372 .any(matches_handle)
373 })
374}
375
376impl StepDriver for TranscriptPlayer<'_> {
377 fn perform(
378 &mut self,
379 case: &CaseCore,
380 step: &FlowStep,
381 expected: OutcomeKind,
382 _row: usize,
383 vars: &mut VarStore,
384 ) -> Result<StepObservation, String> {
385 let Some(recorded) = self.entry.steps.get(self.cursor) else {
386 return Ok(StepObservation::transport(
387 "transcript exhausted before the flow ended".to_owned(),
388 ));
389 };
390 self.cursor += 1;
391
392 let Some(binding) = self.binding_for(case, &step.call, step.variant.as_deref()) else {
393 return Err(format!("no binding declares operation {}", step.call));
394 };
395 let selectors = self.set.selectors.as_ref().map(|(_, s)| s);
396 let observation =
397 outcome::classify_status(binding, selectors, recorded.response.status, expected);
398
399 if let outcome::Observation::Kind(kind) = observation {
400 self.bind_recorded_captures(step, binding, recorded, kind, vars);
401 }
402 if !matches!(
406 outcome::judge(expected, &observation),
407 StepJudgement::Continue
408 ) {
409 return Ok(StepObservation {
410 observation,
411 assertion_failures: Vec::new(),
412 advisories: Vec::new(),
413 });
414 }
415 Self::refuse_unrecorded(case, step)?;
416 let Ok(status) = StatusCode::from_u16(recorded.response.status) else {
417 return Ok(StepObservation::transport(format!(
418 "recorded status {} is not an HTTP status code",
419 recorded.response.status
420 )));
421 };
422 let body = recorded.response.body.as_ref().unwrap_or(&Value::Null);
423 let facts = ExchangeFacts {
424 status,
425 body,
426 media_type: recorded
427 .response
428 .headers
429 .iter()
430 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
431 .map(|(_, value)| value.as_str()),
432 };
433 let mut no_corpus = crate::exec::assertions::NoCorpus;
434 let mut corpus = self.corpus();
435 let ground: &mut dyn CorpusGround = match corpus.as_mut() {
436 Some(loaded) => loaded,
437 None => &mut no_corpus,
438 };
439 let mut assertion_failures = Vec::new();
444 if let outcome::Observation::Kind(kind) = observation
445 && let Some(expectation) = binding.outcome(kind)
446 {
447 Self::refuse_ungrounded_headers(case, step, expectation, recorded)?;
448 let header_ctx = crate::exec::headers::RequestContext {
453 accept: recorded.request.accept.as_deref(),
454 last_version_uid: None,
455 spec_versions: None,
456 };
457 assertion_failures.extend(
458 crate::exec::headers::evaluate(
459 expectation,
460 &recorded.response.headers,
461 recorded.response.body.as_ref(),
462 &header_ctx,
463 vars,
464 )
465 .into_iter()
466 .map(crate::exec::assertions::AssertionOutcome::Mismatch),
467 );
468 }
469 let mut advisories = Vec::new();
470 for assertion in &step.assertions {
471 match crate::exec::assertions::eval_from_exchange(assertion, facts, ground) {
472 Ok(recorded_advisories) => advisories.extend(recorded_advisories),
473 Err(outcome) => assertion_failures.push(outcome),
474 }
475 }
476 Ok(StepObservation {
477 observation,
478 assertion_failures,
479 advisories,
480 })
481 }
482
483 fn provision(
484 &mut self,
485 case: &CaseCore,
486 _row: usize,
487 _vars: &mut VarStore,
488 ) -> Result<crate::exec::Provisioned, String> {
489 let minted = case.requires.minted_handles();
492 if minted.is_empty() {
493 return Ok(crate::exec::Provisioned::Ready);
494 }
495 let read: Vec<String> = minted
496 .iter()
497 .filter(|handle| case_reads_capture(case, handle))
498 .map(ToString::to_string)
499 .collect();
500 if read.is_empty() {
501 return Ok(crate::exec::Provisioned::Ready);
502 }
503 Err(format!(
504 "case {} reads provisioned handle(s) {} the transcript does not record; a pack \
505 entry may not resolve a requires handle against a value the recorded exchanges \
506 never used",
507 case.id,
508 read.join(", ")
509 ))
510 }
511
512 fn postconditions(
521 &mut self,
522 case: &CaseCore,
523 _row: usize,
524 _vars: &mut VarStore,
525 ) -> Result<crate::exec::PostconditionOutcomes, String> {
526 let unjudgeable: Vec<&str> = case
527 .postconditions
528 .iter()
529 .filter(|a| matches!(a.postcondition_role(), PostconditionRole::Judged))
530 .map(Assertion::family)
531 .collect();
532 if unjudgeable.is_empty() {
533 return Ok(crate::exec::PostconditionOutcomes::default());
534 }
535 Err(format!(
536 "case {} carries postconditions the transcript replay cannot judge ({}); a pack entry \
537 may not claim a verdict over assertions the replay never evaluated",
538 case.id,
539 unjudgeable.join(", ")
540 ))
541 }
542
543 fn aggregates(
544 &mut self,
545 case: &CaseCore,
546 all_rows: &[VarStore],
547 ) -> Result<Vec<String>, String> {
548 let mut failures = Vec::new();
549 for assertion in &case.postconditions {
550 if let Assertion::Unique { over, .. } = assertion
551 && let crate::refgrammar::ValueRef::Capture { name, .. } = &over.0
552 && let Err(crate::exec::assertions::AssertionFailure(m)) =
553 crate::exec::assertions::eval_unique(name, all_rows)
554 {
555 failures.push(m);
556 }
557 }
558 Ok(failures)
559 }
560}
561
562fn extract_scalar(
563 response: &RecordedResponse,
564 spec: &crate::model::binding::WireCapture,
565 vars: &VarStore,
566) -> Option<String> {
567 let from_source = |source: &WireFrom| -> Option<String> {
568 match source {
569 WireFrom::Header { name, last_segment } => {
570 let value = response
571 .headers
572 .iter()
573 .find(|(k, _)| k.eq_ignore_ascii_case(name))
574 .map(|(_, v)| v.clone())?;
575 if *last_segment {
576 value.rsplit('/').next().map(ToOwned::to_owned)
577 } else {
578 Some(value)
579 }
580 }
581 WireFrom::Body { path } => {
582 let mut current = response.body.as_ref()?;
583 for seg in path.split('.') {
584 current = current.get(seg)?;
585 }
586 match current {
587 Value::String(s) => Some(s.clone()),
588 other => Some(other.to_string()),
589 }
590 }
591 WireFrom::Capture(name) => vars.scalar(name).map(ToOwned::to_owned),
592 }
593 };
594 let mut value =
595 from_source(&spec.from).or_else(|| spec.fallback.as_ref().and_then(from_source))?;
596 if matches!(
597 spec.strip,
598 Some(crate::model::binding::StripRule::WeakQuotes)
599 ) {
600 value = value.trim_start_matches("W/").trim_matches('"').to_owned();
601 }
602 if let Some(transform) = spec.transform {
605 value = transform.apply(&value)?;
606 }
607 Some(value)
608}
609
610fn extract_list(body: &Value, path: &str) -> Vec<String> {
611 let mut current = vec![body];
612 for seg in path.split('.') {
613 let (attr, star) = match seg.strip_suffix("[*]") {
614 Some(attr) => (attr, true),
615 None => (seg, false),
616 };
617 let mut next = Vec::new();
618 for v in current {
619 let v = if attr.is_empty() {
620 Some(v)
621 } else {
622 v.get(attr)
623 };
624 if let Some(v) = v {
625 if star {
626 if let Some(items) = v.as_array() {
627 next.extend(items.iter());
628 }
629 } else {
630 next.push(v);
631 }
632 }
633 }
634 current = next;
635 }
636 current
637 .into_iter()
638 .filter_map(|v| match v {
639 Value::String(s) => Some(s.clone()),
640 other => other.as_str().map(ToOwned::to_owned),
641 })
642 .collect()
643}
644
645pub fn replay_entry(
651 set: &crate::artifacts::ArtifactSet,
652 entry: &TranscriptEntry,
653) -> Result<(ExpectedVerdict, crate::exec::RowOutcome), String> {
654 let case = set
655 .cases
656 .iter()
657 .map(|(_, c)| c)
658 .find(|c| c.id == entry.case)
659 .ok_or_else(|| format!("transcript case {} is not in the catalogue", entry.case))?;
660 let mut sliced = case.clone();
663 if let Some(parameters) = &mut sliced.parameters {
664 if let Some(matrix) = &mut parameters.matrix {
665 let row =
666 matrix.rows.get(entry.row).cloned().ok_or_else(|| {
667 format!("case {} has no matrix row {}", entry.case, entry.row)
668 })?;
669 matrix.rows = vec![row];
670 }
671 if let Some(fixtures) = &mut parameters.fixture_set {
672 let fixture = fixtures
673 .get(entry.row)
674 .cloned()
675 .ok_or_else(|| format!("case {} has no fixture row {}", entry.case, entry.row))?;
676 *fixtures = vec![fixture];
677 }
678 }
679 let mut player = TranscriptPlayer::new(set, entry);
680 let record = crate::exec::run_case(&sliced, entry.format, &mut player)?;
681 let row = record
682 .rows
683 .first()
684 .cloned()
685 .ok_or_else(|| format!("case {} produced no row", entry.case))?;
686 Ok((entry.expected_verdict, row))
687}
688
689#[must_use]
691pub fn verdict_matches(expected: ExpectedVerdict, produced: &crate::exec::RowOutcome) -> bool {
692 matches!(
693 (expected, produced),
694 (ExpectedVerdict::Passed, crate::exec::RowOutcome::Passed)
695 | (
696 ExpectedVerdict::Failed,
697 crate::exec::RowOutcome::Failed { .. }
698 )
699 | (
700 ExpectedVerdict::Errored,
701 crate::exec::RowOutcome::Errored { .. }
702 )
703 | (
704 ExpectedVerdict::NotApplicable,
705 crate::exec::RowOutcome::NotApplicable { .. }
706 )
707 | (
708 ExpectedVerdict::Skipped,
709 crate::exec::RowOutcome::Skipped { .. }
710 )
711 )
712}