Skip to main content

camel_integration_test/
document.rs

1//! Scenario document model, parsing, and validation (ADR-0069 sections
2//! 1-2).
3//!
4//! A scenario document is a `.test.yaml` (or `.test.yml`) sidecar that
5//! declares one integration-tier test: exactly one route source
6//! (`routeFiles`, `routeFilesFromRoot`, or inline `routes`), an ordered
7//! `scenario:` action list, an optional `env:` map with fixed fixture
8//! values, an optional `envPassthrough:` allowlist, an optional
9//! endpoint-keyed `partners:` scripting map, 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}