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`, `elapsedAtLeast`) are humantime
18//! strings, for example `"5s"` or `"250ms"`, parsed during validation
19//! so errors can 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 /// Optional minimum wire-arrival age. Only valid on
140 /// `lastReceived` targets: the last received message must have
141 /// arrived at least this long after the scenario started (the
142 /// not-before-X control `run.sh` expresses with `awk`). The
143 /// assertion anchors to the message's wire arrival, never the
144 /// consumption time.
145 elapsed_at_least: Option<Duration>,
146 },
147}
148
149impl ScenarioAction {
150 /// The `(bind variable, endpoint)` bindings this action's endpoint
151 /// references declare.
152 fn bindings(&self) -> Vec<(&str, &str)> {
153 fn endpoint_bindings(endpoint: &EndpointRef) -> Vec<(&str, &str)> {
154 endpoint.binding().into_iter().collect()
155 }
156 match self {
157 Self::Send { to, .. } => endpoint_bindings(to),
158 Self::Receive { from, .. } => endpoint_bindings(from),
159 Self::Validate { target, .. } => match target {
160 ScenarioTarget::LastReceived(endpoint) => endpoint_bindings(endpoint),
161 ScenarioTarget::Partner(_) => Vec::new(),
162 ScenarioTarget::Variable(_) => Vec::new(),
163 },
164 Self::Sleep { .. } => Vec::new(),
165 }
166 }
167}
168
169/// What a `validate` action asserts against.
170#[derive(Debug, Clone, PartialEq)]
171#[non_exhaustive]
172pub enum ScenarioTarget {
173 /// The last message received on the endpoint.
174 LastReceived(EndpointRef),
175 /// A scenario variable set by an earlier `extract`. Variable
176 /// existence is validated at run time.
177 Variable(String),
178 /// A partner endpoint: the assertion reads the partner's recorded
179 /// request traffic. The URI must equal a harness endpoint
180 /// reference declared by the scenario's own `send`/`receive`
181 /// actions.
182 Partner(EndpointRef),
183}
184
185/// An endpoint reference: a bare endpoint string or a map with
186/// `endpoint`, `provisioning`, and `bindVar` keys.
187#[derive(Debug, Clone, PartialEq)]
188pub struct EndpointRef {
189 /// Endpoint URI, for example `http://127.0.0.1:9999/hook`.
190 pub endpoint: String,
191 /// Who owns the partner lifecycle; only `harness` is implemented in
192 /// v1.
193 pub provisioning: Option<Provisioning>,
194 /// Scenario variable name the harness fills with this endpoint's
195 /// bound address when provisioning is `harness`.
196 pub bind_var: Option<String>,
197}
198
199impl EndpointRef {
200 /// The `(bind variable, endpoint)` binding this reference declares,
201 /// if any. The reserved env-key rule collects these pairs.
202 fn binding(&self) -> Option<(&str, &str)> {
203 self.bind_var
204 .as_deref()
205 .map(|bind_var| (bind_var, self.endpoint.as_str()))
206 }
207}
208
209/// Partner provisioning source (ADR-0069 section 9). The axis is who
210/// owns the lifecycle. `testcontainer` and `user-provided` are reserved
211/// grammar values; the parser rejects them.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213#[non_exhaustive]
214pub enum Provisioning {
215 /// The harness binds an in-process listener on `127.0.0.1:0`. The
216 /// only source implemented in v1.
217 Harness,
218}
219
220/// The scripted responses a document's `partners:` entry maps to, for
221/// one endpoint key. `None` when the document declares no entry for
222/// the endpoint — the caller binds a permissive partner. `Some` maps
223/// each script grammar entry to its wire form: absent `status`
224/// defaults to 200, absent `times` to 1 (serve once), absent headers
225/// to the empty map, and the body follows the client send path's
226/// `value_to_wire` encoding (empty when absent); `delay` and `fault`
227/// map through.
228///
229/// The canonical `PartnerScript` → wire-form mapping; the CLI driver
230/// and library-level scenarios bind partners through this function so
231/// the semantics live in exactly one place.
232#[cfg(feature = "http")]
233pub fn partner_scripts_for(
234 doc: &ScenarioDocument,
235 endpoint: &str,
236) -> Option<Vec<crate::adapters::http::ScriptedResponse>> {
237 use crate::adapters::http::ScriptedResponse;
238 let scripts = doc.partners.as_ref()?.get(endpoint)?;
239 Some(
240 scripts
241 .iter()
242 .map(|script| {
243 let (status, headers, body) = match script.response.as_ref() {
244 Some(response) => (
245 response.status.unwrap_or(200),
246 response.headers.clone().unwrap_or_default(),
247 response.body.as_ref().map_or_else(Vec::new, |value| {
248 crate::adapters::http::value_to_wire(value)
249 }),
250 ),
251 // Fault entries carry no response; the placeholder
252 // keeps the wire form — serve checks the fault
253 // first, so the placeholder never reaches the wire.
254 None => (200, BTreeMap::new(), Vec::new()),
255 };
256 ScriptedResponse {
257 method: script.method.clone(),
258 path: script.path.clone(),
259 times: script.times.unwrap_or(1),
260 delay: script.delay,
261 fault: script.fault.clone(),
262 status,
263 headers,
264 body,
265 }
266 })
267 .collect(),
268 )
269}
270
271/// A validation expectation. The grammar keys mirror the mock-testkit
272/// matcher rules: `equals`, `regex`, `contains`, `startsWith`,
273/// `endsWith`, `exists`, `jsonSubset`.
274///
275/// Grammar (dual, `expectReply.body` style): a bare value is a literal
276/// `equals`; an object with exactly one recognized matcher key is that
277/// matcher (this reading takes precedence over the literal one); any
278/// other object — zero, multiple, or unrecognized keys — is a literal
279/// `equals` compared structurally. `regex` patterns are
280/// compile-verified at load time, matching the unit-tier matcher
281/// rules.
282#[derive(Debug, Clone, PartialEq)]
283#[non_exhaustive]
284pub enum Expectation {
285 /// Exact equality against the value.
286 Equals(Value),
287 /// Regular expression match, compile-verified at load time.
288 Regex(String),
289 /// Substring containment.
290 Contains(String),
291 /// Prefix match.
292 StartsWith(String),
293 /// Suffix match.
294 EndsWith(String),
295 /// The value under validation is present.
296 Exists,
297 /// Recursive-subset match against an object.
298 JsonSubset(Value),
299}
300
301/// The recorded-request count bound of a [`PartnerExpectation`]:
302/// exactly one bound form per expectation. Poll semantics per bound
303/// (arrivals only add, so the filtered count is monotone
304/// non-decreasing):
305///
306/// - Without a deadline, one immediate snapshot decides for every
307/// bound.
308/// - [`CountBound::Exact`] polls until a snapshot's count equals `n`;
309/// a snapshot above never passes.
310/// - [`CountBound::AtLeast`] succeeds early, once the count reaches
311/// `n` (sound: the count only grows).
312/// - [`CountBound::AtMost`] is an absence claim over the window: it
313/// waits the full deadline, fails immediately on any snapshot above
314/// `n`, and decides on the final snapshot — an early passing
315/// snapshot cannot prove the count stays within bounds.
316/// - [`CountBound::Range`] fails immediately above the maximum and
317/// otherwise waits the full deadline, deciding on the final
318/// snapshot within `[min, max]`.
319#[derive(Debug, Clone, PartialEq)]
320#[non_exhaustive]
321pub enum CountBound {
322 /// Exactly `n` matching requests.
323 Exact(u64),
324 /// At least `n` matching requests (early success at `n` or more).
325 AtLeast(u64),
326 /// At most `n` matching requests (absence claim over the window).
327 AtMost(u64),
328 /// Between `min` and `max` matching requests, inclusive.
329 Range(u64, u64),
330}
331
332/// The path filter of a [`PartnerExpectation`] over the recorded
333/// path-and-query; at most one filter per expectation.
334#[derive(Debug, Clone, PartialEq)]
335#[non_exhaustive]
336pub enum PathFilter {
337 /// Exact path-and-query match (strict bytes).
338 Exact(String),
339 /// Substring containment against the recorded path-and-query.
340 Contains(String),
341 /// Regular expression match, compile-verified at load time.
342 Matches(String),
343}
344
345/// The partner expectation of a `validate` action with a `partner`
346/// target: a recorded-request count bound plus optional `method`,
347/// `path`, and `query` subset filters.
348#[derive(Debug, Clone, PartialEq)]
349pub struct PartnerExpectation {
350 /// The count bound the recorded requests must satisfy.
351 pub bound: CountBound,
352 /// Optional request-method filter.
353 pub method: Option<String>,
354 /// Optional request-path filter (path-and-query).
355 pub path: Option<PathFilter>,
356 /// Optional query subset filter: every declared pair must be
357 /// present (order- and encoding-independent) in the recorded
358 /// request's percent-decoded query.
359 pub query: Option<BTreeMap<String, String>>,
360}
361
362/// The expectation of a `validate` action, keyed by its target: the
363/// message matcher grammar for `lastReceived` and `variable` targets,
364/// the partner count grammar for `partner` targets.
365#[derive(Debug, Clone, PartialEq)]
366#[non_exhaustive]
367pub enum ValidateExpectation {
368 /// Message matcher expectation (`lastReceived` / `variable`).
369 Message(Expectation),
370 /// Partner request-count expectation (`partner`).
371 Partner(PartnerExpectation),
372}
373
374// ---------------------------------------------------------------------------
375// Raw serde stage
376// ---------------------------------------------------------------------------
377
378/// Raw document form. Unit-tier sections are captured, not rejected at
379/// the serde layer, so the mixing ban can name them. Scenario items
380/// stay raw values: the single-key action dispatch runs during
381/// validation so errors can name the action index.
382#[derive(Deserialize)]
383#[serde(deny_unknown_fields, rename_all = "camelCase")]
384struct RawDocument {
385 route_files: Option<Vec<String>>,
386 route_files_from_root: Option<Vec<String>>,
387 routes: Option<serde_yaml::Value>,
388 scenario: Option<Vec<serde_yaml::Value>>,
389 env: Option<BTreeMap<String, String>>,
390 env_passthrough: Option<Vec<String>>,
391 profile: Option<String>,
392 // Document-level partner scripting: the raw map stays
393 // endpoint-keyed with raw sequence values; conversion runs during
394 // validation so errors can name the entry key.
395 partners: Option<BTreeMap<String, serde_yaml::Value>>,
396 // Unit-tier vocabulary, present only to detect and name the mixing
397 // ban violation.
398 inputs: Option<serde_yaml::Value>,
399 expects: Option<serde_yaml::Value>,
400 intercepts: Option<serde_yaml::Value>,
401}
402
403#[derive(Deserialize)]
404#[serde(deny_unknown_fields, rename_all = "camelCase")]
405struct RawSend {
406 to: RawEndpointRef,
407 body: Option<Value>,
408 headers: Option<BTreeMap<String, Value>>,
409 /// Raw `method` string; optional. Validation resolves it (explicit
410 /// or inferred from body presence) so errors can name the action
411 /// index.
412 method: Option<String>,
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#[derive(Deserialize)]
433#[serde(deny_unknown_fields, rename_all = "camelCase")]
434struct RawValidate {
435 /// Raw `target` node; the single-key form (`lastReceived` /
436 /// `variable` / `partner`) converts during validation.
437 target: serde_yaml::Value,
438 expectation: Value,
439 /// Raw humantime string; partner targets only, parsed during
440 /// validation so the error can name the action index.
441 deadline: Option<String>,
442 /// Raw humantime string; `lastReceived` targets only, parsed
443 /// during validation so the error can name the action index.
444 elapsed_at_least: Option<String>,
445}
446
447/// Raw endpoint reference: bare string or map with `endpoint`,
448/// `provisioning`, and `bindVar`.
449#[derive(Debug, Clone)]
450struct RawEndpointRef {
451 endpoint: String,
452 provisioning: Option<String>,
453 bind_var: Option<String>,
454}
455
456impl RawEndpointRef {
457 /// Deserializes from a bare string (shorthand) or a map.
458 fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
459 match value {
460 serde_yaml::Value::String(endpoint) => Ok(Self {
461 endpoint,
462 provisioning: None,
463 bind_var: None,
464 }),
465 serde_yaml::Value::Mapping(ref map) => {
466 // Field-by-field extraction: a hand-rolled map walk gives
467 // errors that name the offending key, which the
468 // deny_unknown_fields machinery of the compat shim
469 // cannot.
470 let mut endpoint: Option<String> = None;
471 let mut provisioning: Option<String> = None;
472 let mut bind_var: Option<String> = None;
473 for (key, value) in map {
474 match key.as_str() {
475 "endpoint" | "provisioning" | "bindVar" => {
476 let text = value.as_str().ok_or_else(|| {
477 format!(
478 "endpoint reference `{key}` must be a string, got {value:?}"
479 )
480 })?;
481 match key.as_str() {
482 "endpoint" => endpoint = Some(text.to_string()),
483 "provisioning" => provisioning = Some(text.to_string()),
484 _ => bind_var = Some(text.to_string()),
485 }
486 }
487 other => {
488 return Err(format!("unknown field `{other}` in endpoint reference"));
489 }
490 }
491 }
492 let endpoint = endpoint
493 .ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
494 Ok(Self {
495 endpoint,
496 provisioning,
497 bind_var,
498 })
499 }
500 other => Err(format!(
501 "endpoint reference must be a string or a map, got {other:?}"
502 )),
503 }
504 }
505}
506
507impl<'de> Deserialize<'de> for RawEndpointRef {
508 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
509 where
510 D: Deserializer<'de>,
511 {
512 let value = serde_yaml::Value::deserialize(deserializer)?;
513 RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
514 }
515}
516
517// ---------------------------------------------------------------------------
518// Errors
519// ---------------------------------------------------------------------------
520
521/// Parse and validation errors for scenario documents.
522///
523/// Exit-code mapping for the CLI adapter (ADR-0069 section 7):
524/// classification is by variant, never by message text. Every variant
525/// is a load-time failure and maps to exit 2.
526///
527/// - `doc-validation` class — Display carries the `doc-validation:`
528/// token: `NotTestDocument`, `MissingScenario`, `MixedVocabulary`,
529/// `Validation`, `ReservedEnvKey`, `InlineRoutes`.
530/// - `infra-unavailable` class — `UnsupportedProvisioning` (reserved
531/// provisioning grammar; Display names the class).
532/// - Unit-tier message parity — `RouteSourceMissing` and
533/// `RouteSourceConflict` render the unit-tier parser's messages
534/// verbatim, without the token, so both parsers report identical
535/// text; the CLI maps them to exit 2 as doc parse errors, the same
536/// as the unit tier does today.
537/// - Read and serde failures — `Io`, `Yaml`, `UnknownField` map to
538/// exit 2 as doc parse errors (unreadable file, broken grammar).
539#[derive(Debug, thiserror::Error)]
540#[non_exhaustive]
541pub enum DocError {
542 /// The document file could not be read.
543 #[error("failed to read test document {path}: {source}")]
544 Io {
545 /// Path of the unreadable document.
546 path: PathBuf,
547 /// Underlying read failure.
548 source: std::io::Error,
549 },
550 /// Malformed YAML or a type mismatch at the serde layer.
551 #[error("invalid test document: {0}")]
552 Yaml(String),
553 /// A `deny_unknown_fields` rejection.
554 #[error("unknown field in test document: {0}")]
555 UnknownField(String),
556 /// The path lacks the reserved `.test.yaml` / `.test.yml` suffix.
557 #[error(
558 "doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
559 )]
560 NotTestDocument {
561 /// The rejected path.
562 path: PathBuf,
563 },
564 /// The document declares no `scenario:` section.
565 #[error("doc-validation: scenario document must declare a `scenario:` section")]
566 MissingScenario,
567 /// The document mixes the scenario vocabulary with unit-tier
568 /// sections.
569 #[error(
570 "doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
571 )]
572 MixedVocabulary {
573 /// The unit-tier fields found, backticked and comma-joined.
574 found: String,
575 },
576 /// No route source is declared. Same message as the unit-tier
577 /// parser.
578 #[error(
579 "exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
580 )]
581 RouteSourceMissing,
582 /// More than one route source is declared. Same message as the
583 /// unit-tier parser.
584 #[error("route sources {present} are mutually exclusive; exactly one route source is required")]
585 RouteSourceConflict {
586 /// The declared keys, backticked and comma-joined.
587 present: String,
588 },
589 /// An action failed validation; `index` is the position in the
590 /// `scenario:` list. An empty `scenario:` list is rejected with
591 /// index 0 (the section, not an action, failed).
592 #[error("doc-validation: scenario[{index}]: {message}")]
593 Validation {
594 /// Zero-based position of the action in the `scenario:` list.
595 index: usize,
596 /// What failed.
597 message: String,
598 },
599 /// The endpoint declares a provisioning source that is reserved in
600 /// v1; only `harness` is supported.
601 #[error(
602 "doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
603 )]
604 UnsupportedProvisioning {
605 /// The rejected provisioning value.
606 value: String,
607 /// The endpoint that declared it.
608 endpoint: String,
609 },
610 /// A document `env` key equals an endpoint's `bindVar`. The
611 /// reserved set is exactly the `bindVar` values declared by the
612 /// document's own endpoints; the harness binding wins.
613 #[error(
614 "doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
615 )]
616 ReservedEnvKey {
617 /// The reserved key.
618 key: String,
619 /// The endpoint that reserved it.
620 endpoint: String,
621 },
622 /// A `partners` entry failed validation; `endpoint` is the entry
623 /// key of the failing script list.
624 #[error("doc-validation: partners[{endpoint}]: {message}")]
625 Partners {
626 /// The endpoint key of the failing entry.
627 endpoint: String,
628 /// What failed.
629 message: String,
630 },
631 /// Inline `routes` failed to parse.
632 #[error("doc-validation: inline routes: {0}")]
633 InlineRoutes(String),
634}
635
636/// Classifies a compat-layer (serde_yaml) error text, mirroring the
637/// unit-tier classifier.
638fn classify_yaml_error(raw: &str) -> DocError {
639 if raw.contains("unknown field") {
640 return DocError::UnknownField(raw.to_string());
641 }
642 DocError::Yaml(raw.to_string())
643}
644
645// ---------------------------------------------------------------------------
646// Parsing
647// ---------------------------------------------------------------------------
648
649/// Parses and validates a scenario document. Validation order:
650/// (a) the path carries a reserved test-document suffix; (b) the text
651/// deserializes; (c) a non-empty `scenario:` section exists; (d) no
652/// unit-tier section coexists with it; (e) exactly one route source
653/// is declared;
654/// (f) each action converts (single-key dispatch, deadlines, durations,
655/// endpoint provisioning, expectation grammar) with action-index
656/// errors; (g) each `partners` entry converts (script grammar, response
657/// status range) with entry-key errors; (h) no `env` key collides with
658/// a declared `bindVar`; (i) each `partner` validate target URI equals
659/// a harness endpoint reference declared by the scenario's own
660/// `send`/`receive` actions.
661pub fn parse_scenario_document(path: &Path) -> Result<ScenarioDocument, DocError> {
662 if !camel_dsl::discovery::is_test_document(path) {
663 return Err(DocError::NotTestDocument {
664 path: path.to_path_buf(),
665 });
666 }
667 let text = std::fs::read_to_string(path).map_err(|source| DocError::Io {
668 path: path.to_path_buf(),
669 source,
670 })?;
671 let raw = serde_yaml::from_str::<RawDocument>(&text)
672 .map_err(|e| classify_yaml_error(&e.to_string()))?;
673
674 // (c) This parser accepts scenario documents only, and the
675 // scenario list must be non-empty: an empty list would yield a
676 // trivially-green FULL document with zero actions (mirrors the
677 // unit tier's non-empty `expects` rule).
678 let Some(raw_scenario) = raw.scenario else {
679 return Err(DocError::MissingScenario);
680 };
681 if raw_scenario.is_empty() {
682 return Err(DocError::Validation {
683 index: 0,
684 message: "`scenario` must declare at least one action".to_string(),
685 });
686 }
687 // (d) Mixing ban (ADR-0069 section 2).
688 let mut unit_tier: Vec<&str> = Vec::new();
689 if raw.inputs.is_some() {
690 unit_tier.push("inputs");
691 }
692 if raw.expects.is_some() {
693 unit_tier.push("expects");
694 }
695 if raw.intercepts.is_some() {
696 unit_tier.push("intercepts");
697 }
698 if !unit_tier.is_empty() {
699 return Err(DocError::MixedVocabulary {
700 found: backticked(&unit_tier),
701 });
702 }
703 // (e) Exactly one route source, with the unit-tier messages.
704 let mut present: Vec<&'static str> = Vec::new();
705 if raw.route_files.is_some() {
706 present.push("routeFiles");
707 }
708 if raw.route_files_from_root.is_some() {
709 present.push("routeFilesFromRoot");
710 }
711 if raw.routes.is_some() {
712 present.push("routes");
713 }
714 let route_source = match present.as_slice() {
715 ["routeFiles"] => RouteSource::RouteFiles(
716 raw.route_files
717 .unwrap_or_default()
718 .into_iter()
719 .map(PathBuf::from)
720 .collect(),
721 ),
722 ["routeFilesFromRoot"] => RouteSource::RouteFilesFromRoot(
723 raw.route_files_from_root
724 .unwrap_or_default()
725 .into_iter()
726 .map(PathBuf::from)
727 .collect(),
728 ),
729 ["routes"] => {
730 let value = raw.routes.unwrap_or(serde_yaml::Value::Null);
731 RouteSource::Inline(parse_inline_routes(&value)?)
732 }
733 [] => return Err(DocError::RouteSourceMissing),
734 _ => {
735 return Err(DocError::RouteSourceConflict {
736 present: backticked(&present),
737 });
738 }
739 };
740 // (f) Action conversion.
741 let mut scenario = Vec::with_capacity(raw_scenario.len());
742 for (index, item) in raw_scenario.into_iter().enumerate() {
743 scenario.push(build_action(item, index)?);
744 }
745 // (g) Partner scripting: entries convert from the raw sequence
746 // with the entry key named on every failure; an empty sequence is
747 // a valid, inert entry. The grammar conversion lives in the
748 // partner-script module.
749 let partners = crate::partner_script::partners_from_raw(raw.partners)?;
750 // (h) Reserved env keys: the harness binding wins over document
751 // fixtures.
752 if let Some(env) = raw.env.as_ref() {
753 for action in &scenario {
754 for (bind_var, endpoint) in action.bindings() {
755 if env.contains_key(bind_var) {
756 return Err(DocError::ReservedEnvKey {
757 key: bind_var.to_string(),
758 endpoint: endpoint.to_string(),
759 });
760 }
761 }
762 }
763 }
764 // (i) Partner-target cross-check: a `partner` validate target URI
765 // must equal a harness endpoint reference declared by the
766 // scenario's own `send`/`receive` actions (URI string equality).
767 // A typo'd URI would otherwise assert against traffic nobody
768 // records.
769 let mut harness_uris: Vec<&str> = Vec::new();
770 let mut partner_targets: Vec<(usize, &EndpointRef)> = Vec::new();
771 for (index, action) in scenario.iter().enumerate() {
772 match action {
773 ScenarioAction::Send { to, .. } => {
774 if to.provisioning == Some(Provisioning::Harness) {
775 harness_uris.push(to.endpoint.as_str());
776 }
777 }
778 ScenarioAction::Receive { from, .. } => {
779 if from.provisioning == Some(Provisioning::Harness) {
780 harness_uris.push(from.endpoint.as_str());
781 }
782 }
783 ScenarioAction::Validate {
784 target: ScenarioTarget::Partner(endpoint),
785 ..
786 } => partner_targets.push((index, endpoint)),
787 _ => {}
788 }
789 }
790 for (index, endpoint) in partner_targets {
791 if !harness_uris.contains(&endpoint.endpoint.as_str()) {
792 return Err(DocError::Validation {
793 index,
794 message: format!(
795 "validate `partner` target `{}` does not match any harness endpoint reference declared by this scenario's `send`/`receive` actions",
796 endpoint.endpoint
797 ),
798 });
799 }
800 }
801 Ok(ScenarioDocument {
802 route_source,
803 scenario,
804 partners,
805 env: raw.env,
806 env_passthrough: raw.env_passthrough,
807 profile: raw.profile,
808 })
809}
810
811/// Parses inline `routes` through the shared DSL parser. `parse_yaml`
812/// expects a top-level `routes:` key; the inline value (the array under
813/// `routes:`) is wrapped back into that shape, the same as the unit-tier
814/// runner.
815fn parse_inline_routes(value: &serde_yaml::Value) -> Result<Vec<RouteDefinition>, DocError> {
816 let mut mapping = serde_yaml::Mapping::new();
817 mapping.insert("routes", value.clone());
818 let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
819 .map_err(|e| DocError::InlineRoutes(format!("failed to serialize inline routes: {e}")))?;
820 camel_dsl::parse_yaml(&text).map_err(|e| DocError::InlineRoutes(e.to_string()))
821}
822
823/// Converts one raw action item into the public model. An item is a
824/// single-key map (`send`, `receive`, `sleep`, `validate`); dispatch
825/// runs here, not in serde, so every failure carries the action index.
826fn build_action(item: serde_yaml::Value, index: usize) -> Result<ScenarioAction, DocError> {
827 let action_error = |message: String| DocError::Validation { index, message };
828 let serde_yaml::Value::Mapping(ref map) = item else {
829 return Err(action_error(format!(
830 "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got {item:?}"
831 )));
832 };
833 let Some((key, content)) = map.iter().next() else {
834 return Err(action_error(
835 "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got an empty map"
836 .to_string(),
837 ));
838 };
839 if map.len() != 1 {
840 return Err(action_error(format!(
841 "action must declare exactly one key, got {}",
842 backticked(&map.keys().map(String::as_str).collect::<Vec<_>>())
843 )));
844 }
845 let action_error_from_serde = |e: serde_yaml::Error| action_error(e.to_string());
846 match key.as_str() {
847 "send" => {
848 let raw: RawSend =
849 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
850 let method = match raw.method {
851 Some(method) => {
852 let upper = method.trim().to_ascii_uppercase();
853 if !is_http_token(&upper) {
854 return Err(action_error(format!(
855 "send action `method` must be a valid HTTP method name, got `{method}`"
856 )));
857 }
858 upper
859 }
860 None => {
861 if raw.body.is_some() {
862 "POST".to_string()
863 } else {
864 "GET".to_string()
865 }
866 }
867 };
868 Ok(ScenarioAction::Send {
869 to: endpoint_from_raw(raw.to)?,
870 body: raw.body,
871 headers: raw.headers,
872 method,
873 })
874 }
875 "receive" => {
876 let raw: RawReceive =
877 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
878 let deadline = raw.deadline.ok_or_else(|| {
879 action_error(
880 "receive action requires a `deadline` (humantime string, e.g. `5s`)"
881 .to_string(),
882 )
883 })?;
884 Ok(ScenarioAction::Receive {
885 from: endpoint_from_raw(raw.from)?,
886 deadline: parse_duration(&deadline, index, "deadline")?,
887 extract: raw.extract,
888 })
889 }
890 "sleep" => {
891 let raw: RawSleep =
892 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
893 Ok(ScenarioAction::Sleep {
894 duration: parse_duration(&raw.duration, index, "sleep duration")?,
895 })
896 }
897 "validate" => {
898 let raw: RawValidate =
899 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
900 let target = build_target(&raw.target, index)?;
901 let deadline = match raw.deadline.as_deref() {
902 None => None,
903 // The poll deadline exists because a partner count
904 // settles asynchronously; on any other target it has
905 // no meaning and is a grammar error.
906 Some(raw_deadline) if matches!(target, ScenarioTarget::Partner(_)) => {
907 Some(parse_duration(raw_deadline, index, "deadline")?)
908 }
909 Some(raw_deadline) => {
910 return Err(action_error(format!(
911 "`deadline` is only valid on a `partner` validate target, got `{raw_deadline}`"
912 )));
913 }
914 };
915 let elapsed_at_least = match raw.elapsed_at_least.as_deref() {
916 None => None,
917 // The elapsed bound measures the wire arrival of the
918 // last received message against the scenario start;
919 // only that target carries an arrival to measure.
920 Some(raw_bound) if matches!(target, ScenarioTarget::LastReceived(_)) => {
921 Some(parse_duration(raw_bound, index, "elapsedAtLeast")?)
922 }
923 Some(raw_bound) => {
924 return Err(action_error(format!(
925 "`elapsedAtLeast` is only valid on a `lastReceived` validate target, got `{raw_bound}`"
926 )));
927 }
928 };
929 let expectation = match &target {
930 ScenarioTarget::Partner(_) => ValidateExpectation::Partner(
931 partner_expectation_from_value(&raw.expectation, index)?,
932 ),
933 _ => ValidateExpectation::Message(expectation_from_value(&raw.expectation, index)?),
934 };
935 Ok(ScenarioAction::Validate {
936 target,
937 expectation,
938 deadline,
939 elapsed_at_least,
940 })
941 }
942 other => Err(action_error(format!(
943 "unknown action `{other}`; expected `send`, `receive`, `sleep`, or `validate`"
944 ))),
945 }
946}
947
948/// Builds a `validate` target from the raw `target` node: a single-key
949/// map (`lastReceived`, `variable`, or `partner`).
950fn build_target(value: &serde_yaml::Value, index: usize) -> Result<ScenarioTarget, DocError> {
951 let action_error = |message: String| DocError::Validation { index, message };
952 let serde_yaml::Value::Mapping(map) = value else {
953 return Err(action_error(format!(
954 "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got {value:?}"
955 )));
956 };
957 let Some((key, content)) = map.iter().next() else {
958 return Err(action_error(
959 "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got an empty map"
960 .to_string(),
961 ));
962 };
963 match key.as_str() {
964 "lastReceived" => {
965 let raw: RawEndpointRef =
966 serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
967 Ok(ScenarioTarget::LastReceived(endpoint_from_raw(raw)?))
968 }
969 "variable" => match content.as_str() {
970 Some(name) => Ok(ScenarioTarget::Variable(name.to_string())),
971 None => Err(action_error(format!(
972 "validate `variable` target must be a string, got {content:?}"
973 ))),
974 },
975 "partner" => {
976 let raw: RawEndpointRef =
977 serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
978 Ok(ScenarioTarget::Partner(endpoint_from_raw(raw)?))
979 }
980 other => Err(action_error(format!(
981 "unknown validate target `{other}`; expected `lastReceived`, `variable`, or `partner`"
982 ))),
983 }
984}
985
986/// Applies the provisioning gate: only `harness` (or absent) passes.
987fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
988 let provisioning = match raw.provisioning.as_deref() {
989 None => None,
990 Some("harness") => Some(Provisioning::Harness),
991 Some(value) => {
992 return Err(DocError::UnsupportedProvisioning {
993 value: value.to_string(),
994 endpoint: raw.endpoint.clone(),
995 });
996 }
997 };
998 Ok(EndpointRef {
999 endpoint: raw.endpoint,
1000 provisioning,
1001 bind_var: raw.bind_var,
1002 })
1003}
1004
1005/// Parses a humantime duration string, naming the action index on
1006/// failure.
1007fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
1008 humantime::parse_duration(raw).map_err(|e| DocError::Validation {
1009 index,
1010 message: format!("invalid {field} `{raw}`: {e}"),
1011 })
1012}
1013
1014/// Whether `s` is a valid HTTP token: non-empty and composed only of
1015/// ASCII alphanumerics or one of ``!#$%&'*+-.^_`|~``. Crate-visible
1016/// for the parse-test module.
1017pub(crate) fn is_http_token(s: &str) -> bool {
1018 !s.is_empty()
1019 && s.chars().all(|c| {
1020 c.is_ascii_alphanumeric()
1021 || matches!(
1022 c,
1023 '!' | '#'
1024 | '$'
1025 | '%'
1026 | '&'
1027 | '\''
1028 | '*'
1029 | '+'
1030 | '-'
1031 | '.'
1032 | '^'
1033 | '_'
1034 | '`'
1035 | '|'
1036 | '~'
1037 )
1038 })
1039}
1040
1041/// Recognized expectation matcher keys.
1042fn is_matcher_key(key: &str) -> bool {
1043 matches!(
1044 key,
1045 "equals" | "regex" | "contains" | "startsWith" | "endsWith" | "exists" | "jsonSubset"
1046 )
1047}
1048
1049/// Applies the expectation dual grammar: a bare value is a literal
1050/// `equals`; an object whose single key is a recognized matcher key is
1051/// that matcher; any other object is a literal `equals`. Payload shapes
1052/// mirror the mock-testkit matcher rules.
1053fn expectation_from_value(value: &Value, index: usize) -> Result<Expectation, DocError> {
1054 const FIELD: &str = "expectation";
1055 let invalid = |message: String| DocError::Validation { index, message };
1056 if let Value::Object(map) = value
1057 && map.len() == 1
1058 && let Some((key, payload)) = map.iter().next()
1059 && is_matcher_key(key)
1060 {
1061 return match key.as_str() {
1062 "equals" => Ok(Expectation::Equals(payload.clone())),
1063 "regex" | "contains" | "startsWith" | "endsWith" => {
1064 let Some(pattern) = payload.as_str() else {
1065 return Err(invalid(format!(
1066 "{FIELD}: `{key}` requires a string payload"
1067 )));
1068 };
1069 if key.as_str() == "regex"
1070 && let Err(e) = regex::Regex::new(pattern)
1071 {
1072 return Err(invalid(format!("{FIELD}: invalid regex `{pattern}`: {e}")));
1073 }
1074 Ok(match key.as_str() {
1075 "regex" => Expectation::Regex(pattern.to_string()),
1076 "contains" => Expectation::Contains(pattern.to_string()),
1077 "startsWith" => Expectation::StartsWith(pattern.to_string()),
1078 _ => Expectation::EndsWith(pattern.to_string()),
1079 })
1080 }
1081 "exists" => {
1082 if payload.is_null() {
1083 Ok(Expectation::Exists)
1084 } else {
1085 Err(invalid(format!("{FIELD}: `exists` takes no argument")))
1086 }
1087 }
1088 _ => {
1089 if payload.is_object() {
1090 Ok(Expectation::JsonSubset(payload.clone()))
1091 } else {
1092 Err(invalid(format!("{FIELD}: `jsonSubset` must be an object")))
1093 }
1094 }
1095 };
1096 }
1097 Ok(Expectation::Equals(value.clone()))
1098}
1099
1100/// Applies the partner expectation grammar: a map with exactly one
1101/// count bound (`count`; or `atLeast`, `atMost`, or their range), an
1102/// optional `method` string, at most one path filter (`path`,
1103/// `pathContains`, `pathMatches` — the regex compiled at load), and
1104/// an optional `query` subset map of string keys to string values;
1105/// unknown keys fail. Field-by-field extraction, like the
1106/// endpoint-reference reader, so errors name the offending key.
1107fn partner_expectation_from_value(
1108 value: &Value,
1109 index: usize,
1110) -> Result<PartnerExpectation, DocError> {
1111 const FIELD: &str = "partner expectation";
1112 const KEYS: &[&str] = &[
1113 "count",
1114 "atLeast",
1115 "atMost",
1116 "method",
1117 "path",
1118 "pathContains",
1119 "pathMatches",
1120 "query",
1121 ];
1122 let invalid = |message: String| DocError::Validation { index, message };
1123 let Value::Object(map) = value else {
1124 return Err(invalid(format!(
1125 "{FIELD} must be a map with a count bound, got {value:?}"
1126 )));
1127 };
1128 let mut count: Option<u64> = None;
1129 let mut at_least: Option<u64> = None;
1130 let mut at_most: Option<u64> = None;
1131 let mut method: Option<String> = None;
1132 let mut path: Option<PathFilter> = None;
1133 let mut path_key: Option<&str> = None;
1134 let mut query: Option<BTreeMap<String, String>> = None;
1135 for (key, payload) in map {
1136 match key.as_str() {
1137 "count" | "atLeast" | "atMost" => {
1138 let bound = payload.as_u64().ok_or_else(|| {
1139 invalid(format!(
1140 "{FIELD}: `{key}` must be a non-negative integer, got {payload}"
1141 ))
1142 })?;
1143 match key.as_str() {
1144 "count" => count = Some(bound),
1145 "atLeast" => at_least = Some(bound),
1146 _ => at_most = Some(bound),
1147 }
1148 }
1149 "method" => {
1150 let text = payload.as_str().ok_or_else(|| {
1151 invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1152 })?;
1153 method = Some(text.to_string());
1154 }
1155 "path" | "pathContains" | "pathMatches" => {
1156 if let Some(first) = path_key {
1157 return Err(invalid(format!(
1158 "{FIELD}: `{first}` and `{key}` are exclusive: at most one path filter"
1159 )));
1160 }
1161 let text = payload.as_str().ok_or_else(|| {
1162 invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1163 })?;
1164 path = Some(match key.as_str() {
1165 "path" => PathFilter::Exact(text.to_string()),
1166 "pathContains" => PathFilter::Contains(text.to_string()),
1167 _ => {
1168 if let Err(e) = regex::Regex::new(text) {
1169 return Err(invalid(format!("{FIELD}: invalid regex `{text}`: {e}")));
1170 }
1171 PathFilter::Matches(text.to_string())
1172 }
1173 });
1174 path_key = Some(key.as_str());
1175 }
1176 "query" => {
1177 let Value::Object(pairs) = payload else {
1178 return Err(invalid(format!(
1179 "{FIELD}: `query` must be a map of string keys to string values, got {payload}"
1180 )));
1181 };
1182 let mut subset = BTreeMap::new();
1183 for (name, pair) in pairs {
1184 let Some(text) = pair.as_str() else {
1185 return Err(invalid(format!(
1186 "{FIELD}: `query` value for `{name}` must be a string, got {pair}"
1187 )));
1188 };
1189 subset.insert(name.clone(), text.to_string());
1190 }
1191 query = Some(subset);
1192 }
1193 other => {
1194 return Err(invalid(format!(
1195 "{FIELD}: unknown field `{other}`; expected {}",
1196 backticked(KEYS)
1197 )));
1198 }
1199 }
1200 }
1201 if count.is_some() && (at_least.is_some() || at_most.is_some()) {
1202 let mut others: Vec<&str> = Vec::new();
1203 if at_least.is_some() {
1204 others.push("atLeast");
1205 }
1206 if at_most.is_some() {
1207 others.push("atMost");
1208 }
1209 return Err(invalid(format!(
1210 "{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
1211 backticked(&others)
1212 )));
1213 }
1214 let bound = if let Some(exact) = count {
1215 CountBound::Exact(exact)
1216 } else if let (Some(min), Some(max)) = (at_least, at_most) {
1217 if min > max {
1218 return Err(invalid(format!(
1219 "{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
1220 )));
1221 }
1222 CountBound::Range(min, max)
1223 } else if let Some(n) = at_least {
1224 CountBound::AtLeast(n)
1225 } else if let Some(n) = at_most {
1226 CountBound::AtMost(n)
1227 } else {
1228 return Err(invalid(format!(
1229 "{FIELD}: requires a count bound: `count`, `atLeast`, or `atMost`"
1230 )));
1231 };
1232 Ok(PartnerExpectation {
1233 bound,
1234 method,
1235 path,
1236 query,
1237 })
1238}
1239
1240/// Backticks and comma-joins field names for error messages.
1241fn backticked(fields: &[&str]) -> String {
1242 fields
1243 .iter()
1244 .map(|field| format!("`{field}`"))
1245 .collect::<Vec<_>>()
1246 .join(", ")
1247}