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}