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