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`, `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}