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