Skip to main content

camel_integration_test/
document.rs

1//! Scenario document model, parsing, and validation (ADR-0069 sections
2//! 1-2).
3//!
4//! A scenario document is a `.test.yaml` (or `.test.yml`) sidecar that
5//! declares one integration-tier test: exactly one route source
6//! (`routeFiles`, `routeFilesFromRoot`, or inline `routes`), an ordered
7//! `scenario:` action list, an optional `env:` map with fixed fixture
8//! values, an optional `envPassthrough:` allowlist, an optional
9//! endpoint-keyed `partners:` scripting map, an optional pinned
10//! `profile`, an optional document-level `sendDeadline` bounding
11//! every send, and an optional document-level `inbound:` listener
12//! declaration (feature `http`). Unknown fields are rejected.
13//!
14//! The scenario vocabulary and the unit-tier vocabulary (`inputs`,
15//! `expects`, `intercepts`) never mix in one document. A document with
16//! `scenario:` that also declares a unit-tier section is rejected at
17//! load time.
18//!
19//! Durations (`sendDeadline`, `deadline`, `duration`,
20//! `elapsedAtLeast`) are humantime strings, for example `"5s"` or
21//! `"250ms"`, parsed during validation so errors can name the action
22//! index.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use camel_api::Value;
29use camel_core::RouteDefinition;
30use noyalib::compat::serde_yaml;
31use serde::de::Error as _;
32use serde::{Deserialize, Deserializer};
33
34// The partner-script grammar lives in its own module; the public
35// types are re-exported here so the document API stays one surface.
36pub use crate::partner_script::{PartnerFault, PartnerScript, PartnerScriptResponse};
37// The matcher algebra lives in the shared pure core (camel-matchers);
38// re-exported here so the document API stays one surface. The raw
39// serde stage below constructs these core types directly.
40pub use camel_matchers::RequestExpectation as PartnerExpectation;
41pub use camel_matchers::{CountBound, Expectation, PathFilter};
42
43// ---------------------------------------------------------------------------
44// Public model
45// ---------------------------------------------------------------------------
46
47/// A parsed scenario document. Route file paths stay as declared;
48/// resolving them against the document directory or the project root is
49/// the runner's job, the same split the unit-tier parser keeps.
50#[derive(Debug)]
51pub struct ScenarioDocument {
52    /// The document's own path as parsed. The boot root may be a
53    /// nearest-ancestor `Camel.toml` directory rather than the
54    /// document's directory, so the document directory travels with
55    /// the model: relative `routeFiles` anchor here (rc-jjzy5).
56    pub source_path: std::path::PathBuf,
57    /// The single declared route source.
58    pub route_source: RouteSource,
59    /// Ordered scenario actions.
60    pub scenario: Vec<ScenarioAction>,
61    /// Document-level partner scripting, keyed by endpoint address.
62    /// The grammar lives here; the runner consumes the map.
63    pub partners: Option<BTreeMap<String, Vec<PartnerScript>>>,
64    /// Fixed fixture values for the scenario; the layered environment
65    /// source reads these before any ambient value.
66    pub env: Option<BTreeMap<String, String>>,
67    /// Ambient variable names allowed to pass through to the scenario.
68    pub env_passthrough: Option<Vec<String>>,
69    /// Profile pinned per document; an ambient profile would break
70    /// hermeticity.
71    pub profile: Option<String>,
72    /// Document-level bound for every `send` action (rc-tr4w): an
73    /// optional tighter deadline than the runner's thirty-second
74    /// default, real time only (ADR-0069 §6).
75    pub send_deadline: Option<Duration>,
76    /// The document-level `inbound:` declaration (rc-5yon): the
77    /// harness binds `127.0.0.1:0`, stages the listener on the HTTP
78    /// component's global registry (ADR-0070), and exposes the bound
79    /// address under the named bind variable so route URIs interpolate
80    /// it. Provisioning runs behind the `http` feature; a declaration
81    /// in a build without the feature is a named load error (ADR-0069
82    /// §8 demand-gated activation).
83    pub inbound: Option<InboundListener>,
84}
85
86/// The route source of a scenario document. Exactly one form is
87/// declared; the parser rejects zero or multiple declarations.
88///
89/// Not `Clone`: the inline form carries `RouteDefinition`s, which are
90/// not `Clone`.
91#[non_exhaustive]
92pub enum RouteSource {
93    /// Route files to load, relative to the document's directory.
94    RouteFiles(Vec<PathBuf>),
95    /// Route files to load, resolved against the nearest ancestor
96    /// `Camel.toml` directory (the project root).
97    RouteFilesFromRoot(Vec<PathBuf>),
98    /// Inline route definitions, parsed at load time.
99    Inline(Vec<RouteDefinition>),
100}
101
102impl std::fmt::Debug for RouteSource {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            // `RouteDefinition` implements neither `Debug` nor `Clone`;
106            // the inline form reports its route count only.
107            Self::RouteFiles(files) => f.debug_tuple("RouteFiles").field(files).finish(),
108            Self::RouteFilesFromRoot(files) => {
109                f.debug_tuple("RouteFilesFromRoot").field(files).finish()
110            }
111            Self::Inline(routes) => f
112                .debug_tuple("Inline")
113                .field(&format_args!("{} route definitions", routes.len()))
114                .finish(),
115        }
116    }
117}
118
119/// One ordered scenario action (ADR-0069 section 11, adopted from
120/// Citrus: `send`, `receive` with a mandatory deadline, `sleep`,
121/// `validate`).
122#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum ScenarioAction {
125    /// Send a message to an endpoint.
126    Send {
127        /// Target endpoint reference.
128        to: EndpointRef,
129        /// Message body; omitted means an empty body.
130        body: Option<Value>,
131        /// Message headers.
132        headers: Option<BTreeMap<String, Value>>,
133        /// Resolved method: explicit or inferred (`POST` with a body,
134        /// `GET` without), uppercase.
135        method: String,
136        /// Reply assertion for `direct:` sends (rc-qvz6): the same
137        /// matcher grammar `validate` parses, evaluated against the
138        /// synchronous route reply the context-stimulus adapter
139        /// returns. Load-time rejected on every other scheme.
140        expect_reply: Option<Expectation>,
141    },
142    /// Receive a message from an endpoint before the deadline passes.
143    Receive {
144        /// Source endpoint reference.
145        from: EndpointRef,
146        /// Mandatory deadline, real monotonic time.
147        deadline: Duration,
148        /// Extractions into scenario variables, keyed by variable name.
149        extract: Option<BTreeMap<String, String>>,
150    },
151    /// Pause the scenario for the given duration.
152    Sleep {
153        /// Sleep length.
154        duration: Duration,
155    },
156    /// Assert an expectation against a scenario target.
157    Validate {
158        /// What to validate: the last message received on an endpoint,
159        /// a scenario variable, or a partner's recorded traffic.
160        target: ScenarioTarget,
161        /// Matcher expectation: the message grammar for `lastReceived`
162        /// and `variable` targets, the partner count grammar for
163        /// `partner` targets.
164        expectation: ValidateExpectation,
165        /// Optional poll deadline. Only valid on `partner` targets,
166        /// whose counts settle asynchronously; without it the partner
167        /// assertion reads one immediate snapshot.
168        deadline: Option<Duration>,
169        /// Optional minimum wire-arrival age. Only valid on
170        /// `lastReceived` targets: the last received message must have
171        /// arrived at least this long after the scenario started (the
172        /// not-before-X control `run.sh` expresses with `awk`). The
173        /// assertion anchors to the message's wire arrival, never the
174        /// consumption time.
175        elapsed_at_least: Option<Duration>,
176    },
177}
178
179impl ScenarioAction {
180    /// The `(bind variable, endpoint)` bindings this action's endpoint
181    /// references declare.
182    fn bindings(&self) -> Vec<(&str, &str)> {
183        fn endpoint_bindings(endpoint: &EndpointRef) -> Vec<(&str, &str)> {
184            endpoint.binding().into_iter().collect()
185        }
186        match self {
187            Self::Send { to, .. } => endpoint_bindings(to),
188            Self::Receive { from, .. } => endpoint_bindings(from),
189            Self::Validate { target, .. } => match target {
190                ScenarioTarget::LastReceived(endpoint) => endpoint_bindings(endpoint),
191                ScenarioTarget::Partner(_) => Vec::new(),
192                ScenarioTarget::Variable(_) => Vec::new(),
193            },
194            Self::Sleep { .. } => Vec::new(),
195        }
196    }
197}
198
199/// What a `validate` action asserts against.
200#[derive(Debug, Clone, PartialEq)]
201#[non_exhaustive]
202pub enum ScenarioTarget {
203    /// The last message received on the endpoint.
204    LastReceived(EndpointRef),
205    /// A scenario variable set by an earlier `extract`. Variable
206    /// existence is validated at run time.
207    Variable(String),
208    /// A partner endpoint: the assertion reads the partner's recorded
209    /// request traffic. The URI must equal a harness endpoint
210    /// reference declared by the scenario's own `send`/`receive`
211    /// actions.
212    Partner(EndpointRef),
213}
214
215/// An endpoint reference: a bare endpoint string or a map with
216/// `endpoint`, `provisioning`, and `bindVar` keys.
217#[derive(Debug, Clone, PartialEq)]
218pub struct EndpointRef {
219    /// Endpoint URI, for example `http://127.0.0.1:9999/hook`.
220    pub endpoint: String,
221    /// Who owns the partner lifecycle; only `harness` is implemented in
222    /// v1.
223    pub provisioning: Option<Provisioning>,
224    /// Scenario variable name the harness fills with this endpoint's
225    /// bound address when provisioning is `harness`.
226    pub bind_var: Option<String>,
227}
228
229impl EndpointRef {
230    /// The `(bind variable, endpoint)` binding this reference declares,
231    /// if any. The reserved env-key rule collects these pairs.
232    fn binding(&self) -> Option<(&str, &str)> {
233        self.bind_var
234            .as_deref()
235            .map(|bind_var| (bind_var, self.endpoint.as_str()))
236    }
237}
238
239/// Partner provisioning source (ADR-0069 section 9). The axis is who
240/// owns the lifecycle. `testcontainer` and `user-provided` are reserved
241/// grammar values; the parser rejects them.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243#[non_exhaustive]
244pub enum Provisioning {
245    /// The harness binds an in-process listener on `127.0.0.1:0`. The
246    /// only source implemented in v1.
247    Harness,
248}
249
250/// The document's `inbound:` declaration (rc-5yon): v1 grammar is a
251/// single map `inbound: {bindVar: NAME}`. The harness provisions one
252/// listener per document, binds `127.0.0.1:0`, stages it on the HTTP
253/// component's global registry (ADR-0070 staged consumption), and
254/// fills the bind variable with `http://<bound-address>` so route
255/// consumer URIs interpolate the staged socket.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct InboundListener {
258    /// Scenario variable name the harness fills with the staged
259    /// listener's `http://<bound-address>` URL.
260    pub bind_var: String,
261}
262
263/// The scripted responses a document's `partners:` entry maps to, for
264/// one endpoint key. `None` when the document declares no entry for
265/// the endpoint — the caller binds a permissive partner. `Some` maps
266/// each script grammar entry to its wire form: absent `status`
267/// defaults to 200, absent `times` to 1 (serve once), absent headers
268/// to the empty map, and the body follows the client send path's
269/// `value_to_wire` encoding (empty when absent); `delay` and `fault`
270/// map through.
271///
272/// The canonical `PartnerScript` → wire-form mapping; the CLI driver
273/// and library-level scenarios bind partners through this function so
274/// the semantics live in exactly one place.
275#[cfg(feature = "http")]
276pub fn partner_scripts_for(
277    doc: &ScenarioDocument,
278    endpoint: &str,
279) -> Option<Vec<crate::adapters::http::ScriptedResponse>> {
280    use crate::adapters::http::ScriptedResponse;
281    let scripts = doc.partners.as_ref()?.get(endpoint)?;
282    Some(
283        scripts
284            .iter()
285            .map(|script| {
286                let (status, headers, body) = match script.response.as_ref() {
287                    Some(response) => (
288                        response.status.unwrap_or(200),
289                        response.headers.clone().unwrap_or_default(),
290                        response.body.as_ref().map_or_else(Vec::new, |value| {
291                            crate::adapters::http::value_to_wire(value)
292                        }),
293                    ),
294                    // Fault entries carry no response; the placeholder
295                    // keeps the wire form — serve checks the fault
296                    // first, so the placeholder never reaches the wire.
297                    None => (200, BTreeMap::new(), Vec::new()),
298                };
299                ScriptedResponse {
300                    method: script.method.clone(),
301                    path: script.path.clone(),
302                    times: script.times.unwrap_or(1),
303                    delay: script.delay,
304                    fault: script.fault.clone(),
305                    status,
306                    headers,
307                    body,
308                }
309            })
310            .collect(),
311    )
312}
313
314/// The expectation of a `validate` action, keyed by its target: the
315/// message matcher grammar for `lastReceived` and `variable` targets,
316/// the partner count grammar for `partner` targets.
317#[derive(Debug, Clone, PartialEq)]
318#[non_exhaustive]
319pub enum ValidateExpectation {
320    /// Message matcher expectation (`lastReceived` / `variable`).
321    Message(Expectation),
322    /// Partner request-count expectation (`partner`).
323    Partner(PartnerExpectation),
324}
325
326// ---------------------------------------------------------------------------
327// Raw serde stage
328// ---------------------------------------------------------------------------
329
330/// Raw document form. Unit-tier sections are captured, not rejected at
331/// the serde layer, so the mixing ban can name them. Scenario items
332/// stay raw values: the single-key action dispatch runs during
333/// validation so errors can name the action index.
334#[derive(Deserialize)]
335#[serde(deny_unknown_fields, rename_all = "camelCase")]
336struct RawDocument {
337    route_files: Option<Vec<String>>,
338    route_files_from_root: Option<Vec<String>>,
339    routes: Option<serde_yaml::Value>,
340    scenario: Option<Vec<serde_yaml::Value>>,
341    env: Option<BTreeMap<String, String>>,
342    env_passthrough: Option<Vec<String>>,
343    profile: Option<String>,
344    // Document-level partner scripting: the raw map stays
345    // endpoint-keyed with raw sequence values; conversion runs during
346    // validation so errors can name the entry key.
347    partners: Option<BTreeMap<String, serde_yaml::Value>>,
348    // Document-level send bound: raw humantime string; parsed during
349    // validation so the error names the field.
350    send_deadline: Option<String>,
351    // Document-level inbound listener declaration: raw node; the
352    // grammar walk runs during validation so unknown fields name
353    // themselves in every build, and the `http` feature gate fires
354    // after structure (ADR-0069 §8 demand-gated activation).
355    inbound: Option<serde_yaml::Value>,
356    // Unit-tier vocabulary, present only to detect and name the mixing
357    // ban violation.
358    inputs: Option<serde_yaml::Value>,
359    expects: Option<serde_yaml::Value>,
360    intercepts: Option<serde_yaml::Value>,
361}
362
363#[derive(Deserialize)]
364#[serde(deny_unknown_fields, rename_all = "camelCase")]
365struct RawSend {
366    to: RawEndpointRef,
367    body: Option<Value>,
368    headers: Option<BTreeMap<String, Value>>,
369    /// Raw `method` string; optional. Validation resolves it (explicit
370    /// or inferred from body presence) so errors can name the action
371    /// index.
372    method: Option<String>,
373    /// Raw `expectReply` node; optional, `direct:` sends only.
374    /// Validation converts it through the same matcher grammar
375    /// `validate` uses, and rejects it on every other scheme.
376    expect_reply: Option<Value>,
377}
378
379#[derive(Deserialize)]
380#[serde(deny_unknown_fields, rename_all = "camelCase")]
381struct RawReceive {
382    from: RawEndpointRef,
383    /// Raw humantime string; required by validation, not by serde, so
384    /// the error can name the action index.
385    deadline: Option<String>,
386    extract: Option<BTreeMap<String, String>>,
387}
388
389#[derive(Deserialize)]
390#[serde(deny_unknown_fields, rename_all = "camelCase")]
391struct RawSleep {
392    /// Raw humantime string.
393    duration: String,
394}
395
396#[derive(Deserialize)]
397#[serde(deny_unknown_fields, rename_all = "camelCase")]
398struct RawValidate {
399    /// Raw `target` node; the single-key form (`lastReceived` /
400    /// `variable` / `partner`) converts during validation.
401    target: serde_yaml::Value,
402    expectation: Value,
403    /// Raw humantime string; partner targets only, parsed during
404    /// validation so the error can name the action index.
405    deadline: Option<String>,
406    /// Raw humantime string; `lastReceived` targets only, parsed
407    /// during validation so the error can name the action index.
408    elapsed_at_least: Option<String>,
409}
410
411/// Raw endpoint reference: bare string or map with `endpoint`,
412/// `provisioning`, and `bindVar`.
413#[derive(Debug, Clone)]
414struct RawEndpointRef {
415    endpoint: String,
416    provisioning: Option<String>,
417    bind_var: Option<String>,
418}
419
420impl RawEndpointRef {
421    /// Deserializes from a bare string (shorthand) or a map.
422    fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
423        match value {
424            serde_yaml::Value::String(endpoint) => Ok(Self {
425                endpoint,
426                provisioning: None,
427                bind_var: None,
428            }),
429            serde_yaml::Value::Mapping(ref map) => {
430                // Field-by-field extraction: a hand-rolled map walk gives
431                // errors that name the offending key, which the
432                // deny_unknown_fields machinery of the compat shim
433                // cannot.
434                let mut endpoint: Option<String> = None;
435                let mut provisioning: Option<String> = None;
436                let mut bind_var: Option<String> = None;
437                for (key, value) in map {
438                    match key.as_str() {
439                        "endpoint" | "provisioning" | "bindVar" => {
440                            let text = value.as_str().ok_or_else(|| {
441                                format!(
442                                    "endpoint reference `{key}` must be a string, got {value:?}"
443                                )
444                            })?;
445                            match key.as_str() {
446                                "endpoint" => endpoint = Some(text.to_string()),
447                                "provisioning" => provisioning = Some(text.to_string()),
448                                _ => bind_var = Some(text.to_string()),
449                            }
450                        }
451                        other => {
452                            return Err(format!("unknown field `{other}` in endpoint reference"));
453                        }
454                    }
455                }
456                let endpoint = endpoint
457                    .ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
458                Ok(Self {
459                    endpoint,
460                    provisioning,
461                    bind_var,
462                })
463            }
464            other => Err(format!(
465                "endpoint reference must be a string or a map, got {other:?}"
466            )),
467        }
468    }
469}
470
471impl<'de> Deserialize<'de> for RawEndpointRef {
472    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
473    where
474        D: Deserializer<'de>,
475    {
476        let value = serde_yaml::Value::deserialize(deserializer)?;
477        RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Errors
483// ---------------------------------------------------------------------------
484
485/// Parse and validation errors for scenario documents.
486///
487/// Exit-code mapping for the CLI adapter (ADR-0069 section 7):
488/// classification is by variant, never by message text. Every variant
489/// is a load-time failure and maps to exit 2.
490///
491/// - `doc-validation` class — Display carries the `doc-validation:`
492///   token: `NotTestDocument`, `MissingScenario`, `MixedVocabulary`,
493///   `Validation`, `ReservedEnvKey`, `InlineRoutes`,
494///   `InlineRoutesRejected`, `ProvisioningWithoutAuthority`,
495///   `ExpectReplyOnUnsupportedSend`.
496/// - `infra-unavailable` class — `UnsupportedProvisioning` (reserved
497///   provisioning grammar; Display names the class).
498/// - Unit-tier message parity — `RouteSourceMissing` and
499///   `RouteSourceConflict` render the unit-tier parser's messages
500///   verbatim, without the token, so both parsers report identical
501///   text; the CLI maps them to exit 2 as doc parse errors, the same
502///   as the unit tier does today.
503/// - Read and serde failures — `Io`, `Yaml`, `UnknownField` map to
504///   exit 2 as doc parse errors (unreadable file, broken grammar).
505#[derive(Debug, thiserror::Error)]
506#[non_exhaustive]
507pub enum DocError {
508    /// The document file could not be read.
509    #[error("failed to read test document {path}: {source}")]
510    Io {
511        /// Path of the unreadable document.
512        path: PathBuf,
513        /// Underlying read failure.
514        source: std::io::Error,
515    },
516    /// Malformed YAML or a type mismatch at the serde layer.
517    #[error("invalid test document: {0}")]
518    Yaml(String),
519    /// A `deny_unknown_fields` rejection.
520    #[error("unknown field in test document: {0}")]
521    UnknownField(String),
522    /// The path lacks the reserved `.test.yaml` / `.test.yml` suffix.
523    #[error(
524        "doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
525    )]
526    NotTestDocument {
527        /// The rejected path.
528        path: PathBuf,
529    },
530    /// The document declares no `scenario:` section.
531    #[error("doc-validation: scenario document must declare a `scenario:` section")]
532    MissingScenario,
533    /// The document mixes the scenario vocabulary with unit-tier
534    /// sections.
535    #[error(
536        "doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
537    )]
538    MixedVocabulary {
539        /// The unit-tier fields found, backticked and comma-joined.
540        found: String,
541    },
542    /// No route source is declared. Same message as the unit-tier
543    /// parser.
544    #[error(
545        "exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
546    )]
547    RouteSourceMissing,
548    /// More than one route source is declared. Same message as the
549    /// unit-tier parser.
550    #[error("route sources {present} are mutually exclusive; exactly one route source is required")]
551    RouteSourceConflict {
552        /// The declared keys, backticked and comma-joined.
553        present: String,
554    },
555    /// An action failed validation; `index` is the position in the
556    /// `scenario:` list. An empty `scenario:` list is rejected with
557    /// index 0 (the section, not an action, failed).
558    #[error("doc-validation: scenario[{index}]: {message}")]
559    Validation {
560        /// Zero-based position of the action in the `scenario:` list.
561        index: usize,
562        /// What failed.
563        message: String,
564    },
565    /// The endpoint declares a provisioning source that is reserved in
566    /// v1; only `harness` is supported.
567    #[error(
568        "doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
569    )]
570    UnsupportedProvisioning {
571        /// The rejected provisioning value.
572        value: String,
573        /// The endpoint that declared it.
574        endpoint: String,
575    },
576    /// A `provisioning: harness` endpoint reference declares a
577    /// `bindVar` while its scheme (`direct:` or `fake:`) binds no
578    /// partner, so the variable would never receive a bound authority
579    /// and the entry fails later as a verdict-class var-resolution
580    /// error (rc-j87j). Rejected at load instead.
581    #[error(
582        "doc-validation: endpoint `{endpoint}` declares `bindVar` but its `{ref_scheme}:` reference binds no harness partner, so the variable would never receive a bound authority (exit-2 doc-validation class)"
583    )]
584    ProvisioningWithoutAuthority {
585        /// The endpoint whose reference cannot fill the variable.
586        endpoint: String,
587        /// The scheme of the endpoint reference (`direct` or `fake`).
588        ref_scheme: String,
589    },
590    /// A document `env` key equals an endpoint's `bindVar`. The
591    /// reserved set is exactly the `bindVar` values declared by the
592    /// document's own endpoints; the harness binding wins.
593    #[error(
594        "doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
595    )]
596    ReservedEnvKey {
597        /// The reserved key.
598        key: String,
599        /// The endpoint that reserved it.
600        endpoint: String,
601    },
602    /// A `partners` entry failed validation; `endpoint` is the entry
603    /// key of the failing script list.
604    #[error("doc-validation: partners[{endpoint}]: {message}")]
605    Partners {
606        /// The endpoint key of the failing entry.
607        endpoint: String,
608        /// What failed.
609        message: String,
610    },
611    /// Inline `routes` failed to parse.
612    #[error("doc-validation: inline routes: {0}")]
613    InlineRoutes(String),
614    /// The document's route source is inline `routes`. Inline
615    /// definitions cannot boot in v1; the author must declare
616    /// `routeFiles`. Rejected at load, before partners bind, instead
617    /// of failing the boot afterward (rc-9dpx).
618    #[error(
619        "doc-validation: inline `routes` are rejected at load: declare `routeFiles` instead (inline definitions cannot boot in the scenario tier; exit 2)"
620    )]
621    InlineRoutesRejected,
622    /// A send declares `expectReply` on a scheme that produces no
623    /// synchronous reply: only the context-stimulus `direct:` send
624    /// returns one. Partner sends (`http`/`https`) park their
625    /// roundtrips for a later `receive`, and `fake:` adapters record
626    /// sends without answering, so the assertion could never run
627    /// (rc-qvz6). Rejected at load, naming the action index, the
628    /// scheme, and the literal `expectReply` field.
629    #[error(
630        "doc-validation: scenario[{index}]: `expectReply` is only valid on a `direct:` send, not `{scheme}` (exit 2)"
631    )]
632    ExpectReplyOnUnsupportedSend {
633        /// Zero-based position of the action in the `scenario:` list.
634        index: usize,
635        /// The scheme of the send's endpoint reference.
636        scheme: String,
637    },
638}
639
640/// Classifies a compat-layer (serde_yaml) error text, mirroring the
641/// unit-tier classifier.
642fn classify_yaml_error(raw: &str) -> DocError {
643    if raw.contains("unknown field") {
644        return DocError::UnknownField(raw.to_string());
645    }
646    DocError::Yaml(raw.to_string())
647}
648
649// ---------------------------------------------------------------------------
650// Parsing
651// ---------------------------------------------------------------------------
652
653/// Parses and validates a scenario document. Validation order:
654/// (a) the path carries a reserved test-document suffix; (b) the text
655/// deserializes; (c) a non-empty `scenario:` section exists; (d) no
656/// unit-tier section coexists with it; (e) exactly one route source
657/// is declared, and it is not inline (`routes` cannot boot in v1, so
658/// the defect fails at load instead of at boot);
659/// (f) each action converts (single-key dispatch, deadlines, durations,
660/// endpoint provisioning, expectation grammar, the `direct:`-only
661/// `expectReply` gate) with action-index errors; (g) each `partners`
662/// entry converts (script grammar, response status range) with
663/// entry-key errors; (h) no `env` key collides with a declared
664/// `bindVar`; (i) each `partner` validate target URI equals
665/// a harness endpoint reference declared by the scenario's own
666/// `send`/`receive` actions. The optional `inbound:` section converts
667/// between (g) and (h): grammar in every build, activation
668/// demand-gated behind `http` (ADR-0069 §8).
669pub fn parse_scenario_document(path: &Path) -> Result<ScenarioDocument, DocError> {
670    if !camel_dsl::discovery::is_test_document(path) {
671        return Err(DocError::NotTestDocument {
672            path: path.to_path_buf(),
673        });
674    }
675    let text = std::fs::read_to_string(path).map_err(|source| DocError::Io {
676        path: path.to_path_buf(),
677        source,
678    })?;
679    let raw = serde_yaml::from_str::<RawDocument>(&text)
680        .map_err(|e| classify_yaml_error(&e.to_string()))?;
681
682    // (c) This parser accepts scenario documents only, and the
683    // scenario list must be non-empty: an empty list would yield a
684    // trivially-green FULL document with zero actions (mirrors the
685    // unit tier's non-empty `expects` rule).
686    let Some(raw_scenario) = raw.scenario else {
687        return Err(DocError::MissingScenario);
688    };
689    if raw_scenario.is_empty() {
690        return Err(DocError::Validation {
691            index: 0,
692            message: "`scenario` must declare at least one action".to_string(),
693        });
694    }
695    // (d) Mixing ban (ADR-0069 section 2).
696    let mut unit_tier: Vec<&str> = Vec::new();
697    if raw.inputs.is_some() {
698        unit_tier.push("inputs");
699    }
700    if raw.expects.is_some() {
701        unit_tier.push("expects");
702    }
703    if raw.intercepts.is_some() {
704        unit_tier.push("intercepts");
705    }
706    if !unit_tier.is_empty() {
707        return Err(DocError::MixedVocabulary {
708            found: backticked(&unit_tier),
709        });
710    }
711    // (e) Exactly one route source, with the unit-tier messages.
712    let mut present: Vec<&'static str> = Vec::new();
713    if raw.route_files.is_some() {
714        present.push("routeFiles");
715    }
716    if raw.route_files_from_root.is_some() {
717        present.push("routeFilesFromRoot");
718    }
719    if raw.routes.is_some() {
720        present.push("routes");
721    }
722    let route_source = match present.as_slice() {
723        ["routeFiles"] => RouteSource::RouteFiles(
724            raw.route_files
725                .unwrap_or_default()
726                .into_iter()
727                .map(PathBuf::from)
728                .collect(),
729        ),
730        ["routeFilesFromRoot"] => RouteSource::RouteFilesFromRoot(
731            raw.route_files_from_root
732                .unwrap_or_default()
733                .into_iter()
734                .map(PathBuf::from)
735                .collect(),
736        ),
737        ["routes"] => {
738            let value = raw.routes.unwrap_or(serde_yaml::Value::Null);
739            RouteSource::Inline(parse_inline_routes(&value)?)
740        }
741        [] => return Err(DocError::RouteSourceMissing),
742        _ => {
743            return Err(DocError::RouteSourceConflict {
744                present: backticked(&present),
745            });
746        }
747    };
748    // (e, rc-9dpx) Inline route sources cannot boot in v1; reject at
749    // load, before partners bind, instead of failing the boot after
750    // the composition root is up. The boot keeps its own rejection as
751    // defense-in-depth.
752    if matches!(route_source, RouteSource::Inline(_)) {
753        return Err(DocError::InlineRoutesRejected);
754    }
755    // (f) Action conversion.
756    let mut scenario = Vec::with_capacity(raw_scenario.len());
757    for (index, item) in raw_scenario.into_iter().enumerate() {
758        scenario.push(build_action(item, index)?);
759    }
760    // (g) Partner scripting: entries convert from the raw sequence
761    // with the entry key named on every failure; an empty sequence is
762    // a valid, inert entry. The grammar conversion lives in the
763    // partner-script module.
764    let partners = crate::partner_script::partners_from_raw(raw.partners)?;
765    // (g2) Inbound listener declaration: the grammar walk runs in
766    // every build so grammar errors read identically with and without
767    // the `http` feature; the feature gate fires inside, after
768    // structure (ADR-0069 §8).
769    let inbound = raw.inbound.map(inbound_from_raw).transpose()?;
770    // (h) Reserved env keys: the harness binding wins over document
771    // fixtures — both the endpoints' bindVars and, since rc-5yon, the
772    // inbound listener's bindVar.
773    if let Some(env) = raw.env.as_ref() {
774        if let Some(inbound) = inbound.as_ref()
775            && env.contains_key(&inbound.bind_var)
776        {
777            return Err(DocError::ReservedEnvKey {
778                key: inbound.bind_var.clone(),
779                endpoint: "inbound".to_string(),
780            });
781        }
782        for action in &scenario {
783            for (bind_var, endpoint) in action.bindings() {
784                if env.contains_key(bind_var) {
785                    return Err(DocError::ReservedEnvKey {
786                        key: bind_var.to_string(),
787                        endpoint: endpoint.to_string(),
788                    });
789                }
790            }
791        }
792    }
793    // (i) Partner-target cross-check: a `partner` validate target URI
794    // must equal a harness endpoint reference declared by the
795    // scenario's own `send`/`receive` actions (URI string equality).
796    // A typo'd URI would otherwise assert against traffic nobody
797    // records.
798    let mut harness_uris: Vec<&str> = Vec::new();
799    let mut partner_targets: Vec<(usize, &EndpointRef)> = Vec::new();
800    for (index, action) in scenario.iter().enumerate() {
801        match action {
802            ScenarioAction::Send { to, .. } => {
803                if to.provisioning == Some(Provisioning::Harness) {
804                    harness_uris.push(to.endpoint.as_str());
805                }
806            }
807            ScenarioAction::Receive { from, .. } => {
808                if from.provisioning == Some(Provisioning::Harness) {
809                    harness_uris.push(from.endpoint.as_str());
810                }
811            }
812            ScenarioAction::Validate {
813                target: ScenarioTarget::Partner(endpoint),
814                ..
815            } => partner_targets.push((index, endpoint)),
816            _ => {}
817        }
818    }
819    for (index, endpoint) in partner_targets {
820        if !harness_uris.contains(&endpoint.endpoint.as_str()) {
821            return Err(DocError::Validation {
822                index,
823                message: format!(
824                    "validate `partner` target `{}` does not match any harness endpoint reference declared by this scenario's `send`/`receive` actions",
825                    endpoint.endpoint
826                ),
827            });
828        }
829    }
830    // Document-level send bound: optional; a present value goes
831    // through the same humantime grammar as the action deadlines,
832    // naming the field on failure (index 0 — the section, not an
833    // action, failed).
834    let send_deadline = raw
835        .send_deadline
836        .as_deref()
837        .map(|raw_deadline| parse_duration(raw_deadline, 0, "sendDeadline"))
838        .transpose()?;
839    Ok(ScenarioDocument {
840        source_path: path.to_path_buf(),
841        route_source,
842        scenario,
843        partners,
844        env: raw.env,
845        env_passthrough: raw.env_passthrough,
846        profile: raw.profile,
847        send_deadline,
848        inbound,
849    })
850}
851
852/// Converts the raw `inbound:` node. The v1 grammar is a single map
853/// `inbound: {bindVar: NAME}`; unknown fields are rejected naming the
854/// key, mirroring the partners-section strictness. Structure is
855/// validated in every build so grammar errors read identically with
856/// and without the `http` feature; only a structurally valid
857/// declaration reaches the demand gate (ADR-0069 §8), which rejects it
858/// naming the section and the feature when the harness is built
859/// without `http`. Section-level errors use index 0 — the section, not
860/// an action, failed (the `sendDeadline` precedent).
861fn inbound_from_raw(value: serde_yaml::Value) -> Result<InboundListener, DocError> {
862    let section_error = |message: String| DocError::Validation { index: 0, message };
863    let serde_yaml::Value::Mapping(ref map) = value else {
864        return Err(section_error(format!(
865            "`inbound` must be a map with a `bindVar` key, got {value:?}"
866        )));
867    };
868    let mut bind_var: Option<String> = None;
869    for (key, value) in map {
870        match key.as_str() {
871            "bindVar" => {
872                let text = value.as_str().ok_or_else(|| {
873                    section_error(format!(
874                        "`inbound`: `bindVar` must be a string, got {value:?}"
875                    ))
876                })?;
877                bind_var = Some(text.to_string());
878            }
879            other => {
880                return Err(section_error(format!(
881                    "`inbound`: unknown field `{other}`; expected `bindVar`"
882                )));
883            }
884        }
885    }
886    let bind_var =
887        bind_var.ok_or_else(|| section_error("`inbound` requires a `bindVar` key".to_string()))?;
888    // Demand-gated activation (ADR-0069 §8): the grammar parsed; the
889    // activation needs the `http` feature, which provisions the
890    // listener.
891    #[cfg(not(feature = "http"))]
892    {
893        let _ = bind_var;
894        Err(section_error(
895            "`inbound` requires the `http` feature, which this harness build does \
896             not enable: rebuild with `--features http` (demand-gated activation)"
897                .to_string(),
898        ))
899    }
900    #[cfg(feature = "http")]
901    Ok(InboundListener { bind_var })
902}
903
904/// Parses inline `routes` through the shared DSL parser. `parse_yaml`
905/// expects a top-level `routes:` key; the inline value (the array under
906/// `routes:`) is wrapped back into that shape, the same as the unit-tier
907/// runner.
908fn parse_inline_routes(value: &serde_yaml::Value) -> Result<Vec<RouteDefinition>, DocError> {
909    let mut mapping = serde_yaml::Mapping::new();
910    mapping.insert("routes", value.clone());
911    let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
912        .map_err(|e| DocError::InlineRoutes(format!("failed to serialize inline routes: {e}")))?;
913    camel_dsl::parse_yaml(&text).map_err(|e| DocError::InlineRoutes(e.to_string()))
914}
915
916/// Converts one raw action item into the public model. An item is a
917/// single-key map (`send`, `receive`, `sleep`, `validate`); dispatch
918/// runs here, not in serde, so every failure carries the action index.
919fn build_action(item: serde_yaml::Value, index: usize) -> Result<ScenarioAction, DocError> {
920    let action_error = |message: String| DocError::Validation { index, message };
921    let serde_yaml::Value::Mapping(ref map) = item else {
922        return Err(action_error(format!(
923            "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got {item:?}"
924        )));
925    };
926    let Some((key, content)) = map.iter().next() else {
927        return Err(action_error(
928            "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got an empty map"
929                .to_string(),
930        ));
931    };
932    if map.len() != 1 {
933        return Err(action_error(format!(
934            "action must declare exactly one key, got {}",
935            backticked(&map.keys().map(String::as_str).collect::<Vec<_>>())
936        )));
937    }
938    let action_error_from_serde = |e: serde_yaml::Error| action_error(e.to_string());
939    match key.as_str() {
940        "send" => {
941            let raw: RawSend =
942                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
943            let method = match raw.method {
944                Some(method) => {
945                    let upper = method.trim().to_ascii_uppercase();
946                    if !is_http_token(&upper) {
947                        return Err(action_error(format!(
948                            "send action `method` must be a valid HTTP method name, got `{method}`"
949                        )));
950                    }
951                    upper
952                }
953                None => {
954                    if raw.body.is_some() {
955                        "POST".to_string()
956                    } else {
957                        "GET".to_string()
958                    }
959                }
960            };
961            // (rc-qvz6) `expectReply` reads the synchronous reply only
962            // the context-stimulus `direct:` send produces; partner
963            // sends park their roundtrips for a later `receive` and
964            // fake adapters record sends without answering, so the
965            // assertion is rejected at load on every other scheme.
966            let scheme = ref_scheme(&raw.to.endpoint);
967            if raw.expect_reply.is_some() && scheme != Some("direct") {
968                return Err(DocError::ExpectReplyOnUnsupportedSend {
969                    index,
970                    // A scheme-less reference names no scheme to
971                    // render; the explicit phrase keeps the
972                    // diagnostic from degrading to an empty name.
973                    scheme: scheme.unwrap_or("no scheme").to_string(),
974                });
975            }
976            let expect_reply = raw
977                .expect_reply
978                .map(|value| expectation_from_value(&value, index, "expectReply"))
979                .transpose()?;
980            Ok(ScenarioAction::Send {
981                to: endpoint_from_raw(raw.to)?,
982                body: raw.body,
983                headers: raw.headers,
984                method,
985                expect_reply,
986            })
987        }
988        "receive" => {
989            let raw: RawReceive =
990                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
991            let deadline = raw.deadline.ok_or_else(|| {
992                action_error(
993                    "receive action requires a `deadline` (humantime string, e.g. `5s`)"
994                        .to_string(),
995                )
996            })?;
997            Ok(ScenarioAction::Receive {
998                from: endpoint_from_raw(raw.from)?,
999                deadline: parse_duration(&deadline, index, "deadline")?,
1000                extract: raw.extract,
1001            })
1002        }
1003        "sleep" => {
1004            let raw: RawSleep =
1005                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
1006            Ok(ScenarioAction::Sleep {
1007                duration: parse_duration(&raw.duration, index, "sleep duration")?,
1008            })
1009        }
1010        "validate" => {
1011            let raw: RawValidate =
1012                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
1013            let target = build_target(&raw.target, index)?;
1014            let deadline = match raw.deadline.as_deref() {
1015                None => None,
1016                // The poll deadline exists because a partner count
1017                // settles asynchronously; on any other target it has
1018                // no meaning and is a grammar error.
1019                Some(raw_deadline) if matches!(target, ScenarioTarget::Partner(_)) => {
1020                    Some(parse_duration(raw_deadline, index, "deadline")?)
1021                }
1022                Some(raw_deadline) => {
1023                    return Err(action_error(format!(
1024                        "`deadline` is only valid on a `partner` validate target, got `{raw_deadline}`"
1025                    )));
1026                }
1027            };
1028            let elapsed_at_least = match raw.elapsed_at_least.as_deref() {
1029                None => None,
1030                // The elapsed bound measures the wire arrival of the
1031                // last received message against the scenario start;
1032                // only that target carries an arrival to measure.
1033                Some(raw_bound) if matches!(target, ScenarioTarget::LastReceived(_)) => {
1034                    Some(parse_duration(raw_bound, index, "elapsedAtLeast")?)
1035                }
1036                Some(raw_bound) => {
1037                    return Err(action_error(format!(
1038                        "`elapsedAtLeast` is only valid on a `lastReceived` validate target, got `{raw_bound}`"
1039                    )));
1040                }
1041            };
1042            let expectation = match &target {
1043                ScenarioTarget::Partner(_) => ValidateExpectation::Partner(
1044                    partner_expectation_from_value(&raw.expectation, index)?,
1045                ),
1046                _ => ValidateExpectation::Message(expectation_from_value(
1047                    &raw.expectation,
1048                    index,
1049                    "expectation",
1050                )?),
1051            };
1052            Ok(ScenarioAction::Validate {
1053                target,
1054                expectation,
1055                deadline,
1056                elapsed_at_least,
1057            })
1058        }
1059        other => Err(action_error(format!(
1060            "unknown action `{other}`; expected `send`, `receive`, `sleep`, or `validate`"
1061        ))),
1062    }
1063}
1064
1065/// Builds a `validate` target from the raw `target` node: a single-key
1066/// map (`lastReceived`, `variable`, or `partner`).
1067fn build_target(value: &serde_yaml::Value, index: usize) -> Result<ScenarioTarget, DocError> {
1068    let action_error = |message: String| DocError::Validation { index, message };
1069    let serde_yaml::Value::Mapping(map) = value else {
1070        return Err(action_error(format!(
1071            "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got {value:?}"
1072        )));
1073    };
1074    let Some((key, content)) = map.iter().next() else {
1075        return Err(action_error(
1076            "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got an empty map"
1077                .to_string(),
1078        ));
1079    };
1080    match key.as_str() {
1081        "lastReceived" => {
1082            let raw: RawEndpointRef =
1083                serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1084            Ok(ScenarioTarget::LastReceived(endpoint_from_raw(raw)?))
1085        }
1086        "variable" => match content.as_str() {
1087            Some(name) => Ok(ScenarioTarget::Variable(name.to_string())),
1088            None => Err(action_error(format!(
1089                "validate `variable` target must be a string, got {content:?}"
1090            ))),
1091        },
1092        "partner" => {
1093            let raw: RawEndpointRef =
1094                serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1095            Ok(ScenarioTarget::Partner(endpoint_from_raw(raw)?))
1096        }
1097        other => Err(action_error(format!(
1098            "unknown validate target `{other}`; expected `lastReceived`, `variable`, or `partner`"
1099        ))),
1100    }
1101}
1102
1103/// Applies the provisioning gate: only `harness` (or absent) passes,
1104/// and a harness entry whose reference scheme binds no partner
1105/// (`direct:`, `fake:`) must not declare a `bindVar` — the variable
1106/// would never receive a bound authority (rc-j87j). A
1107/// `direct:`/`fake:` entry without a `bindVar` stays legal.
1108fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
1109    let provisioning = match raw.provisioning.as_deref() {
1110        None => None,
1111        Some("harness") => Some(Provisioning::Harness),
1112        Some(value) => {
1113            return Err(DocError::UnsupportedProvisioning {
1114                value: value.to_string(),
1115                endpoint: raw.endpoint.clone(),
1116            });
1117        }
1118    };
1119    if provisioning == Some(Provisioning::Harness)
1120        && raw.bind_var.is_some()
1121        && let Some(scheme) = ref_scheme(&raw.endpoint)
1122        && (scheme == "direct" || scheme == "fake")
1123    {
1124        return Err(DocError::ProvisioningWithoutAuthority {
1125            endpoint: raw.endpoint.clone(),
1126            ref_scheme: scheme.to_string(),
1127        });
1128    }
1129    Ok(EndpointRef {
1130        endpoint: raw.endpoint,
1131        provisioning,
1132        bind_var: raw.bind_var,
1133    })
1134}
1135
1136/// The scheme prefix of an endpoint URI: the non-empty text before
1137/// the first `:`, or `None` when the URI carries no scheme — which
1138/// requires the separator; a colon-less string (`orders`) is a bare
1139/// name, not a scheme.
1140fn ref_scheme(endpoint: &str) -> Option<&str> {
1141    let (scheme, _) = endpoint.split_once(':')?;
1142    (!scheme.is_empty()).then_some(scheme)
1143}
1144
1145/// Parses a humantime duration string, naming the action index on
1146/// failure.
1147fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
1148    humantime::parse_duration(raw).map_err(|e| DocError::Validation {
1149        index,
1150        message: format!("invalid {field} `{raw}`: {e}"),
1151    })
1152}
1153
1154/// Whether `s` is a valid HTTP token: non-empty and composed only of
1155/// ASCII alphanumerics or one of ``!#$%&'*+-.^_`|~``. Crate-visible
1156/// for the parse-test module.
1157pub(crate) fn is_http_token(s: &str) -> bool {
1158    !s.is_empty()
1159        && s.chars().all(|c| {
1160            c.is_ascii_alphanumeric()
1161                || matches!(
1162                    c,
1163                    '!' | '#'
1164                        | '$'
1165                        | '%'
1166                        | '&'
1167                        | '\''
1168                        | '*'
1169                        | '+'
1170                        | '-'
1171                        | '.'
1172                        | '^'
1173                        | '_'
1174                        | '`'
1175                        | '|'
1176                        | '~'
1177                )
1178        })
1179}
1180
1181/// Recognized expectation matcher keys.
1182fn is_matcher_key(key: &str) -> bool {
1183    matches!(
1184        key,
1185        "equals" | "regex" | "contains" | "startsWith" | "endsWith" | "exists" | "jsonSubset"
1186    )
1187}
1188
1189/// Applies the expectation dual grammar: a bare value is a literal
1190/// `equals`; an object whose single key is a recognized matcher key is
1191/// that matcher; any other object is a literal `equals`. Payload shapes
1192/// mirror the mock-testkit matcher rules. The field name parameter
1193/// (`expectation`, `expectReply`) keeps one verb parser behind both
1194/// readers (rc-qvz6): the verbs never fork between `validate` and
1195/// send-level reply assertions.
1196fn expectation_from_value(
1197    value: &Value,
1198    index: usize,
1199    field: &'static str,
1200) -> Result<Expectation, DocError> {
1201    let invalid = |message: String| DocError::Validation { index, message };
1202    if let Value::Object(map) = value
1203        && map.len() == 1
1204        && let Some((key, payload)) = map.iter().next()
1205        && is_matcher_key(key)
1206    {
1207        return match key.as_str() {
1208            "equals" => Ok(Expectation::Equals(payload.clone())),
1209            "regex" | "contains" | "startsWith" | "endsWith" => {
1210                let Some(pattern) = payload.as_str() else {
1211                    return Err(invalid(format!(
1212                        "{field}: `{key}` requires a string payload"
1213                    )));
1214                };
1215                if key.as_str() == "regex"
1216                    && let Err(e) = regex::Regex::new(pattern)
1217                {
1218                    return Err(invalid(format!("{field}: invalid regex `{pattern}`: {e}")));
1219                }
1220                Ok(match key.as_str() {
1221                    "regex" => Expectation::Regex(pattern.to_string()),
1222                    "contains" => Expectation::Contains(pattern.to_string()),
1223                    "startsWith" => Expectation::StartsWith(pattern.to_string()),
1224                    _ => Expectation::EndsWith(pattern.to_string()),
1225                })
1226            }
1227            "exists" => {
1228                if payload.is_null() {
1229                    Ok(Expectation::Exists)
1230                } else {
1231                    Err(invalid(format!("{field}: `exists` takes no argument")))
1232                }
1233            }
1234            _ => {
1235                if payload.is_object() {
1236                    Ok(Expectation::JsonSubset(payload.clone()))
1237                } else {
1238                    Err(invalid(format!("{field}: `jsonSubset` must be an object")))
1239                }
1240            }
1241        };
1242    }
1243    Ok(Expectation::Equals(value.clone()))
1244}
1245
1246/// Applies the partner expectation grammar: a map with exactly one
1247/// count bound (`count`; or `atLeast`, `atMost`, or their range), an
1248/// optional `method` string, at most one path filter (`path`,
1249/// `pathContains`, `pathMatches` — the regex compiled at load), and
1250/// an optional `query` subset map of string keys to string values;
1251/// unknown keys fail. Field-by-field extraction, like the
1252/// endpoint-reference reader, so errors name the offending key.
1253fn partner_expectation_from_value(
1254    value: &Value,
1255    index: usize,
1256) -> Result<PartnerExpectation, DocError> {
1257    const FIELD: &str = "partner expectation";
1258    const KEYS: &[&str] = &[
1259        "count",
1260        "atLeast",
1261        "atMost",
1262        "method",
1263        "path",
1264        "pathContains",
1265        "pathMatches",
1266        "query",
1267    ];
1268    let invalid = |message: String| DocError::Validation { index, message };
1269    let Value::Object(map) = value else {
1270        return Err(invalid(format!(
1271            "{FIELD} must be a map with a count bound, got {value:?}"
1272        )));
1273    };
1274    let mut count: Option<u64> = None;
1275    let mut at_least: Option<u64> = None;
1276    let mut at_most: Option<u64> = None;
1277    let mut method: Option<String> = None;
1278    let mut path: Option<PathFilter> = None;
1279    let mut path_key: Option<&str> = None;
1280    let mut query: Option<BTreeMap<String, String>> = None;
1281    for (key, payload) in map {
1282        match key.as_str() {
1283            "count" | "atLeast" | "atMost" => {
1284                let bound = payload.as_u64().ok_or_else(|| {
1285                    invalid(format!(
1286                        "{FIELD}: `{key}` must be a non-negative integer, got {payload}"
1287                    ))
1288                })?;
1289                match key.as_str() {
1290                    "count" => count = Some(bound),
1291                    "atLeast" => at_least = Some(bound),
1292                    _ => at_most = Some(bound),
1293                }
1294            }
1295            "method" => {
1296                let text = payload.as_str().ok_or_else(|| {
1297                    invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1298                })?;
1299                method = Some(text.to_string());
1300            }
1301            "path" | "pathContains" | "pathMatches" => {
1302                if let Some(first) = path_key {
1303                    return Err(invalid(format!(
1304                        "{FIELD}: `{first}` and `{key}` are exclusive: at most one path filter"
1305                    )));
1306                }
1307                let text = payload.as_str().ok_or_else(|| {
1308                    invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1309                })?;
1310                path = Some(match key.as_str() {
1311                    "path" => PathFilter::Exact(text.to_string()),
1312                    "pathContains" => PathFilter::Contains(text.to_string()),
1313                    _ => {
1314                        if let Err(e) = regex::Regex::new(text) {
1315                            return Err(invalid(format!("{FIELD}: invalid regex `{text}`: {e}")));
1316                        }
1317                        PathFilter::Matches(text.to_string())
1318                    }
1319                });
1320                path_key = Some(key.as_str());
1321            }
1322            "query" => {
1323                let Value::Object(pairs) = payload else {
1324                    return Err(invalid(format!(
1325                        "{FIELD}: `query` must be a map of string keys to string values, got {payload}"
1326                    )));
1327                };
1328                let mut subset = BTreeMap::new();
1329                for (name, pair) in pairs {
1330                    let Some(text) = pair.as_str() else {
1331                        return Err(invalid(format!(
1332                            "{FIELD}: `query` value for `{name}` must be a string, got {pair}"
1333                        )));
1334                    };
1335                    subset.insert(name.clone(), text.to_string());
1336                }
1337                query = Some(subset);
1338            }
1339            other => {
1340                return Err(invalid(format!(
1341                    "{FIELD}: unknown field `{other}`; expected {}",
1342                    backticked(KEYS)
1343                )));
1344            }
1345        }
1346    }
1347    if count.is_some() && (at_least.is_some() || at_most.is_some()) {
1348        let mut others: Vec<&str> = Vec::new();
1349        if at_least.is_some() {
1350            others.push("atLeast");
1351        }
1352        if at_most.is_some() {
1353            others.push("atMost");
1354        }
1355        return Err(invalid(format!(
1356            "{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
1357            backticked(&others)
1358        )));
1359    }
1360    let bound = if let Some(exact) = count {
1361        CountBound::Exact(exact)
1362    } else if let (Some(min), Some(max)) = (at_least, at_most) {
1363        if min > max {
1364            return Err(invalid(format!(
1365                "{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
1366            )));
1367        }
1368        CountBound::Range(min, max)
1369    } else if let Some(n) = at_least {
1370        CountBound::AtLeast(n)
1371    } else if let Some(n) = at_most {
1372        CountBound::AtMost(n)
1373    } else {
1374        return Err(invalid(format!(
1375            "{FIELD}: requires a count bound: `count`, `atLeast`, or `atMost`"
1376        )));
1377    };
1378    Ok(PartnerExpectation {
1379        bound,
1380        method,
1381        path,
1382        query,
1383    })
1384}
1385
1386/// Backticks and comma-joins field names for error messages.
1387fn backticked(fields: &[&str]) -> String {
1388    fields
1389        .iter()
1390        .map(|field| format!("`{field}`"))
1391        .collect::<Vec<_>>()
1392        .join(", ")
1393}