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, an optional document-level `inbound:` listener
12//! declaration (feature `http`), and an optional document-level
13//! `logs:` assertion block (rc-tdgh5) whose grammar is clause-checked
14//! here and evaluated against the harness capture window at run time.
15//! Unknown fields are rejected.
16//!
17//! The scenario vocabulary and the unit-tier vocabulary (`inputs`,
18//! `expects`, `intercepts`) never mix in one document. A document with
19//! `scenario:` that also declares a unit-tier section is rejected at
20//! load time.
21//!
22//! Durations (`sendDeadline`, `deadline`, `duration`,
23//! `elapsedAtLeast`) are humantime strings, for example `"5s"` or
24//! `"250ms"`, parsed during validation so errors can name the action
25//! index.
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use camel_api::Value;
32use camel_core::RouteDefinition;
33use noyalib::compat::serde_yaml;
34use serde::Deserialize;
35
36use error::{RawEndpointRef, classify_yaml_error, endpoint_from_raw, parse_duration};
37
38// The partner-script grammar lives in its own module; the public
39// types are re-exported here so the document API stays one surface.
40pub use crate::partner_script::{PartnerFault, PartnerScript, PartnerScriptResponse};
41// The matcher algebra lives in the shared pure core (camel-matchers);
42// re-exported here so the document API stays one surface. The raw
43// serde stage below constructs these core types directly.
44pub use camel_matchers::RequestExpectation as PartnerExpectation;
45pub use camel_matchers::{CountBound, Expectation, PathFilter, RowsExpectation};
46// The load-error vocabulary and the raw endpoint-reference
47// conversion live in the submodule `error` (rc-0ahfl); `DocError`
48// stays re-exported here so the document API keeps one surface.
49pub mod error;
50pub use error::DocError;
51pub mod logs;
52pub use logs::{LogLevel, LogsAssertion};
53pub mod validate;
54pub use validate::{ScenarioTarget, SqlTarget, ValidateExpectation};
55use validate::{backticked, partner_expectation_from_value};
56pub(crate) use validate::{
57    expectation_from_value, sql_expectation_from_value, sql_query_lacks_order_by,
58};
59
60// ---------------------------------------------------------------------------
61// Public model
62// ---------------------------------------------------------------------------
63
64/// A parsed scenario document. Route file paths stay as declared;
65/// resolving them against the document directory or the project root is
66/// the runner's job, the same split the unit-tier parser keeps.
67#[derive(Debug)]
68pub struct ScenarioDocument {
69    /// The document's own path as parsed. The boot root may be a
70    /// nearest-ancestor `Camel.toml` directory rather than the
71    /// document's directory, so the document directory travels with
72    /// the model: relative `routeFiles` anchor here (rc-jjzy5).
73    pub source_path: std::path::PathBuf,
74    /// The single declared route source.
75    pub route_source: RouteSource,
76    /// Ordered scenario actions.
77    pub scenario: Vec<ScenarioAction>,
78    /// Document-level partner scripting, keyed by endpoint address.
79    /// The grammar lives here; the runner consumes the map.
80    pub partners: Option<BTreeMap<String, Vec<PartnerScript>>>,
81    /// Fixed fixture values for the scenario; the layered environment
82    /// source reads these before any ambient value.
83    pub env: Option<BTreeMap<String, String>>,
84    /// Ambient variable names allowed to pass through to the scenario.
85    pub env_passthrough: Option<Vec<String>>,
86    /// Profile pinned per document; an ambient profile would break
87    /// hermeticity.
88    pub profile: Option<String>,
89    /// Document-level bound for every `send` action (rc-tr4w): an
90    /// optional tighter deadline than the runner's thirty-second
91    /// default, real time only (ADR-0069 §6).
92    pub send_deadline: Option<Duration>,
93    /// The document-level `inbound:` declaration (rc-5yon): the
94    /// harness binds `127.0.0.1:0`, stages the listener on the HTTP
95    /// component's global registry (ADR-0070), and exposes the bound
96    /// address under the named bind variable so route URIs interpolate
97    /// it. Provisioning runs behind the `http` feature; a declaration
98    /// in a build without the feature is a named load error (ADR-0069
99    /// §8 demand-gated activation).
100    pub inbound: Option<InboundListener>,
101    /// The document-level `logs:` assertion block (rc-tdgh5):
102    /// optional; when present, the runner opens a capture window at
103    /// document start and evaluates the clauses against the captured
104    /// events after the action loop. Requires the harness's capture
105    /// subscriber to own the process's tracing seat (first-wins
106    /// `try_init` before the boot); otherwise the document fails
107    /// through the apparatus class.
108    pub logs: Option<LogsAssertion>,
109}
110
111/// The route source of a scenario document. Exactly one form is
112/// declared; the parser rejects zero or multiple declarations.
113///
114/// Not `Clone`: the inline form carries `RouteDefinition`s, which are
115/// not `Clone`.
116#[non_exhaustive]
117pub enum RouteSource {
118    /// Route files to load, relative to the document's directory.
119    RouteFiles(Vec<PathBuf>),
120    /// Route files to load, resolved against the nearest ancestor
121    /// `Camel.toml` directory (the project root).
122    RouteFilesFromRoot(Vec<PathBuf>),
123    /// Inline route definitions, parsed at load time.
124    Inline(Vec<RouteDefinition>),
125}
126
127impl std::fmt::Debug for RouteSource {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            // `RouteDefinition` implements neither `Debug` nor `Clone`;
131            // the inline form reports its route count only.
132            Self::RouteFiles(files) => f.debug_tuple("RouteFiles").field(files).finish(),
133            Self::RouteFilesFromRoot(files) => {
134                f.debug_tuple("RouteFilesFromRoot").field(files).finish()
135            }
136            Self::Inline(routes) => f
137                .debug_tuple("Inline")
138                .field(&format_args!("{} route definitions", routes.len()))
139                .finish(),
140        }
141    }
142}
143
144/// One ordered scenario action (ADR-0069 section 11, adopted from
145/// Citrus: `send`, `receive` with a mandatory deadline, `sleep`,
146/// `validate`, `sql`).
147#[derive(Debug, Clone)]
148#[non_exhaustive]
149pub enum ScenarioAction {
150    /// Send a message to an endpoint.
151    Send {
152        /// Target endpoint reference.
153        to: EndpointRef,
154        /// Message body; omitted means an empty body.
155        body: Option<Value>,
156        /// Message headers.
157        headers: Option<BTreeMap<String, Value>>,
158        /// Resolved method: explicit or inferred (`POST` with a body,
159        /// `GET` without), uppercase.
160        method: String,
161        /// Reply assertion for `direct:` sends (rc-qvz6): the same
162        /// matcher grammar `validate` parses, evaluated against the
163        /// synchronous route reply the context-stimulus adapter
164        /// returns. Load-time rejected on every other scheme.
165        expect_reply: Option<Expectation>,
166    },
167    /// Receive a message from an endpoint before the deadline passes.
168    Receive {
169        /// Source endpoint reference.
170        from: EndpointRef,
171        /// Mandatory deadline, real monotonic time.
172        deadline: Duration,
173        /// Extractions into scenario variables, keyed by variable name.
174        extract: Option<BTreeMap<String, String>>,
175    },
176    /// Pause the scenario for the given duration.
177    Sleep {
178        /// Sleep length.
179        duration: Duration,
180    },
181    /// Assert an expectation against a scenario target.
182    Validate {
183        /// What to validate: the last message received on an endpoint,
184        /// a scenario variable, a partner's recorded traffic, or a
185        /// datasource read.
186        target: ScenarioTarget,
187        /// Matcher expectation: the message grammar for `lastReceived`
188        /// and `variable` targets, the partner count grammar for
189        /// `partner` targets, the sql row grammar for `sql` targets.
190        expectation: ValidateExpectation,
191        /// Optional poll deadline. Only valid on `partner` and `sql`
192        /// targets, whose assertions settle asynchronously or read a
193        /// live datasource; without it the partner assertion reads one
194        /// immediate snapshot.
195        deadline: Option<Duration>,
196        /// Optional minimum wire-arrival age. Only valid on
197        /// `lastReceived` targets: the last received message must have
198        /// arrived at least this long after the scenario started (the
199        /// not-before-X control `run.sh` expresses with `awk`). The
200        /// assertion anchors to the message's wire arrival, never the
201        /// consumption time.
202        elapsed_at_least: Option<Duration>,
203    },
204    /// Seed datasource state before the route assertions run (bd
205    /// rc-25lup.1): execute the ordered `prepare` mutation statements
206    /// against the named datasource's pool through the scenario `sql:`
207    /// vocabulary. Reads are rejected at load (`is_read_statement`):
208    /// the `validate` sql target owns reads, and the two vocabularies
209    /// never mix. Activation is demand-gated behind the harness `sql`
210    /// feature (the `inbound:`/`http` precedent, ADR-0069 §8); the
211    /// grammar and validation run in every build.
212    Sql {
213        /// The datasource name as declared under `[datasources.*]` in
214        /// `Camel.toml`.
215        datasource: String,
216        /// Ordered SQL mutation statements, executed in order against
217        /// the datasource's pool.
218        prepare: Vec<String>,
219    },
220}
221
222impl ScenarioAction {
223    /// The `(bind variable, endpoint)` bindings this action's endpoint
224    /// references declare.
225    fn bindings(&self) -> Vec<(&str, &str)> {
226        fn endpoint_bindings(endpoint: &EndpointRef) -> Vec<(&str, &str)> {
227            endpoint.binding().into_iter().collect()
228        }
229        match self {
230            Self::Send { to, .. } => endpoint_bindings(to),
231            Self::Receive { from, .. } => endpoint_bindings(from),
232            Self::Validate { target, .. } => match target {
233                ScenarioTarget::LastReceived(endpoint) => endpoint_bindings(endpoint),
234                // A partner target carrying `provisioning: harness`
235                // declares a harness reference of its own: its `bindVar`
236                // reserves the env key exactly like a send/receive
237                // reference (rc-z1cjv). A plain-string target declares
238                // nothing.
239                ScenarioTarget::Partner(endpoint)
240                    if endpoint.provisioning == Some(Provisioning::Harness) =>
241                {
242                    endpoint_bindings(endpoint)
243                }
244                ScenarioTarget::Partner(_) => Vec::new(),
245                ScenarioTarget::Variable(_) => Vec::new(),
246                // A sql target references a named datasource, never an
247                // endpoint: it declares no bindings (the `Variable`
248                // precedent).
249                ScenarioTarget::Sql(_) => Vec::new(),
250            },
251            Self::Sleep { .. } => Vec::new(),
252            // A `sql:` action references a named datasource, never an
253            // endpoint: it declares no bindings.
254            Self::Sql { .. } => Vec::new(),
255        }
256    }
257}
258
259/// An endpoint reference: a bare endpoint string or a map with
260/// `endpoint`, `provisioning`, and `bindVar` keys.
261#[derive(Debug, Clone, PartialEq)]
262pub struct EndpointRef {
263    /// Endpoint URI, for example `http://127.0.0.1:9999/hook`.
264    pub endpoint: String,
265    /// Who owns the partner lifecycle; only `harness` is implemented in
266    /// v1.
267    pub provisioning: Option<Provisioning>,
268    /// Scenario variable name the harness fills with this endpoint's
269    /// bound address when provisioning is `harness`.
270    pub bind_var: Option<String>,
271}
272
273impl EndpointRef {
274    /// The `(bind variable, endpoint)` binding this reference declares,
275    /// if any. The reserved env-key rule collects these pairs.
276    fn binding(&self) -> Option<(&str, &str)> {
277        self.bind_var
278            .as_deref()
279            .map(|bind_var| (bind_var, self.endpoint.as_str()))
280    }
281}
282
283/// Partner provisioning source (ADR-0069 section 9). The axis is who
284/// owns the lifecycle. `testcontainer` and `user-provided` are reserved
285/// grammar values; the parser rejects them.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287#[non_exhaustive]
288pub enum Provisioning {
289    /// The harness binds an in-process listener on `127.0.0.1:0`. The
290    /// only source implemented in v1.
291    Harness,
292}
293
294/// The document's `inbound:` declaration (rc-5yon): v1 grammar is a
295/// single map `inbound: {bindVar: NAME}`. The harness provisions one
296/// listener per document, binds `127.0.0.1:0`, stages it on the HTTP
297/// component's global registry (ADR-0070 staged consumption), and
298/// fills the bind variable with `http://<bound-address>` so route
299/// consumer URIs interpolate the staged socket.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct InboundListener {
302    /// Scenario variable name the harness fills with the staged
303    /// listener's `http://<bound-address>` URL.
304    pub bind_var: String,
305}
306
307/// The scripted responses a document's `partners:` entry maps to, for
308/// one endpoint key. `None` when the document declares no entry for
309/// the endpoint — the caller binds a permissive partner. `Some` maps
310/// each script grammar entry to its wire form: absent `status`
311/// defaults to 200, absent `times` to 1 (serve once), absent headers
312/// to the empty map, and the body follows the client send path's
313/// `value_to_wire` encoding (empty when absent); `delay` and `fault`
314/// map through.
315///
316/// The canonical `PartnerScript` → wire-form mapping; the CLI driver
317/// and library-level scenarios bind partners through this function so
318/// the semantics live in exactly one place.
319#[cfg(feature = "http")]
320pub fn partner_scripts_for(
321    doc: &ScenarioDocument,
322    endpoint: &str,
323) -> Option<Vec<crate::adapters::http::ScriptedResponse>> {
324    use crate::adapters::http::ScriptedResponse;
325    let scripts = doc.partners.as_ref()?.get(endpoint)?;
326    Some(
327        scripts
328            .iter()
329            .map(|script| {
330                let (status, headers, body) = match script.response.as_ref() {
331                    Some(response) => (
332                        response.status.unwrap_or(200),
333                        response.headers.clone().unwrap_or_default(),
334                        response.body.as_ref().map_or_else(Vec::new, |value| {
335                            crate::adapters::http::value_to_wire(value)
336                        }),
337                    ),
338                    // Fault entries carry no response; the placeholder
339                    // keeps the wire form — serve checks the fault
340                    // first, so the placeholder never reaches the wire.
341                    None => (200, BTreeMap::new(), Vec::new()),
342                };
343                ScriptedResponse {
344                    method: script.method.clone(),
345                    path: script.path.clone(),
346                    times: script.times.unwrap_or(1),
347                    delay: script.delay,
348                    fault: script.fault.clone(),
349                    status,
350                    headers,
351                    body,
352                }
353            })
354            .collect(),
355    )
356}
357
358// ---------------------------------------------------------------------------
359// Raw serde stage
360// ---------------------------------------------------------------------------
361
362/// Raw document form. Unit-tier sections are captured, not rejected at
363/// the serde layer, so the mixing ban can name them. Scenario items
364/// stay raw values: the single-key action dispatch runs during
365/// validation so errors can name the action index.
366#[derive(Deserialize)]
367#[serde(deny_unknown_fields, rename_all = "camelCase")]
368struct RawDocument {
369    route_files: Option<Vec<String>>,
370    route_files_from_root: Option<Vec<String>>,
371    routes: Option<serde_yaml::Value>,
372    scenario: Option<Vec<serde_yaml::Value>>,
373    env: Option<BTreeMap<String, String>>,
374    env_passthrough: Option<Vec<String>>,
375    profile: Option<String>,
376    // Document-level partner scripting: the raw map stays
377    // endpoint-keyed with raw sequence values; conversion runs during
378    // validation so errors can name the entry key.
379    partners: Option<BTreeMap<String, serde_yaml::Value>>,
380    // Document-level send bound: raw humantime string; parsed during
381    // validation so the error names the field.
382    send_deadline: Option<String>,
383    // Document-level inbound listener declaration: raw node; the
384    // grammar walk runs during validation so unknown fields name
385    // themselves in every build, and the `http` feature gate fires
386    // after structure (ADR-0069 §8 demand-gated activation).
387    inbound: Option<serde_yaml::Value>,
388    // Document-level log assertions (rc-tdgh5): raw node; the clause
389    // walk (keys, level set, regex compile) runs during validation so
390    // every error names the offending clause.
391    logs: Option<serde_yaml::Value>,
392    // Unit-tier vocabulary, present only to detect and name the mixing
393    // ban violation.
394    inputs: Option<serde_yaml::Value>,
395    expects: Option<serde_yaml::Value>,
396    intercepts: Option<serde_yaml::Value>,
397}
398
399#[derive(Deserialize)]
400#[serde(deny_unknown_fields, rename_all = "camelCase")]
401struct RawSend {
402    to: RawEndpointRef,
403    body: Option<Value>,
404    headers: Option<BTreeMap<String, Value>>,
405    /// Raw `method` string; optional. Validation resolves it (explicit
406    /// or inferred from body presence) so errors can name the action
407    /// index.
408    method: Option<String>,
409    /// Raw `expectReply` node; optional, `direct:` sends only.
410    /// Validation converts it through the same matcher grammar
411    /// `validate` uses, and rejects it on every other scheme.
412    expect_reply: Option<Value>,
413}
414
415#[derive(Deserialize)]
416#[serde(deny_unknown_fields, rename_all = "camelCase")]
417struct RawReceive {
418    from: RawEndpointRef,
419    /// Raw humantime string; required by validation, not by serde, so
420    /// the error can name the action index.
421    deadline: Option<String>,
422    extract: Option<BTreeMap<String, String>>,
423}
424
425#[derive(Deserialize)]
426#[serde(deny_unknown_fields, rename_all = "camelCase")]
427struct RawSleep {
428    /// Raw humantime string.
429    duration: String,
430}
431
432/// Raw sql validate-target payload: the object under the `sql` key.
433/// Field names stay snake_case despite the `camelCase` rename (no
434/// multi-word fields today) — the attribute is load-bearing for the
435/// deny-unknown error and future fields.
436#[derive(Deserialize)]
437#[serde(deny_unknown_fields, rename_all = "camelCase")]
438struct RawSqlTarget {
439    datasource: String,
440    query: String,
441}
442
443#[derive(Deserialize)]
444#[serde(deny_unknown_fields, rename_all = "camelCase")]
445struct RawValidate {
446    /// Raw `target` node; the single-key form (`lastReceived` /
447    /// `variable` / `partner`) converts during validation.
448    target: serde_yaml::Value,
449    expectation: Value,
450    /// Raw humantime string; partner and sql targets only, parsed
451    /// during validation so the error can name the action index.
452    deadline: Option<String>,
453    /// Raw humantime string; `lastReceived` targets only, parsed
454    /// during validation so the error can name the action index.
455    elapsed_at_least: Option<String>,
456}
457
458// ---------------------------------------------------------------------------
459// Parsing
460// ---------------------------------------------------------------------------
461
462/// Parses and validates a scenario document. Validation order:
463/// (a) the path carries a reserved test-document suffix; (b) the text
464/// deserializes; (c) a non-empty `scenario:` section exists; (d) no
465/// unit-tier section coexists with it; (e) exactly one route source
466/// is declared, and it is not inline (`routes` cannot boot in v1, so
467/// the defect fails at load instead of at boot);
468/// (f) each action converts (single-key dispatch, deadlines, durations,
469/// endpoint provisioning, expectation grammar, the `direct:`-only
470/// `expectReply` gate) with action-index errors; (g) each `partners`
471/// entry converts (script grammar, response status range) with
472/// entry-key errors; (h) no `env` key collides with a declared
473/// `bindVar`; (i) each `partner` validate target URI equals a harness
474/// endpoint reference declared by the scenario's own `send`/`receive`
475/// actions, or self-declares the reference: an object-form
476/// `provisioning: harness` target whose `http` URI a `partners:` entry
477/// names. The optional `inbound:` section converts
478/// between (g) and (h): grammar in every build, activation
479/// demand-gated behind `http` (ADR-0069 §8). (j) The optional
480/// `logs:` block converts (clause grammar: contains markers,
481/// load-compiled regex, the noLevelAbove level set) with
482/// clause-naming load errors.
483pub fn parse_scenario_document(path: &Path) -> Result<ScenarioDocument, DocError> {
484    if !camel_dsl::discovery::is_test_document(path) {
485        return Err(DocError::NotTestDocument {
486            path: path.to_path_buf(),
487        });
488    }
489    let text = std::fs::read_to_string(path).map_err(|source| DocError::Io {
490        path: path.to_path_buf(),
491        source,
492    })?;
493    let raw = serde_yaml::from_str::<RawDocument>(&text)
494        .map_err(|e| classify_yaml_error(&e.to_string()))?;
495
496    // (c) This parser accepts scenario documents only, and the
497    // scenario list must be non-empty: an empty list would yield a
498    // trivially-green FULL document with zero actions (mirrors the
499    // unit tier's non-empty `expects` rule).
500    let Some(raw_scenario) = raw.scenario else {
501        return Err(DocError::MissingScenario);
502    };
503    if raw_scenario.is_empty() {
504        return Err(DocError::Validation {
505            index: 0,
506            message: "`scenario` must declare at least one action".to_string(),
507        });
508    }
509    // (d) Mixing ban (ADR-0069 section 2).
510    let mut unit_tier: Vec<&str> = Vec::new();
511    if raw.inputs.is_some() {
512        unit_tier.push("inputs");
513    }
514    if raw.expects.is_some() {
515        unit_tier.push("expects");
516    }
517    if raw.intercepts.is_some() {
518        unit_tier.push("intercepts");
519    }
520    if !unit_tier.is_empty() {
521        return Err(DocError::MixedVocabulary {
522            found: backticked(&unit_tier),
523        });
524    }
525    // (e) Exactly one route source, with the unit-tier messages.
526    let mut present: Vec<&'static str> = Vec::new();
527    if raw.route_files.is_some() {
528        present.push("routeFiles");
529    }
530    if raw.route_files_from_root.is_some() {
531        present.push("routeFilesFromRoot");
532    }
533    if raw.routes.is_some() {
534        present.push("routes");
535    }
536    let route_source = match present.as_slice() {
537        ["routeFiles"] => RouteSource::RouteFiles(
538            raw.route_files
539                .unwrap_or_default()
540                .into_iter()
541                .map(PathBuf::from)
542                .collect(),
543        ),
544        ["routeFilesFromRoot"] => RouteSource::RouteFilesFromRoot(
545            raw.route_files_from_root
546                .unwrap_or_default()
547                .into_iter()
548                .map(PathBuf::from)
549                .collect(),
550        ),
551        ["routes"] => {
552            let value = raw.routes.unwrap_or(serde_yaml::Value::Null);
553            RouteSource::Inline(parse_inline_routes(&value)?)
554        }
555        [] => return Err(DocError::RouteSourceMissing),
556        _ => {
557            return Err(DocError::RouteSourceConflict {
558                present: backticked(&present),
559            });
560        }
561    };
562    // (e, rc-9dpx) Inline route sources cannot boot in v1; reject at
563    // load, before partners bind, instead of failing the boot after
564    // the composition root is up. The boot keeps its own rejection as
565    // defense-in-depth.
566    if matches!(route_source, RouteSource::Inline(_)) {
567        return Err(DocError::InlineRoutesRejected);
568    }
569    // (f) Action conversion.
570    let mut scenario = Vec::with_capacity(raw_scenario.len());
571    for (index, item) in raw_scenario.into_iter().enumerate() {
572        scenario.push(build_action(item, index)?);
573    }
574    // (g) Partner scripting: entries convert from the raw sequence
575    // with the entry key named on every failure; an empty sequence is
576    // a valid, inert entry. The grammar conversion lives in the
577    // partner-script module.
578    let partners = crate::partner_script::partners_from_raw(raw.partners)?;
579    // (g2) Inbound listener declaration: the grammar walk runs in
580    // every build so grammar errors read identically with and without
581    // the `http` feature; the feature gate fires inside, after
582    // structure (ADR-0069 §8).
583    let inbound = raw.inbound.map(inbound_from_raw).transpose()?;
584    // (h) Reserved env keys: the harness binding wins over document
585    // fixtures — both the endpoints' bindVars and, since rc-5yon, the
586    // inbound listener's bindVar.
587    if let Some(env) = raw.env.as_ref() {
588        if let Some(inbound) = inbound.as_ref()
589            && env.contains_key(&inbound.bind_var)
590        {
591            return Err(DocError::ReservedEnvKey {
592                key: inbound.bind_var.clone(),
593                endpoint: "inbound".to_string(),
594            });
595        }
596        for action in &scenario {
597            for (bind_var, endpoint) in action.bindings() {
598                if env.contains_key(bind_var) {
599                    return Err(DocError::ReservedEnvKey {
600                        key: bind_var.to_string(),
601                        endpoint: endpoint.to_string(),
602                    });
603                }
604            }
605        }
606    }
607    // (i) Partner-target cross-check: a `partner` validate target URI
608    // must equal a harness endpoint reference declared by the
609    // scenario's own `send`/`receive` actions (URI string equality),
610    // or self-declare the reference: an object-form `provisioning:
611    // harness` target whose `http` URI a `partners:` entry names. A
612    // typo'd URI would otherwise assert against traffic nobody
613    // records.
614    let mut harness_uris: Vec<&str> = Vec::new();
615    let mut self_declared: Vec<&str> = Vec::new();
616    let mut partner_targets: Vec<(usize, &EndpointRef)> = Vec::new();
617    for (index, action) in scenario.iter().enumerate() {
618        match action {
619            ScenarioAction::Send { to, .. } => {
620                if to.provisioning == Some(Provisioning::Harness) {
621                    harness_uris.push(to.endpoint.as_str());
622                }
623            }
624            ScenarioAction::Receive { from, .. } => {
625                if from.provisioning == Some(Provisioning::Harness) {
626                    harness_uris.push(from.endpoint.as_str());
627                }
628            }
629            ScenarioAction::Validate {
630                target: ScenarioTarget::Partner(endpoint),
631                ..
632            } => {
633                partner_targets.push((index, endpoint));
634                // Self-declaration grammar: object form with
635                // `provisioning: harness` (a bare string can carry no
636                // provisioning), an `http` scheme, and a `partners:`
637                // entry scripting the URI. A bare map `{endpoint: U}`
638                // without `provisioning: harness` also declares
639                // nothing: the object-form shape alone is not a
640                // self-declaration — `provisioning: harness` is the
641                // declaration act.
642                if endpoint.provisioning == Some(Provisioning::Harness)
643                    && ref_scheme(&endpoint.endpoint) == Some("http")
644                    && partners
645                        .as_ref()
646                        .is_some_and(|map| map.contains_key(&endpoint.endpoint))
647                {
648                    self_declared.push(endpoint.endpoint.as_str());
649                }
650            }
651            _ => {}
652        }
653    }
654    for (index, endpoint) in partner_targets {
655        let declared = harness_uris.contains(&endpoint.endpoint.as_str())
656            || (endpoint.provisioning == Some(Provisioning::Harness)
657                && self_declared.contains(&endpoint.endpoint.as_str()));
658        if !declared {
659            return Err(DocError::Validation {
660                index,
661                message: format!(
662                    "validate `partner` target `{}` matches no harness partner: declare the URI through a `send`/`receive` reference with `provisioning: harness`, or self-declare it with an object-form target carrying `provisioning: harness` and a `partners:` entry naming the URI",
663                    endpoint.endpoint
664                ),
665            });
666        }
667    }
668    // Reverse cross-check (bd rc-ilqg, moved from the CLI driver so
669    // library callers get the identical rejection): every `partners:`
670    // key must equal a wired harness `http` endpoint reference —
671    // declared by a `send`/`receive` with `provisioning: harness`, or
672    // self-declared by an object-form partner target. A typo of a
673    // real key (`:0/order` vs `:0/orders`) fails here, at load,
674    // BEFORE any partner binds — never as a silent fall-through to
675    // the permissive default. Section-level: index 0.
676    if let Some(partners) = &partners {
677        let declared = |key: &str| {
678            (ref_scheme(key) == Some("http") && harness_uris.contains(&key))
679                || self_declared.contains(&key)
680        };
681        if let Some(key) = partners.keys().find(|key| !declared(key)) {
682            return Err(DocError::Validation {
683                index: 0,
684                message: format!(
685                    "partners[{key}]: no wired harness `http` endpoint reference declares this key"
686                ),
687            });
688        }
689    }
690    // Document-level send bound: optional; a present value goes
691    // through the same humantime grammar as the action deadlines,
692    // naming the field on failure (index 0 — the section, not an
693    // action, failed).
694    let send_deadline = raw
695        .send_deadline
696        .as_deref()
697        .map(|raw_deadline| parse_duration(raw_deadline, 0, "sendDeadline"))
698        .transpose()?;
699    // (j) Document-level log assertions (rc-tdgh5): the clause walk
700    // runs during validation so a malformed block is a load error
701    // naming the offending clause.
702    let logs = raw.logs.map(logs::logs_from_raw).transpose()?;
703    Ok(ScenarioDocument {
704        source_path: path.to_path_buf(),
705        route_source,
706        scenario,
707        partners,
708        env: raw.env,
709        env_passthrough: raw.env_passthrough,
710        profile: raw.profile,
711        send_deadline,
712        inbound,
713        logs,
714    })
715}
716
717/// Converts the raw `inbound:` node. The v1 grammar is a single map
718/// `inbound: {bindVar: NAME}`; unknown fields are rejected naming the
719/// key, mirroring the partners-section strictness. Structure is
720/// validated in every build so grammar errors read identically with
721/// and without the `http` feature; only a structurally valid
722/// declaration reaches the demand gate (ADR-0069 §8), which rejects it
723/// naming the section and the feature when the harness is built
724/// without `http`. Section-level errors use index 0 — the section, not
725/// an action, failed (the `sendDeadline` precedent).
726fn inbound_from_raw(value: serde_yaml::Value) -> Result<InboundListener, DocError> {
727    let section_error = |message: String| DocError::Validation { index: 0, message };
728    let serde_yaml::Value::Mapping(ref map) = value else {
729        return Err(section_error(format!(
730            "`inbound` must be a map with a `bindVar` key, got {value:?}"
731        )));
732    };
733    let mut bind_var: Option<String> = None;
734    for (key, value) in map {
735        match key.as_str() {
736            "bindVar" => {
737                let text = value.as_str().ok_or_else(|| {
738                    section_error(format!(
739                        "`inbound`: `bindVar` must be a string, got {value:?}"
740                    ))
741                })?;
742                bind_var = Some(text.to_string());
743            }
744            other => {
745                return Err(section_error(format!(
746                    "`inbound`: unknown field `{other}`; expected `bindVar`"
747                )));
748            }
749        }
750    }
751    let bind_var =
752        bind_var.ok_or_else(|| section_error("`inbound` requires a `bindVar` key".to_string()))?;
753    // Demand-gated activation (ADR-0069 §8): the grammar parsed; the
754    // activation needs the `http` feature, which provisions the
755    // listener.
756    #[cfg(not(feature = "http"))]
757    {
758        let _ = bind_var;
759        Err(section_error(
760            "`inbound` requires the `http` feature, which this harness build does \
761             not enable: rebuild with `--features http` (demand-gated activation)"
762                .to_string(),
763        ))
764    }
765    #[cfg(feature = "http")]
766    Ok(InboundListener { bind_var })
767}
768
769/// Converts a raw `sql:` action into the model, feature-split so the
770/// arm type checks in both configurations (bd rc-25lup.1).
771///
772/// Validation (read/empty-prepare defects, naming the action and
773/// statement index) runs in every build BEFORE the gate; only a
774/// structurally valid action reaches the demand gate, which — without
775/// the harness `sql` feature — rejects it naming the feature and the
776/// rebuild instruction (the `inbound:`/`http` precedent, ADR-0069 §8).
777#[cfg(feature = "sql")]
778fn sql_action_from_raw(
779    raw: crate::sql_action::RawSqlAction,
780    index: usize,
781) -> Result<ScenarioAction, DocError> {
782    let validated = crate::sql_action::validate_sql_action(&raw, index)
783        .map_err(|message| DocError::Validation { index, message })?;
784    Ok(ScenarioAction::Sql {
785        datasource: validated.datasource,
786        prepare: validated.prepare,
787    })
788}
789
790/// The feature-off twin: the same validation hook, then the named
791/// demand-gate error instead of the action.
792#[cfg(not(feature = "sql"))]
793fn sql_action_from_raw(
794    raw: crate::sql_action::RawSqlAction,
795    index: usize,
796) -> Result<ScenarioAction, DocError> {
797    if let Err(message) = crate::sql_action::validate_sql_action(&raw, index) {
798        return Err(DocError::Validation { index, message });
799    }
800    Err(DocError::Validation {
801        index,
802        message: "`sql` requires the `sql` feature, which this harness build does \
803                  not enable: rebuild with `--features sql` (demand-gated activation)"
804            .to_string(),
805    })
806}
807
808/// Parses inline `routes` through the shared DSL parser. `parse_yaml`
809/// expects a top-level `routes:` key; the inline value (the array under
810/// `routes:`) is wrapped back into that shape, the same as the unit-tier
811/// runner.
812fn parse_inline_routes(value: &serde_yaml::Value) -> Result<Vec<RouteDefinition>, DocError> {
813    let mut mapping = serde_yaml::Mapping::new();
814    mapping.insert("routes", value.clone());
815    let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
816        .map_err(|e| DocError::InlineRoutes(format!("failed to serialize inline routes: {e}")))?;
817    camel_dsl::parse_yaml(&text).map_err(|e| DocError::InlineRoutes(e.to_string()))
818}
819
820/// Converts one raw action item into the public model. An item is a
821/// single-key map (`send`, `receive`, `sleep`, `validate`, `sql`); dispatch
822/// runs here, not in serde, so every failure carries the action index.
823fn build_action(item: serde_yaml::Value, index: usize) -> Result<ScenarioAction, DocError> {
824    let action_error = |message: String| DocError::Validation { index, message };
825    let serde_yaml::Value::Mapping(ref map) = item else {
826        return Err(action_error(format!(
827            "action must be a single-key map (`send`, `receive`, `sleep`, `validate`, `sql`), got {item:?}"
828        )));
829    };
830    let Some((key, content)) = map.iter().next() else {
831        return Err(action_error(
832            "action must be a single-key map (`send`, `receive`, `sleep`, `validate`, `sql`), got an empty map"
833                .to_string(),
834        ));
835    };
836    if map.len() != 1 {
837        return Err(action_error(format!(
838            "action must declare exactly one key, got {}",
839            backticked(&map.keys().map(String::as_str).collect::<Vec<_>>())
840        )));
841    }
842    let action_error_from_serde = |e: serde_yaml::Error| action_error(e.to_string());
843    match key.as_str() {
844        "send" => {
845            let raw: RawSend =
846                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
847            let method = match raw.method {
848                Some(method) => {
849                    let upper = method.trim().to_ascii_uppercase();
850                    if !is_http_token(&upper) {
851                        return Err(action_error(format!(
852                            "send action `method` must be a valid HTTP method name, got `{method}`"
853                        )));
854                    }
855                    upper
856                }
857                None => {
858                    if raw.body.is_some() {
859                        "POST".to_string()
860                    } else {
861                        "GET".to_string()
862                    }
863                }
864            };
865            // (rc-qvz6) `expectReply` reads the synchronous reply only
866            // the context-stimulus `direct:` send produces; partner
867            // sends park their roundtrips for a later `receive` and
868            // fake adapters record sends without answering, so the
869            // assertion is rejected at load on every other scheme.
870            let scheme = ref_scheme(&raw.to.endpoint);
871            if raw.expect_reply.is_some() && scheme != Some("direct") {
872                return Err(DocError::ExpectReplyOnUnsupportedSend {
873                    index,
874                    // A scheme-less reference names no scheme to
875                    // render; the explicit phrase keeps the
876                    // diagnostic from degrading to an empty name.
877                    scheme: scheme.unwrap_or("no scheme").to_string(),
878                });
879            }
880            let expect_reply = raw
881                .expect_reply
882                .map(|value| expectation_from_value(&value, index, "expectReply"))
883                .transpose()?;
884            Ok(ScenarioAction::Send {
885                to: endpoint_from_raw(raw.to)?,
886                body: raw.body,
887                headers: raw.headers,
888                method,
889                expect_reply,
890            })
891        }
892        "receive" => {
893            let raw: RawReceive =
894                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
895            let deadline = raw.deadline.ok_or_else(|| {
896                action_error(
897                    "receive action requires a `deadline` (humantime string, e.g. `5s`)"
898                        .to_string(),
899                )
900            })?;
901            Ok(ScenarioAction::Receive {
902                from: endpoint_from_raw(raw.from)?,
903                deadline: parse_duration(&deadline, index, "deadline")?,
904                extract: raw.extract,
905            })
906        }
907        "sleep" => {
908            let raw: RawSleep =
909                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
910            Ok(ScenarioAction::Sleep {
911                duration: parse_duration(&raw.duration, index, "sleep duration")?,
912            })
913        }
914        "validate" => {
915            let raw: RawValidate =
916                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
917            let target = build_target(&raw.target, index)?;
918            let deadline = match raw.deadline.as_deref() {
919                None => None,
920                // The poll deadline exists because a partner count
921                // settles asynchronously and a sql read runs against a
922                // live datasource; on any other target it has no
923                // meaning and is a grammar error.
924                Some(raw_deadline)
925                    if matches!(target, ScenarioTarget::Partner(_) | ScenarioTarget::Sql(_)) =>
926                {
927                    Some(parse_duration(raw_deadline, index, "deadline")?)
928                }
929                Some(raw_deadline) => {
930                    return Err(action_error(format!(
931                        "`deadline` is only valid on a `partner` or `sql` validate target, got `{raw_deadline}`"
932                    )));
933                }
934            };
935            let elapsed_at_least = match raw.elapsed_at_least.as_deref() {
936                None => None,
937                // The elapsed bound measures the wire arrival of the
938                // last received message against the scenario start;
939                // only that target carries an arrival to measure.
940                Some(raw_bound) if matches!(target, ScenarioTarget::LastReceived(_)) => {
941                    Some(parse_duration(raw_bound, index, "elapsedAtLeast")?)
942                }
943                Some(raw_bound) => {
944                    return Err(action_error(format!(
945                        "`elapsedAtLeast` is only valid on a `lastReceived` validate target, got `{raw_bound}`"
946                    )));
947                }
948            };
949            let expectation = match &target {
950                ScenarioTarget::Partner(_) => ValidateExpectation::Partner(
951                    partner_expectation_from_value(&raw.expectation, index)?,
952                ),
953                ScenarioTarget::Sql(_) => {
954                    ValidateExpectation::Rows(sql_expectation_from_value(&raw.expectation, index)?)
955                }
956                _ => ValidateExpectation::Message(expectation_from_value(
957                    &raw.expectation,
958                    index,
959                    "expectation",
960                )?),
961            };
962            // Nondeterminism advisory (bd rc-25lup.2): an ordered
963            // `rows` assertion over a query without `ORDER BY` depends
964            // on the database's row return order. Advisory only — the
965            // grammar accepts the document; the warning names the
966            // action index so a big scenario stays triageable.
967            if let (ScenarioTarget::Sql(target), ValidateExpectation::Rows(rows)) =
968                (&target, &expectation)
969                && !rows.unordered
970                && rows.rows.is_some()
971                && sql_query_lacks_order_by(&target.query)
972            {
973                tracing::warn!(
974                    "validate action {index}: sql query has no `ORDER BY`; the ordered `rows` \
975                     assertion is nondeterministic without it — declare `unordered: true` or \
976                     add `ORDER BY`"
977                );
978            }
979            Ok(ScenarioAction::Validate {
980                target,
981                expectation,
982                deadline,
983                elapsed_at_least,
984            })
985        }
986        crate::sql_action::SQL_ACTION_KEY => {
987            let raw: crate::sql_action::RawSqlAction =
988                serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
989            // Ordering mandate (bd rc-25lup.1): validation runs BEFORE
990            // the feature demand gate, so a read or an empty prepare
991            // list fails doc-validation naming the action index and the
992            // statement index in BOTH feature configurations — the
993            // document defect is independent of what this build can
994            // execute (the inbound grammar precedent).
995            sql_action_from_raw(raw, index)
996        }
997        other => Err(action_error(format!(
998            "unknown action `{other}`; expected `send`, `receive`, `sleep`, `validate`, or `sql`"
999        ))),
1000    }
1001}
1002
1003/// Builds a `validate` target from the raw `target` node: a single-key
1004/// map (`lastReceived`, `variable`, `partner`, or `sql`).
1005fn build_target(value: &serde_yaml::Value, index: usize) -> Result<ScenarioTarget, DocError> {
1006    let action_error = |message: String| DocError::Validation { index, message };
1007    let serde_yaml::Value::Mapping(map) = value else {
1008        return Err(action_error(format!(
1009            "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`, or `sql`), got {value:?}"
1010        )));
1011    };
1012    let Some((key, content)) = map.iter().next() else {
1013        return Err(action_error(
1014            "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`, or `sql`), got an empty map"
1015                .to_string(),
1016        ));
1017    };
1018    match key.as_str() {
1019        "lastReceived" => {
1020            let raw: RawEndpointRef =
1021                serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1022            Ok(ScenarioTarget::LastReceived(endpoint_from_raw(raw)?))
1023        }
1024        "variable" => match content.as_str() {
1025            Some(name) => Ok(ScenarioTarget::Variable(name.to_string())),
1026            None => Err(action_error(format!(
1027                "validate `variable` target must be a string, got {content:?}"
1028            ))),
1029        },
1030        "partner" => {
1031            let raw: RawEndpointRef =
1032                serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1033            Ok(ScenarioTarget::Partner(endpoint_from_raw(raw)?))
1034        }
1035        "sql" => {
1036            let raw: RawSqlTarget =
1037                serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1038            if raw.datasource.is_empty() {
1039                return Err(action_error(
1040                    "validate `sql` target requires a non-empty `datasource`".to_string(),
1041                ));
1042            }
1043            if raw.query.is_empty() {
1044                return Err(action_error(
1045                    "validate `sql` target requires a non-empty `query`".to_string(),
1046                ));
1047            }
1048            // Vocabulary split (bd rc-25lup.1): the validate sql target
1049            // owns reads only; the `sql:` prepare action owns
1050            // mutations. Mirrors the prepare-side rejection phrasing.
1051            if !crate::sql_action::is_read_statement(&raw.query) {
1052                return Err(action_error(
1053                    "validate `sql` target: query is not a read (select/with prefix); reads \
1054                     belong to the validate sql target, the `sql:` prepare action owns mutations"
1055                        .to_string(),
1056                ));
1057            }
1058            Ok(ScenarioTarget::Sql(SqlTarget {
1059                datasource: raw.datasource,
1060                query: raw.query,
1061            }))
1062        }
1063        other => Err(action_error(format!(
1064            "unknown validate target `{other}`; expected `lastReceived`, `variable`, `partner`, or `sql`"
1065        ))),
1066    }
1067}
1068
1069/// The scheme prefix of an endpoint URI: the non-empty text before
1070/// the first `:`, or `None` when the URI carries no scheme — which
1071/// requires the separator; a colon-less string (`orders`) is a bare
1072/// name, not a scheme.
1073fn ref_scheme(endpoint: &str) -> Option<&str> {
1074    let (scheme, _) = endpoint.split_once(':')?;
1075    (!scheme.is_empty()).then_some(scheme)
1076}
1077
1078/// Whether `s` is a valid HTTP token: non-empty and composed only of
1079/// ASCII alphanumerics or one of ``!#$%&'*+-.^_`|~``. Crate-visible
1080/// for the parse-test module.
1081pub(crate) fn is_http_token(s: &str) -> bool {
1082    !s.is_empty()
1083        && s.chars().all(|c| {
1084            c.is_ascii_alphanumeric()
1085                || matches!(
1086                    c,
1087                    '!' | '#'
1088                        | '$'
1089                        | '%'
1090                        | '&'
1091                        | '\''
1092                        | '*'
1093                        | '+'
1094                        | '-'
1095                        | '.'
1096                        | '^'
1097                        | '_'
1098                        | '`'
1099                        | '|'
1100                        | '~'
1101                )
1102        })
1103}