camel_integration_test/runner.rs
1//! The scenario action runner (ADR-0069 §5, §7).
2//!
3//! Executes a scenario's ordered actions against a
4//! [`PartnerRouter`](crate::adapters::PartnerRouter): `send`
5//! dispatches through the adapter, `receive` awaits with the action's
6//! deadline and applies `extract` into [`ScenarioVars`], `sleep` uses
7//! tokio time, and `validate` evaluates the matcher grammar against
8//! the last received message or an extracted variable, or asserts a
9//! partner's recorded-request count (feature `http`).
10//!
11//! Failure taxonomy (ADR-0069 §7), encoded by variant and named in
12//! `Display`, never by message text alone:
13//!
14//! - Verdict class — the scenario ran and the system under test
15//! failed it: [`ScenarioFailure::ReceiveTimeout`],
16//! [`ScenarioFailure::ValidationMismatch`],
17//! [`ScenarioFailure::VarUnresolved`].
18//! - Apparatus class — the scenario never got a meaningful answer:
19//! [`ScenarioFailure::ActionTransport`],
20//! [`ScenarioFailure::PartnerStartup`],
21//! [`ScenarioFailure::ShutdownFailure`].
22//!
23//! Verdict-class failures map to exit 1 at the CLI; apparatus-class
24//! failures map to exit 2, as do doc-validation failures before the
25//! runner ever runs.
26//!
27//! Every await is bounded: `receive` carries the action deadline, and
28//! `send` is bounded by [`SEND_DEADLINE`].
29
30use std::collections::BTreeMap;
31use std::time::{Duration, Instant};
32
33use camel_api::Value;
34
35use crate::adapters::redact_wire_path;
36use crate::adapters::{
37 IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError, lanes_suffix,
38};
39use crate::document::{
40 EndpointRef, Expectation, Provisioning, ScenarioAction, ScenarioDocument, ScenarioTarget,
41 ValidateExpectation,
42};
43
44/// Partner verification for the `validate` action's `partner` target
45/// (ADR-0069 §5): the filtered recorded-request count, the deadline
46/// poll, and the mismatch-detail renderers.
47mod partner_validate;
48
49// Test-only re-exports: these primitives are exercised directly by
50// `runner_test`, while the runner itself only calls
51// `partner_validate_action`.
52use partner_validate::partner_validate_action;
53#[cfg(all(test, feature = "http"))]
54pub(crate) use partner_validate::{
55 matching_requests, partner_mismatch_detail, render_bound, render_filters,
56};
57
58/// The bounded deadline for every `send` action (ADR-0069 §7: every
59/// adapter operation carries a deadline).
60const SEND_DEADLINE: Duration = Duration::from_secs(30);
61
62/// Mutable run state carried across actions: scenario variables set by
63/// `extract`, and the last message received per endpoint for
64/// `lastReceived` validation.
65#[derive(Debug, Default)]
66pub struct ScenarioVars {
67 /// Variables extracted from received messages, by name.
68 variables: BTreeMap<String, Value>,
69 /// Last message received per endpoint URI.
70 last_received: BTreeMap<String, IncomingMessage>,
71}
72
73impl ScenarioVars {
74 /// Empty run state.
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 /// The variable set by an earlier `extract`, if any.
80 pub fn get(&self, name: &str) -> Option<&Value> {
81 self.variables.get(name)
82 }
83
84 /// Sets a variable, overwriting any earlier value.
85 pub fn set(&mut self, name: impl Into<String>, value: Value) {
86 self.variables.insert(name.into(), value);
87 }
88
89 /// The last message received on the endpoint URI, if any.
90 pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
91 self.last_received.get(endpoint)
92 }
93
94 /// Records the last message received on an endpoint URI.
95 fn remember(&mut self, endpoint: String, message: IncomingMessage) {
96 self.last_received.insert(endpoint, message);
97 }
98}
99
100/// Resolves `${name}` placeholders in a scenario string against `vars`.
101///
102/// Grammar: `$${` escapes to a literal `${`; `${name}` substitutes the
103/// variable when `name` matches `[A-Za-z0-9_]+` and is immediately
104/// followed by `}`. Anything else — including `${env:FOO}`, where a
105/// colon follows the name — stays literal, so `${env:}` never resolves
106/// in scenarios. A non-string variable substitutes its JSON
107/// representation (`Value::to_string`), so a number 42 yields `42`.
108/// Substituted text is not re-scanned. An unset variable fails with
109/// [`ScenarioFailure::VarUnresolved`].
110pub(crate) fn resolve_placeholders(
111 input: &str,
112 vars: &ScenarioVars,
113) -> Result<String, ScenarioFailure> {
114 let bytes = input.as_bytes();
115 let mut out = Vec::with_capacity(bytes.len());
116 let mut i = 0;
117 while i < bytes.len() {
118 if bytes[i] == b'$' {
119 // `$${` escapes to a literal `${`.
120 if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
121 out.extend_from_slice(b"${");
122 i += 3;
123 continue;
124 }
125 // `${name}` with name in [A-Za-z0-9_]+ immediately followed
126 // by `}`; a colon or any other character after the name
127 // keeps the whole span literal.
128 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
129 let name_start = i + 2;
130 let mut j = name_start;
131 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
132 j += 1;
133 }
134 if j > name_start && j < bytes.len() && bytes[j] == b'}' {
135 let name = &input[name_start..j];
136 match vars.get(name) {
137 Some(value) => {
138 let replacement = stringify(value);
139 out.extend_from_slice(replacement.as_bytes());
140 i = j + 1;
141 continue;
142 }
143 None => {
144 return Err(ScenarioFailure::VarUnresolved {
145 name: name.to_string(),
146 });
147 }
148 }
149 }
150 }
151 out.push(b'$');
152 i += 1;
153 continue;
154 }
155 out.push(bytes[i]);
156 i += 1;
157 }
158 // The output is a byte-for-byte copy of the input except for
159 // substituted spans, so it stays valid UTF-8.
160 Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) // allow-unwrap
161}
162
163/// Recursively interpolates `${name}` placeholders in a value: maps
164/// and arrays are rebuilt with interpolated values, string leaves go
165/// through [`resolve_placeholders`], and every other leaf is cloned
166/// untouched. An unset variable propagates
167/// [`ScenarioFailure::VarUnresolved`] from any depth.
168pub(crate) fn interpolate_value(
169 value: &Value,
170 vars: &ScenarioVars,
171) -> Result<Value, ScenarioFailure> {
172 match value {
173 Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
174 Value::Array(items) => items
175 .iter()
176 .map(|item| interpolate_value(item, vars))
177 .collect::<Result<Vec<_>, _>>()
178 .map(Value::Array),
179 Value::Object(map) => {
180 let rebuilt = map
181 .iter()
182 .map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
183 .collect::<Result<_, _>>()?;
184 Ok(Value::Object(rebuilt))
185 }
186 other => Ok(other.clone()),
187 }
188}
189
190/// The outcome of a scenario that ran to completion: every action
191/// succeeded and every validation passed.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum ScenarioVerdict {
195 /// All actions completed and all validations passed.
196 Pass,
197}
198
199/// Why a scenario failed (ADR-0069 §7). Verdict-class variants mean
200/// the system under test failed the scenario; apparatus-class
201/// variants mean the scenario never got a meaningful answer. The
202/// CLI maps verdict-class failures to exit 1 and apparatus-class
203/// failures to exit 2; doc validation also maps to exit 2.
204#[derive(Debug, Clone, PartialEq, thiserror::Error)]
205#[non_exhaustive]
206pub enum ScenarioFailure {
207 /// Nothing reached the partner before the deadline (verdict
208 /// class, `receive-timeout`).
209 #[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}{lanes}")]
210 ReceiveTimeout {
211 /// The endpoint URI that delivered nothing.
212 endpoint: String,
213 /// The deadline that elapsed.
214 deadline: Duration,
215 /// Rendered lane evidence (already redacted, ADR-0051): the
216 /// `; no arrival matched; lanes recorded: [...]` suffix, or
217 /// empty when the construction site saw no lanes.
218 lanes: String,
219 },
220 /// A validation failed (verdict class, `validation-mismatch`).
221 #[error("validation-mismatch: action {action}: {detail}")]
222 ValidationMismatch {
223 /// Index of the failing action, zero-based.
224 action: usize,
225 /// What was expected and what arrived.
226 detail: String,
227 },
228 /// A referenced variable was never set (verdict class,
229 /// `scenario-var-unresolved`).
230 #[error("scenario-var-unresolved: {name}")]
231 VarUnresolved {
232 /// The variable name no `extract` ever set.
233 name: String,
234 },
235 /// A send or receive failed at the transport before any assertion
236 /// ran (apparatus class, `action-transport-failure`).
237 #[error("action-transport-failure: action {action}: {source}")]
238 ActionTransport {
239 /// Index of the failing action, zero-based.
240 action: usize,
241 /// The transport failure.
242 source: TransportError,
243 },
244 /// A partner listener bound but its handler failed to start
245 /// (apparatus class, `partner-startup-failure`). Reserved in v1:
246 /// no adapter separates bind from handler start, and the CLI maps
247 /// bind failures to `partner-bind-failure` doc errors.
248 #[error("partner-startup-failure: {message}")]
249 PartnerStartup {
250 /// Startup failure detail.
251 message: String,
252 },
253 /// Teardown of the boot or a partner timed out or erred after the
254 /// verdict was recorded (apparatus class, `shutdown-failure`).
255 #[error("shutdown-failure: {message}")]
256 ShutdownFailure {
257 /// Teardown failure detail.
258 message: String,
259 },
260}
261
262/// Fills the harness bind variables into `vars` (ADR-0069 §9): every
263/// wired reference with `provisioning: harness` and a `bindVar` gets
264/// its partner's bound `host:port` authority from the router, so a
265/// scenario string can address the partner as
266/// `http://${NAME}/path`.
267///
268/// Two-layer split: the scenario variable carries `host:port` only;
269/// the env-tier binding that route files interpolate keeps its
270/// `http://host:port` form (owned by the CLI driver, unchanged here).
271/// A reference with no registered adapter or no bound authority is
272/// skipped: the variable stays unset, and a later use fails with the
273/// verdict-class `VarUnresolved`.
274pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
275 for reference in wired {
276 if reference.provisioning != Some(Provisioning::Harness) {
277 continue;
278 }
279 let Some(bind_var) = reference.bind_var.as_deref() else {
280 continue;
281 };
282 let Some(authority) = router
283 .adapter(&reference.endpoint)
284 .and_then(|adapter| adapter.bound_authority())
285 else {
286 continue;
287 };
288 vars.set(bind_var, Value::String(authority));
289 }
290}
291
292/// Runs a scenario's actions in order against the router.
293///
294/// On success every action completed; on failure the variant names
295/// the ADR-0069 §7 class. `vars` carries extraction results and
296/// last-received state both into and out of the run.
297pub async fn run_scenario(
298 doc: &ScenarioDocument,
299 router: &PartnerRouter,
300 vars: &mut ScenarioVars,
301) -> Result<ScenarioVerdict, ScenarioFailure> {
302 // The scenario-start anchor every `elapsedAtLeast` bound measures
303 // against; taken once per run, before the first action.
304 let started_at = Instant::now();
305 for (index, action) in doc.scenario.iter().enumerate() {
306 run_action(action, index, router, vars, started_at).await?;
307 }
308 Ok(ScenarioVerdict::Pass)
309}
310
311/// The outcome of executing a whole scenario document
312/// (ADR-0069 sections 5 and 7).
313///
314/// [`run_scenario_document`](self::run_scenario_document) fills
315/// `per_action` with one outcome per executed action and stops at the
316/// first failure; `verdict` is `Some(Pass)` only when every action
317/// passed. `final_failure` is the post-verdict slot: the caller that
318/// owns the boot (the CLI, after `BootHandle::shutdown`) records a
319/// `ShutdownFailure` there without masking the recorded verdict.
320#[derive(Debug, Clone, PartialEq)]
321pub struct DocumentOutcome {
322 /// One outcome per executed action, in action order; actions after
323 /// the first failure never ran.
324 pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
325 /// `Some(Pass)` when every action completed; `None` after any
326 /// failure.
327 pub verdict: Option<ScenarioVerdict>,
328 /// Post-verdict shutdown failure, recorded by the boot-owning
329 /// caller; empty when teardown is clean or never ran.
330 pub final_failure: Option<ScenarioFailure>,
331}
332
333/// Executes a scenario document's actions in order against the
334/// router, one recorded outcome per action, stopping at the first
335/// failure (the whole-document contract, library-level).
336///
337/// Partners route through `router`; a `send` addressed to a context
338/// component reaches the booted system under test through the
339/// context-stimulus adapter the caller registered for that endpoint
340/// (see [`crate::adapters`]). The single-action
341/// [`run_scenario`] loop and this loop share [`run_action`].
342pub async fn run_scenario_document(
343 doc: &ScenarioDocument,
344 router: &PartnerRouter,
345 vars: &mut ScenarioVars,
346) -> DocumentOutcome {
347 // The scenario-start anchor every `elapsedAtLeast` bound measures
348 // against; taken once per run, before the first action.
349 let started_at = Instant::now();
350 let mut per_action = Vec::with_capacity(doc.scenario.len());
351 let mut failed = false;
352 for (index, action) in doc.scenario.iter().enumerate() {
353 if failed {
354 break;
355 }
356 match run_action(action, index, router, vars, started_at).await {
357 Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
358 Err(failure) => {
359 per_action.push(Err(failure));
360 failed = true;
361 }
362 }
363 }
364 let verdict = if failed {
365 None
366 } else {
367 Some(ScenarioVerdict::Pass)
368 };
369 DocumentOutcome {
370 per_action,
371 verdict,
372 final_failure: None,
373 }
374}
375
376/// Executes one action at its scenario index. The shared primitive of
377/// [`run_scenario`] and [`run_scenario_document`]; every failure
378/// carries the action index.
379async fn run_action(
380 action: &ScenarioAction,
381 index: usize,
382 router: &PartnerRouter,
383 vars: &mut ScenarioVars,
384 started_at: Instant,
385) -> Result<(), ScenarioFailure> {
386 match action {
387 ScenarioAction::Send {
388 to,
389 body,
390 headers,
391 method,
392 } => {
393 send_action(
394 index,
395 to,
396 body.as_ref(),
397 headers.as_ref(),
398 method,
399 router,
400 vars,
401 )
402 .await?;
403 }
404 ScenarioAction::Receive {
405 from,
406 deadline,
407 extract,
408 } => {
409 receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
410 }
411 ScenarioAction::Sleep { duration } => {
412 tokio::time::sleep(*duration).await;
413 }
414 ScenarioAction::Validate { .. } => {
415 validate_action(action, index, started_at, router, vars).await?;
416 }
417 }
418 Ok(())
419}
420
421/// Dispatches a `send` action, bounded by [`SEND_DEADLINE`].
422///
423/// The endpoint reference, the body's string leaves, and the header
424/// values are the complete interpolation surface: each resolves its
425/// `${name}` placeholders against `vars` before dispatch, and an
426/// unresolved variable fails with the verdict-class `VarUnresolved`.
427/// The dial target comes from the router's address math: a
428/// harness-declared `:0` reference (or a dynamic reference resolving
429/// to a partner authority) dials the partner's bound address with the
430/// interpolated path preserved; anything else dials the interpolated
431/// URI literally.
432async fn send_action(
433 index: usize,
434 to: &EndpointRef,
435 body: Option<&Value>,
436 headers: Option<&BTreeMap<String, Value>>,
437 method: &str,
438 router: &PartnerRouter,
439 vars: &ScenarioVars,
440) -> Result<(), ScenarioFailure> {
441 let declared = to.endpoint.as_str();
442 let interpolated = resolve_placeholders(declared, vars)?;
443 let body = body
444 .map(|value| interpolate_value(value, vars))
445 .transpose()?;
446 let headers = headers
447 .map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
448 map.iter()
449 .map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
450 .collect()
451 })
452 .transpose()?;
453 let msg = OutgoingMessage {
454 body: body.unwrap_or(Value::Null),
455 headers: headers.unwrap_or_default(),
456 method: method.to_string(),
457 };
458 let bounded =
459 tokio::time::timeout(SEND_DEADLINE, router.send(declared, &interpolated, msg)).await;
460 let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
461 action: index,
462 source: TransportError::Deadline {
463 after: SEND_DEADLINE,
464 },
465 })?;
466 sent.map_err(|source| ScenarioFailure::ActionTransport {
467 action: index,
468 source,
469 })
470}
471
472/// Awaits a `receive` action until the deadline, records the message,
473/// and applies `extract` into `vars`.
474async fn receive_action(
475 index: usize,
476 from: &EndpointRef,
477 deadline: Duration,
478 extract: Option<&BTreeMap<String, String>>,
479 router: &PartnerRouter,
480 vars: &mut ScenarioVars,
481) -> Result<(), ScenarioFailure> {
482 // The lane is read under the two-key contract: the declared
483 // string names the registered lane when it can, and the
484 // interpolated URI resolves a dynamic reference's lane by
485 // authority (`lane_key_for`).
486 let declared = from.endpoint.as_str();
487 let interpolated = resolve_placeholders(declared, vars)?;
488 let message = router
489 .receive(declared, &interpolated, deadline)
490 .await
491 .map_err(|source| match source {
492 // Render-site defense: a third-party adapter may hand
493 // over RAW endpoint and lane evidence; the runner holds
494 // the secret set, and redaction is idempotent on
495 // already-masked output (ADR-0051).
496 ReceiveError::Timeout(timeout) => {
497 let keys = router.secret_query_keys();
498 ScenarioFailure::ReceiveTimeout {
499 endpoint: redact_wire_path(&timeout.endpoint, &keys),
500 deadline,
501 lanes: lanes_suffix(
502 &timeout
503 .lanes_recorded
504 .iter()
505 .map(|lane| redact_wire_path(lane, &keys))
506 .collect::<Vec<_>>(),
507 ),
508 }
509 }
510 ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
511 action: index,
512 source,
513 },
514 })?;
515 if let Some(extract) = extract {
516 for (name, selector) in extract {
517 let value = select_from(&message, selector).ok_or_else(|| {
518 ScenarioFailure::ValidationMismatch {
519 action: index,
520 detail: format!(
521 "extract of `{selector}` into variable `{name}` resolved to nothing"
522 ),
523 }
524 })?;
525 vars.set(name.clone(), value);
526 }
527 }
528 vars.remember(from.endpoint.clone(), message);
529 Ok(())
530}
531
532/// Evaluates a `validate` action (ADR-0069 §5).
533///
534/// The `partner` target asserts the exact filtered count of the
535/// requests the harness partner recorded, read as the router's
536/// snapshot — one immediate read without a deadline, a polled one
537/// with it ([`partner_validate_action`]). Every other target applies
538/// the message grammar against `vars`; the deadline is partner-only
539/// (the grammar rejected it on these targets at parse time, so the
540/// message arm ignores it). An `elapsedAtLeast` bound on a
541/// `lastReceived` target checks the message's wire arrival against
542/// the scenario-start anchor before the grammar runs; the grammar
543/// rejected it on every other target at parse time. Mismatch details
544/// name the validation subject — the variable's name, the receiving
545/// endpoint, or the partner URI — so a corrupted-header regression is
546/// diagnosable from the failure text.
547async fn validate_action(
548 action: &ScenarioAction,
549 index: usize,
550 started_at: Instant,
551 router: &PartnerRouter,
552 vars: &ScenarioVars,
553) -> Result<(), ScenarioFailure> {
554 // run_action dispatches only the Validate variant here; the
555 // fallback mirrors the impossible pairing arms below.
556 let ScenarioAction::Validate {
557 target,
558 expectation,
559 deadline,
560 elapsed_at_least,
561 } = action
562 else {
563 return Err(unpaired_validate(index));
564 };
565 match (target, expectation) {
566 // The parser pairs a `partner` target with the partner count
567 // grammar; this arm reads the router's snapshot and owns the
568 // deadline.
569 (ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
570 partner_validate_action(index, &endpoint.endpoint, expected, *deadline, router).await
571 }
572 (_, ValidateExpectation::Message(expectation)) => {
573 let (value, subject) = match target {
574 ScenarioTarget::LastReceived(endpoint) => {
575 // The declared endpoint may carry query bytes: the
576 // subject renders redacted like every diagnostic
577 // that quotes a wire path (ADR-0051).
578 let redacted =
579 redact_wire_path(&endpoint.endpoint, &router.secret_query_keys());
580 let message = vars.last_received(&endpoint.endpoint).ok_or_else(|| {
581 ScenarioFailure::ValidationMismatch {
582 action: index,
583 detail: format!(
584 "no message has been received on {redacted} to validate"
585 ),
586 }
587 })?;
588 // The elapsed bound anchors to the message's wire
589 // arrival, never the consumption time: a message
590 // consumed late can still have arrived early (the
591 // wire is the proof, ADR-0069 §5).
592 if let Some(bound) = elapsed_at_least {
593 let actual = message
594 .arrival
595 .checked_duration_since(started_at)
596 .unwrap_or_default();
597 if actual < *bound {
598 return Err(ScenarioFailure::ValidationMismatch {
599 action: index,
600 detail: format!(
601 "{redacted}: arrived {} after the scenario started; `elapsedAtLeast` requires {}",
602 humantime::format_duration(actual),
603 humantime::format_duration(*bound)
604 ),
605 });
606 }
607 }
608 (
609 message.body.clone(),
610 format!("body last received on {redacted}"),
611 )
612 }
613 ScenarioTarget::Variable(name) => (
614 vars.get(name)
615 .cloned()
616 .ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
617 format!("variable `{name}`"),
618 ),
619 // Taken by the arm above: the grammar never pairs a
620 // `partner` target with the message expectation.
621 ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
622 };
623 match expectation {
624 Expectation::Equals(expected) => {
625 if &value == expected {
626 Ok(())
627 } else {
628 Err(ScenarioFailure::ValidationMismatch {
629 action: index,
630 detail: format!("{subject}: expected {expected}, got {value}"),
631 })
632 }
633 }
634 Expectation::Regex(pattern) => {
635 let regex = regex::Regex::new(pattern).map_err(|error| {
636 ScenarioFailure::ValidationMismatch {
637 action: index,
638 detail: format!("invalid regex `{pattern}`: {error}"),
639 }
640 })?;
641 if regex.is_match(&stringify(&value)) {
642 Ok(())
643 } else {
644 Err(ScenarioFailure::ValidationMismatch {
645 action: index,
646 detail: format!("{subject}: `{pattern}` did not match {value}"),
647 })
648 }
649 }
650 Expectation::Contains(needle) => check(
651 index,
652 stringify(&value).contains(needle),
653 format!("{subject}: did not contain `{needle}`: {value}"),
654 ),
655 Expectation::StartsWith(prefix) => check(
656 index,
657 stringify(&value).starts_with(prefix),
658 format!("{subject}: did not start with `{prefix}`: {value}"),
659 ),
660 Expectation::EndsWith(suffix) => check(
661 index,
662 stringify(&value).ends_with(suffix),
663 format!("{subject}: did not end with `{suffix}`: {value}"),
664 ),
665 Expectation::Exists => {
666 if value == Value::Null {
667 Err(ScenarioFailure::ValidationMismatch {
668 action: index,
669 detail: format!("{subject}: expected a value, got null"),
670 })
671 } else {
672 Ok(())
673 }
674 }
675 Expectation::JsonSubset(pattern) => check(
676 index,
677 json_subset(pattern, &value),
678 format!("{subject}: not a superset of {pattern}: {value}"),
679 ),
680 }
681 }
682 // The parser never pairs a partner expectation with a
683 // non-partner target.
684 _ => Err(unpaired_validate(index)),
685 }
686}
687
688/// The failure for a target/expectation pairing the grammar never
689/// produces: the parser pairs `partner` targets with the partner
690/// count grammar and every other target with the message grammar, so
691/// only a caller bypassing the parser reaches these arms.
692fn unpaired_validate(index: usize) -> ScenarioFailure {
693 ScenarioFailure::ValidationMismatch {
694 action: index,
695 detail: "validate target kind does not pair with the expectation kind".to_string(),
696 }
697}
698
699/// Turns a validation predicate into a [`ScenarioFailure`] on `false`.
700fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
701 if passed {
702 Ok(())
703 } else {
704 Err(ScenarioFailure::ValidationMismatch {
705 action: index,
706 detail,
707 })
708 }
709}
710
711/// Reads a value out of a received message by dotted selector.
712///
713/// Grammar: the first segment selects `body`, `headers`, `status`,
714/// `method`, or `path`; the rest is a literal header name
715/// (`headers.X-Id`, dots allowed in the name) or a dotted object path
716/// under the body (`body.user.id`). A bare `body` or `headers` selects
717/// the whole part.
718///
719/// Header lookup is ASCII-case-insensitive: adapters differ in header
720/// casing (hyper lowercases wire names; the fake preserves author
721/// casing), and the same selector must behave identically per adapter.
722/// Wire recording stays lowercase.
723fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
724 let (head, rest) = match selector.split_once('.') {
725 Some((head, rest)) => (head, Some(rest)),
726 None => (selector, None),
727 };
728 match head {
729 "body" => match rest {
730 None => Some(message.body.clone()),
731 Some(path) => walk_path(&message.body, path),
732 },
733 "headers" => match rest {
734 None => Some(Value::Object(
735 message
736 .headers
737 .iter()
738 .map(|(name, value)| (name.clone(), value.clone()))
739 .collect(),
740 )),
741 Some(name) => lookup_header(&message.headers, name),
742 },
743 // The transport-scalar heads carry no sub-path: `status.why` is
744 // not part of the grammar and resolves to nothing.
745 "status" if rest.is_none() => Some(
746 message
747 .status
748 .map_or(Value::Null, |code| Value::Number(code.into())),
749 ),
750 "method" if rest.is_none() => {
751 Some(message.method.clone().map_or(Value::Null, Value::String))
752 }
753 "path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
754 _ => None,
755 }
756}
757
758/// Case-insensitive header lookup: the first header whose name matches
759/// the selector ASCII-case-insensitively wins; header maps are
760/// case-unique per adapter, so the fold is deterministic.
761fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
762 headers
763 .iter()
764 .find(|(key, _)| key.eq_ignore_ascii_case(name))
765 .map(|(_, value)| value.clone())
766}
767
768/// Walks a dotted object path under a body value; arrays and scalars
769/// resolve to nothing.
770fn walk_path(value: &Value, path: &str) -> Option<Value> {
771 let mut current = value;
772 for key in path.split('.') {
773 current = current.as_object()?.get(key)?;
774 }
775 Some(current.clone())
776}
777
778/// Renders a value for string matchers: strings as-is, anything else
779/// as its JSON form.
780fn stringify(value: &Value) -> String {
781 match value {
782 Value::String(text) => text.clone(),
783 other => other.to_string(),
784 }
785}
786
787/// Recursive-subset match: every key in `pattern` must exist in
788/// `actual` with a recursively subset-matching value; values outside
789/// `pattern` are ignored. Non-object patterns compare by equality.
790fn json_subset(pattern: &Value, actual: &Value) -> bool {
791 match (pattern, actual) {
792 (Value::Object(pattern_object), Value::Object(actual_object)) => {
793 pattern_object.iter().all(|(key, pattern_value)| {
794 actual_object
795 .get(key)
796 .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
797 })
798 }
799 _ => pattern == actual,
800 }
801}