Skip to main content

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}