camel_integration_test/document.rs
1//! Scenario document model, parsing, and validation (ADR-0069 sections
2//! 1-2).
3//!
4//! A scenario document is a `.test.yaml` (or `.test.yml`) sidecar that
5//! declares one integration-tier test: exactly one route source
6//! (`routeFiles`, `routeFilesFromRoot`, or inline `routes`), an ordered
7//! `scenario:` action list, an optional `env:` map with fixed fixture
8//! values, an optional `envPassthrough:` allowlist, an optional
9//! endpoint-keyed `partners:` scripting map, an optional pinned
10//! `profile`, an optional document-level `sendDeadline` bounding
11//! every send, and an optional document-level `inbound:` listener
12//! declaration (feature `http`). Unknown fields are rejected.
13//!
14//! The scenario vocabulary and the unit-tier vocabulary (`inputs`,
15//! `expects`, `intercepts`) never mix in one document. A document with
16//! `scenario:` that also declares a unit-tier section is rejected at
17//! load time.
18//!
19//! Durations (`sendDeadline`, `deadline`, `duration`,
20//! `elapsedAtLeast`) are humantime strings, for example `"5s"` or
21//! `"250ms"`, parsed during validation so errors can name the action
22//! index.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use camel_api::Value;
29use camel_core::RouteDefinition;
30use noyalib::compat::serde_yaml;
31use serde::de::Error as _;
32use serde::{Deserialize, Deserializer};
33
34// The partner-script grammar lives in its own module; the public
35// types are re-exported here so the document API stays one surface.
36pub use crate::partner_script::{PartnerFault, PartnerScript, PartnerScriptResponse};
37// The matcher algebra lives in the shared pure core (camel-matchers);
38// re-exported here so the document API stays one surface. The raw
39// serde stage below constructs these core types directly.
40pub use camel_matchers::RequestExpectation as PartnerExpectation;
41pub use camel_matchers::{CountBound, Expectation, PathFilter};
42
43// ---------------------------------------------------------------------------
44// Public model
45// ---------------------------------------------------------------------------
46
47/// A parsed scenario document. Route file paths stay as declared;
48/// resolving them against the document directory or the project root is
49/// the runner's job, the same split the unit-tier parser keeps.
50#[derive(Debug)]
51pub struct ScenarioDocument {
52 /// The document's own path as parsed. The boot root may be a
53 /// nearest-ancestor `Camel.toml` directory rather than the
54 /// document's directory, so the document directory travels with
55 /// the model: relative `routeFiles` anchor here (rc-jjzy5).
56 pub source_path: std::path::PathBuf,
57 /// The single declared route source.
58 pub route_source: RouteSource,
59 /// Ordered scenario actions.
60 pub scenario: Vec<ScenarioAction>,
61 /// Document-level partner scripting, keyed by endpoint address.
62 /// The grammar lives here; the runner consumes the map.
63 pub partners: Option<BTreeMap<String, Vec<PartnerScript>>>,
64 /// Fixed fixture values for the scenario; the layered environment
65 /// source reads these before any ambient value.
66 pub env: Option<BTreeMap<String, String>>,
67 /// Ambient variable names allowed to pass through to the scenario.
68 pub env_passthrough: Option<Vec<String>>,
69 /// Profile pinned per document; an ambient profile would break
70 /// hermeticity.
71 pub profile: Option<String>,
72 /// Document-level bound for every `send` action (rc-tr4w): an
73 /// optional tighter deadline than the runner's thirty-second
74 /// default, real time only (ADR-0069 §6).
75 pub send_deadline: Option<Duration>,
76 /// The document-level `inbound:` declaration (rc-5yon): the
77 /// harness binds `127.0.0.1:0`, stages the listener on the HTTP
78 /// component's global registry (ADR-0070), and exposes the bound
79 /// address under the named bind variable so route URIs interpolate
80 /// it. Provisioning runs behind the `http` feature; a declaration
81 /// in a build without the feature is a named load error (ADR-0069
82 /// §8 demand-gated activation).
83 pub inbound: Option<InboundListener>,
84}
85
86/// The route source of a scenario document. Exactly one form is
87/// declared; the parser rejects zero or multiple declarations.
88///
89/// Not `Clone`: the inline form carries `RouteDefinition`s, which are
90/// not `Clone`.
91#[non_exhaustive]
92pub enum RouteSource {
93 /// Route files to load, relative to the document's directory.
94 RouteFiles(Vec<PathBuf>),
95 /// Route files to load, resolved against the nearest ancestor
96 /// `Camel.toml` directory (the project root).
97 RouteFilesFromRoot(Vec<PathBuf>),
98 /// Inline route definitions, parsed at load time.
99 Inline(Vec<RouteDefinition>),
100}
101
102impl std::fmt::Debug for RouteSource {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 // `RouteDefinition` implements neither `Debug` nor `Clone`;
106 // the inline form reports its route count only.
107 Self::RouteFiles(files) => f.debug_tuple("RouteFiles").field(files).finish(),
108 Self::RouteFilesFromRoot(files) => {
109 f.debug_tuple("RouteFilesFromRoot").field(files).finish()
110 }
111 Self::Inline(routes) => f
112 .debug_tuple("Inline")
113 .field(&format_args!("{} route definitions", routes.len()))
114 .finish(),
115 }
116 }
117}
118
119/// One ordered scenario action (ADR-0069 section 11, adopted from
120/// Citrus: `send`, `receive` with a mandatory deadline, `sleep`,
121/// `validate`).
122#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum ScenarioAction {
125 /// Send a message to an endpoint.
126 Send {
127 /// Target endpoint reference.
128 to: EndpointRef,
129 /// Message body; omitted means an empty body.
130 body: Option<Value>,
131 /// Message headers.
132 headers: Option<BTreeMap<String, Value>>,
133 /// Resolved method: explicit or inferred (`POST` with a body,
134 /// `GET` without), uppercase.
135 method: String,
136 /// Reply assertion for `direct:` sends (rc-qvz6): the same
137 /// matcher grammar `validate` parses, evaluated against the
138 /// synchronous route reply the context-stimulus adapter
139 /// returns. Load-time rejected on every other scheme.
140 expect_reply: Option<Expectation>,
141 },
142 /// Receive a message from an endpoint before the deadline passes.
143 Receive {
144 /// Source endpoint reference.
145 from: EndpointRef,
146 /// Mandatory deadline, real monotonic time.
147 deadline: Duration,
148 /// Extractions into scenario variables, keyed by variable name.
149 extract: Option<BTreeMap<String, String>>,
150 },
151 /// Pause the scenario for the given duration.
152 Sleep {
153 /// Sleep length.
154 duration: Duration,
155 },
156 /// Assert an expectation against a scenario target.
157 Validate {
158 /// What to validate: the last message received on an endpoint,
159 /// a scenario variable, or a partner's recorded traffic.
160 target: ScenarioTarget,
161 /// Matcher expectation: the message grammar for `lastReceived`
162 /// and `variable` targets, the partner count grammar for
163 /// `partner` targets.
164 expectation: ValidateExpectation,
165 /// Optional poll deadline. Only valid on `partner` targets,
166 /// whose counts settle asynchronously; without it the partner
167 /// assertion reads one immediate snapshot.
168 deadline: Option<Duration>,
169 /// Optional minimum wire-arrival age. Only valid on
170 /// `lastReceived` targets: the last received message must have
171 /// arrived at least this long after the scenario started (the
172 /// not-before-X control `run.sh` expresses with `awk`). The
173 /// assertion anchors to the message's wire arrival, never the
174 /// consumption time.
175 elapsed_at_least: Option<Duration>,
176 },
177}
178
179impl ScenarioAction {
180 /// The `(bind variable, endpoint)` bindings this action's endpoint
181 /// references declare.
182 fn bindings(&self) -> Vec<(&str, &str)> {
183 fn endpoint_bindings(endpoint: &EndpointRef) -> Vec<(&str, &str)> {
184 endpoint.binding().into_iter().collect()
185 }
186 match self {
187 Self::Send { to, .. } => endpoint_bindings(to),
188 Self::Receive { from, .. } => endpoint_bindings(from),
189 Self::Validate { target, .. } => match target {
190 ScenarioTarget::LastReceived(endpoint) => endpoint_bindings(endpoint),
191 ScenarioTarget::Partner(_) => Vec::new(),
192 ScenarioTarget::Variable(_) => Vec::new(),
193 },
194 Self::Sleep { .. } => Vec::new(),
195 }
196 }
197}
198
199/// What a `validate` action asserts against.
200#[derive(Debug, Clone, PartialEq)]
201#[non_exhaustive]
202pub enum ScenarioTarget {
203 /// The last message received on the endpoint.
204 LastReceived(EndpointRef),
205 /// A scenario variable set by an earlier `extract`. Variable
206 /// existence is validated at run time.
207 Variable(String),
208 /// A partner endpoint: the assertion reads the partner's recorded
209 /// request traffic. The URI must equal a harness endpoint
210 /// reference declared by the scenario's own `send`/`receive`
211 /// actions.
212 Partner(EndpointRef),
213}
214
215/// An endpoint reference: a bare endpoint string or a map with
216/// `endpoint`, `provisioning`, and `bindVar` keys.
217#[derive(Debug, Clone, PartialEq)]
218pub struct EndpointRef {
219 /// Endpoint URI, for example `http://127.0.0.1:9999/hook`.
220 pub endpoint: String,
221 /// Who owns the partner lifecycle; only `harness` is implemented in
222 /// v1.
223 pub provisioning: Option<Provisioning>,
224 /// Scenario variable name the harness fills with this endpoint's
225 /// bound address when provisioning is `harness`.
226 pub bind_var: Option<String>,
227}
228
229impl EndpointRef {
230 /// The `(bind variable, endpoint)` binding this reference declares,
231 /// if any. The reserved env-key rule collects these pairs.
232 fn binding(&self) -> Option<(&str, &str)> {
233 self.bind_var
234 .as_deref()
235 .map(|bind_var| (bind_var, self.endpoint.as_str()))
236 }
237}
238
239/// Partner provisioning source (ADR-0069 section 9). The axis is who
240/// owns the lifecycle. `testcontainer` and `user-provided` are reserved
241/// grammar values; the parser rejects them.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243#[non_exhaustive]
244pub enum Provisioning {
245 /// The harness binds an in-process listener on `127.0.0.1:0`. The
246 /// only source implemented in v1.
247 Harness,
248}
249
250/// The document's `inbound:` declaration (rc-5yon): v1 grammar is a
251/// single map `inbound: {bindVar: NAME}`. The harness provisions one
252/// listener per document, binds `127.0.0.1:0`, stages it on the HTTP
253/// component's global registry (ADR-0070 staged consumption), and
254/// fills the bind variable with `http://<bound-address>` so route
255/// consumer URIs interpolate the staged socket.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct InboundListener {
258 /// Scenario variable name the harness fills with the staged
259 /// listener's `http://<bound-address>` URL.
260 pub bind_var: String,
261}
262
263/// The scripted responses a document's `partners:` entry maps to, for
264/// one endpoint key. `None` when the document declares no entry for
265/// the endpoint — the caller binds a permissive partner. `Some` maps
266/// each script grammar entry to its wire form: absent `status`
267/// defaults to 200, absent `times` to 1 (serve once), absent headers
268/// to the empty map, and the body follows the client send path's
269/// `value_to_wire` encoding (empty when absent); `delay` and `fault`
270/// map through.
271///
272/// The canonical `PartnerScript` → wire-form mapping; the CLI driver
273/// and library-level scenarios bind partners through this function so
274/// the semantics live in exactly one place.
275#[cfg(feature = "http")]
276pub fn partner_scripts_for(
277 doc: &ScenarioDocument,
278 endpoint: &str,
279) -> Option<Vec<crate::adapters::http::ScriptedResponse>> {
280 use crate::adapters::http::ScriptedResponse;
281 let scripts = doc.partners.as_ref()?.get(endpoint)?;
282 Some(
283 scripts
284 .iter()
285 .map(|script| {
286 let (status, headers, body) = match script.response.as_ref() {
287 Some(response) => (
288 response.status.unwrap_or(200),
289 response.headers.clone().unwrap_or_default(),
290 response.body.as_ref().map_or_else(Vec::new, |value| {
291 crate::adapters::http::value_to_wire(value)
292 }),
293 ),
294 // Fault entries carry no response; the placeholder
295 // keeps the wire form — serve checks the fault
296 // first, so the placeholder never reaches the wire.
297 None => (200, BTreeMap::new(), Vec::new()),
298 };
299 ScriptedResponse {
300 method: script.method.clone(),
301 path: script.path.clone(),
302 times: script.times.unwrap_or(1),
303 delay: script.delay,
304 fault: script.fault.clone(),
305 status,
306 headers,
307 body,
308 }
309 })
310 .collect(),
311 )
312}
313
314/// The expectation of a `validate` action, keyed by its target: the
315/// message matcher grammar for `lastReceived` and `variable` targets,
316/// the partner count grammar for `partner` targets.
317#[derive(Debug, Clone, PartialEq)]
318#[non_exhaustive]
319pub enum ValidateExpectation {
320 /// Message matcher expectation (`lastReceived` / `variable`).
321 Message(Expectation),
322 /// Partner request-count expectation (`partner`).
323 Partner(PartnerExpectation),
324}
325
326// ---------------------------------------------------------------------------
327// Raw serde stage
328// ---------------------------------------------------------------------------
329
330/// Raw document form. Unit-tier sections are captured, not rejected at
331/// the serde layer, so the mixing ban can name them. Scenario items
332/// stay raw values: the single-key action dispatch runs during
333/// validation so errors can name the action index.
334#[derive(Deserialize)]
335#[serde(deny_unknown_fields, rename_all = "camelCase")]
336struct RawDocument {
337 route_files: Option<Vec<String>>,
338 route_files_from_root: Option<Vec<String>>,
339 routes: Option<serde_yaml::Value>,
340 scenario: Option<Vec<serde_yaml::Value>>,
341 env: Option<BTreeMap<String, String>>,
342 env_passthrough: Option<Vec<String>>,
343 profile: Option<String>,
344 // Document-level partner scripting: the raw map stays
345 // endpoint-keyed with raw sequence values; conversion runs during
346 // validation so errors can name the entry key.
347 partners: Option<BTreeMap<String, serde_yaml::Value>>,
348 // Document-level send bound: raw humantime string; parsed during
349 // validation so the error names the field.
350 send_deadline: Option<String>,
351 // Document-level inbound listener declaration: raw node; the
352 // grammar walk runs during validation so unknown fields name
353 // themselves in every build, and the `http` feature gate fires
354 // after structure (ADR-0069 §8 demand-gated activation).
355 inbound: Option<serde_yaml::Value>,
356 // Unit-tier vocabulary, present only to detect and name the mixing
357 // ban violation.
358 inputs: Option<serde_yaml::Value>,
359 expects: Option<serde_yaml::Value>,
360 intercepts: Option<serde_yaml::Value>,
361}
362
363#[derive(Deserialize)]
364#[serde(deny_unknown_fields, rename_all = "camelCase")]
365struct RawSend {
366 to: RawEndpointRef,
367 body: Option<Value>,
368 headers: Option<BTreeMap<String, Value>>,
369 /// Raw `method` string; optional. Validation resolves it (explicit
370 /// or inferred from body presence) so errors can name the action
371 /// index.
372 method: Option<String>,
373 /// Raw `expectReply` node; optional, `direct:` sends only.
374 /// Validation converts it through the same matcher grammar
375 /// `validate` uses, and rejects it on every other scheme.
376 expect_reply: Option<Value>,
377}
378
379#[derive(Deserialize)]
380#[serde(deny_unknown_fields, rename_all = "camelCase")]
381struct RawReceive {
382 from: RawEndpointRef,
383 /// Raw humantime string; required by validation, not by serde, so
384 /// the error can name the action index.
385 deadline: Option<String>,
386 extract: Option<BTreeMap<String, String>>,
387}
388
389#[derive(Deserialize)]
390#[serde(deny_unknown_fields, rename_all = "camelCase")]
391struct RawSleep {
392 /// Raw humantime string.
393 duration: String,
394}
395
396#[derive(Deserialize)]
397#[serde(deny_unknown_fields, rename_all = "camelCase")]
398struct RawValidate {
399 /// Raw `target` node; the single-key form (`lastReceived` /
400 /// `variable` / `partner`) converts during validation.
401 target: serde_yaml::Value,
402 expectation: Value,
403 /// Raw humantime string; partner targets only, parsed during
404 /// validation so the error can name the action index.
405 deadline: Option<String>,
406 /// Raw humantime string; `lastReceived` targets only, parsed
407 /// during validation so the error can name the action index.
408 elapsed_at_least: Option<String>,
409}
410
411/// Raw endpoint reference: bare string or map with `endpoint`,
412/// `provisioning`, and `bindVar`.
413#[derive(Debug, Clone)]
414struct RawEndpointRef {
415 endpoint: String,
416 provisioning: Option<String>,
417 bind_var: Option<String>,
418}
419
420impl RawEndpointRef {
421 /// Deserializes from a bare string (shorthand) or a map.
422 fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
423 match value {
424 serde_yaml::Value::String(endpoint) => Ok(Self {
425 endpoint,
426 provisioning: None,
427 bind_var: None,
428 }),
429 serde_yaml::Value::Mapping(ref map) => {
430 // Field-by-field extraction: a hand-rolled map walk gives
431 // errors that name the offending key, which the
432 // deny_unknown_fields machinery of the compat shim
433 // cannot.
434 let mut endpoint: Option<String> = None;
435 let mut provisioning: Option<String> = None;
436 let mut bind_var: Option<String> = None;
437 for (key, value) in map {
438 match key.as_str() {
439 "endpoint" | "provisioning" | "bindVar" => {
440 let text = value.as_str().ok_or_else(|| {
441 format!(
442 "endpoint reference `{key}` must be a string, got {value:?}"
443 )
444 })?;
445 match key.as_str() {
446 "endpoint" => endpoint = Some(text.to_string()),
447 "provisioning" => provisioning = Some(text.to_string()),
448 _ => bind_var = Some(text.to_string()),
449 }
450 }
451 other => {
452 return Err(format!("unknown field `{other}` in endpoint reference"));
453 }
454 }
455 }
456 let endpoint = endpoint
457 .ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
458 Ok(Self {
459 endpoint,
460 provisioning,
461 bind_var,
462 })
463 }
464 other => Err(format!(
465 "endpoint reference must be a string or a map, got {other:?}"
466 )),
467 }
468 }
469}
470
471impl<'de> Deserialize<'de> for RawEndpointRef {
472 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
473 where
474 D: Deserializer<'de>,
475 {
476 let value = serde_yaml::Value::deserialize(deserializer)?;
477 RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
478 }
479}
480
481// ---------------------------------------------------------------------------
482// Errors
483// ---------------------------------------------------------------------------
484
485/// Parse and validation errors for scenario documents.
486///
487/// Exit-code mapping for the CLI adapter (ADR-0069 section 7):
488/// classification is by variant, never by message text. Every variant
489/// is a load-time failure and maps to exit 2.
490///
491/// - `doc-validation` class — Display carries the `doc-validation:`
492/// token: `NotTestDocument`, `MissingScenario`, `MixedVocabulary`,
493/// `Validation`, `ReservedEnvKey`, `InlineRoutes`,
494/// `InlineRoutesRejected`, `ProvisioningWithoutAuthority`,
495/// `ExpectReplyOnUnsupportedSend`.
496/// - `infra-unavailable` class — `UnsupportedProvisioning` (reserved
497/// provisioning grammar; Display names the class).
498/// - Unit-tier message parity — `RouteSourceMissing` and
499/// `RouteSourceConflict` render the unit-tier parser's messages
500/// verbatim, without the token, so both parsers report identical
501/// text; the CLI maps them to exit 2 as doc parse errors, the same
502/// as the unit tier does today.
503/// - Read and serde failures — `Io`, `Yaml`, `UnknownField` map to
504/// exit 2 as doc parse errors (unreadable file, broken grammar).
505#[derive(Debug, thiserror::Error)]
506#[non_exhaustive]
507pub enum DocError {
508 /// The document file could not be read.
509 #[error("failed to read test document {path}: {source}")]
510 Io {
511 /// Path of the unreadable document.
512 path: PathBuf,
513 /// Underlying read failure.
514 source: std::io::Error,
515 },
516 /// Malformed YAML or a type mismatch at the serde layer.
517 #[error("invalid test document: {0}")]
518 Yaml(String),
519 /// A `deny_unknown_fields` rejection.
520 #[error("unknown field in test document: {0}")]
521 UnknownField(String),
522 /// The path lacks the reserved `.test.yaml` / `.test.yml` suffix.
523 #[error(
524 "doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
525 )]
526 NotTestDocument {
527 /// The rejected path.
528 path: PathBuf,
529 },
530 /// The document declares no `scenario:` section.
531 #[error("doc-validation: scenario document must declare a `scenario:` section")]
532 MissingScenario,
533 /// The document mixes the scenario vocabulary with unit-tier
534 /// sections.
535 #[error(
536 "doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
537 )]
538 MixedVocabulary {
539 /// The unit-tier fields found, backticked and comma-joined.
540 found: String,
541 },
542 /// No route source is declared. Same message as the unit-tier
543 /// parser.
544 #[error(
545 "exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
546 )]
547 RouteSourceMissing,
548 /// More than one route source is declared. Same message as the
549 /// unit-tier parser.
550 #[error("route sources {present} are mutually exclusive; exactly one route source is required")]
551 RouteSourceConflict {
552 /// The declared keys, backticked and comma-joined.
553 present: String,
554 },
555 /// An action failed validation; `index` is the position in the
556 /// `scenario:` list. An empty `scenario:` list is rejected with
557 /// index 0 (the section, not an action, failed).
558 #[error("doc-validation: scenario[{index}]: {message}")]
559 Validation {
560 /// Zero-based position of the action in the `scenario:` list.
561 index: usize,
562 /// What failed.
563 message: String,
564 },
565 /// The endpoint declares a provisioning source that is reserved in
566 /// v1; only `harness` is supported.
567 #[error(
568 "doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
569 )]
570 UnsupportedProvisioning {
571 /// The rejected provisioning value.
572 value: String,
573 /// The endpoint that declared it.
574 endpoint: String,
575 },
576 /// A `provisioning: harness` endpoint reference declares a
577 /// `bindVar` while its scheme (`direct:` or `fake:`) binds no
578 /// partner, so the variable would never receive a bound authority
579 /// and the entry fails later as a verdict-class var-resolution
580 /// error (rc-j87j). Rejected at load instead.
581 #[error(
582 "doc-validation: endpoint `{endpoint}` declares `bindVar` but its `{ref_scheme}:` reference binds no harness partner, so the variable would never receive a bound authority (exit-2 doc-validation class)"
583 )]
584 ProvisioningWithoutAuthority {
585 /// The endpoint whose reference cannot fill the variable.
586 endpoint: String,
587 /// The scheme of the endpoint reference (`direct` or `fake`).
588 ref_scheme: String,
589 },
590 /// A document `env` key equals an endpoint's `bindVar`. The
591 /// reserved set is exactly the `bindVar` values declared by the
592 /// document's own endpoints; the harness binding wins.
593 #[error(
594 "doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
595 )]
596 ReservedEnvKey {
597 /// The reserved key.
598 key: String,
599 /// The endpoint that reserved it.
600 endpoint: String,
601 },
602 /// A `partners` entry failed validation; `endpoint` is the entry
603 /// key of the failing script list.
604 #[error("doc-validation: partners[{endpoint}]: {message}")]
605 Partners {
606 /// The endpoint key of the failing entry.
607 endpoint: String,
608 /// What failed.
609 message: String,
610 },
611 /// Inline `routes` failed to parse.
612 #[error("doc-validation: inline routes: {0}")]
613 InlineRoutes(String),
614 /// The document's route source is inline `routes`. Inline
615 /// definitions cannot boot in v1; the author must declare
616 /// `routeFiles`. Rejected at load, before partners bind, instead
617 /// of failing the boot afterward (rc-9dpx).
618 #[error(
619 "doc-validation: inline `routes` are rejected at load: declare `routeFiles` instead (inline definitions cannot boot in the scenario tier; exit 2)"
620 )]
621 InlineRoutesRejected,
622 /// A send declares `expectReply` on a scheme that produces no
623 /// synchronous reply: only the context-stimulus `direct:` send
624 /// returns one. Partner sends (`http`/`https`) park their
625 /// roundtrips for a later `receive`, and `fake:` adapters record
626 /// sends without answering, so the assertion could never run
627 /// (rc-qvz6). Rejected at load, naming the action index, the
628 /// scheme, and the literal `expectReply` field.
629 #[error(
630 "doc-validation: scenario[{index}]: `expectReply` is only valid on a `direct:` send, not `{scheme}` (exit 2)"
631 )]
632 ExpectReplyOnUnsupportedSend {
633 /// Zero-based position of the action in the `scenario:` list.
634 index: usize,
635 /// The scheme of the send's endpoint reference.
636 scheme: String,
637 },
638}
639
640/// Classifies a compat-layer (serde_yaml) error text, mirroring the
641/// unit-tier classifier.
642fn classify_yaml_error(raw: &str) -> DocError {
643 if raw.contains("unknown field") {
644 return DocError::UnknownField(raw.to_string());
645 }
646 DocError::Yaml(raw.to_string())
647}
648
649// ---------------------------------------------------------------------------
650// Parsing
651// ---------------------------------------------------------------------------
652
653/// Parses and validates a scenario document. Validation order:
654/// (a) the path carries a reserved test-document suffix; (b) the text
655/// deserializes; (c) a non-empty `scenario:` section exists; (d) no
656/// unit-tier section coexists with it; (e) exactly one route source
657/// is declared, and it is not inline (`routes` cannot boot in v1, so
658/// the defect fails at load instead of at boot);
659/// (f) each action converts (single-key dispatch, deadlines, durations,
660/// endpoint provisioning, expectation grammar, the `direct:`-only
661/// `expectReply` gate) with action-index errors; (g) each `partners`
662/// entry converts (script grammar, response status range) with
663/// entry-key errors; (h) no `env` key collides with a declared
664/// `bindVar`; (i) each `partner` validate target URI equals
665/// a harness endpoint reference declared by the scenario's own
666/// `send`/`receive` actions. The optional `inbound:` section converts
667/// between (g) and (h): grammar in every build, activation
668/// demand-gated behind `http` (ADR-0069 §8).
669pub fn parse_scenario_document(path: &Path) -> Result<ScenarioDocument, DocError> {
670 if !camel_dsl::discovery::is_test_document(path) {
671 return Err(DocError::NotTestDocument {
672 path: path.to_path_buf(),
673 });
674 }
675 let text = std::fs::read_to_string(path).map_err(|source| DocError::Io {
676 path: path.to_path_buf(),
677 source,
678 })?;
679 let raw = serde_yaml::from_str::<RawDocument>(&text)
680 .map_err(|e| classify_yaml_error(&e.to_string()))?;
681
682 // (c) This parser accepts scenario documents only, and the
683 // scenario list must be non-empty: an empty list would yield a
684 // trivially-green FULL document with zero actions (mirrors the
685 // unit tier's non-empty `expects` rule).
686 let Some(raw_scenario) = raw.scenario else {
687 return Err(DocError::MissingScenario);
688 };
689 if raw_scenario.is_empty() {
690 return Err(DocError::Validation {
691 index: 0,
692 message: "`scenario` must declare at least one action".to_string(),
693 });
694 }
695 // (d) Mixing ban (ADR-0069 section 2).
696 let mut unit_tier: Vec<&str> = Vec::new();
697 if raw.inputs.is_some() {
698 unit_tier.push("inputs");
699 }
700 if raw.expects.is_some() {
701 unit_tier.push("expects");
702 }
703 if raw.intercepts.is_some() {
704 unit_tier.push("intercepts");
705 }
706 if !unit_tier.is_empty() {
707 return Err(DocError::MixedVocabulary {
708 found: backticked(&unit_tier),
709 });
710 }
711 // (e) Exactly one route source, with the unit-tier messages.
712 let mut present: Vec<&'static str> = Vec::new();
713 if raw.route_files.is_some() {
714 present.push("routeFiles");
715 }
716 if raw.route_files_from_root.is_some() {
717 present.push("routeFilesFromRoot");
718 }
719 if raw.routes.is_some() {
720 present.push("routes");
721 }
722 let route_source = match present.as_slice() {
723 ["routeFiles"] => RouteSource::RouteFiles(
724 raw.route_files
725 .unwrap_or_default()
726 .into_iter()
727 .map(PathBuf::from)
728 .collect(),
729 ),
730 ["routeFilesFromRoot"] => RouteSource::RouteFilesFromRoot(
731 raw.route_files_from_root
732 .unwrap_or_default()
733 .into_iter()
734 .map(PathBuf::from)
735 .collect(),
736 ),
737 ["routes"] => {
738 let value = raw.routes.unwrap_or(serde_yaml::Value::Null);
739 RouteSource::Inline(parse_inline_routes(&value)?)
740 }
741 [] => return Err(DocError::RouteSourceMissing),
742 _ => {
743 return Err(DocError::RouteSourceConflict {
744 present: backticked(&present),
745 });
746 }
747 };
748 // (e, rc-9dpx) Inline route sources cannot boot in v1; reject at
749 // load, before partners bind, instead of failing the boot after
750 // the composition root is up. The boot keeps its own rejection as
751 // defense-in-depth.
752 if matches!(route_source, RouteSource::Inline(_)) {
753 return Err(DocError::InlineRoutesRejected);
754 }
755 // (f) Action conversion.
756 let mut scenario = Vec::with_capacity(raw_scenario.len());
757 for (index, item) in raw_scenario.into_iter().enumerate() {
758 scenario.push(build_action(item, index)?);
759 }
760 // (g) Partner scripting: entries convert from the raw sequence
761 // with the entry key named on every failure; an empty sequence is
762 // a valid, inert entry. The grammar conversion lives in the
763 // partner-script module.
764 let partners = crate::partner_script::partners_from_raw(raw.partners)?;
765 // (g2) Inbound listener declaration: the grammar walk runs in
766 // every build so grammar errors read identically with and without
767 // the `http` feature; the feature gate fires inside, after
768 // structure (ADR-0069 §8).
769 let inbound = raw.inbound.map(inbound_from_raw).transpose()?;
770 // (h) Reserved env keys: the harness binding wins over document
771 // fixtures — both the endpoints' bindVars and, since rc-5yon, the
772 // inbound listener's bindVar.
773 if let Some(env) = raw.env.as_ref() {
774 if let Some(inbound) = inbound.as_ref()
775 && env.contains_key(&inbound.bind_var)
776 {
777 return Err(DocError::ReservedEnvKey {
778 key: inbound.bind_var.clone(),
779 endpoint: "inbound".to_string(),
780 });
781 }
782 for action in &scenario {
783 for (bind_var, endpoint) in action.bindings() {
784 if env.contains_key(bind_var) {
785 return Err(DocError::ReservedEnvKey {
786 key: bind_var.to_string(),
787 endpoint: endpoint.to_string(),
788 });
789 }
790 }
791 }
792 }
793 // (i) Partner-target cross-check: a `partner` validate target URI
794 // must equal a harness endpoint reference declared by the
795 // scenario's own `send`/`receive` actions (URI string equality).
796 // A typo'd URI would otherwise assert against traffic nobody
797 // records.
798 let mut harness_uris: Vec<&str> = Vec::new();
799 let mut partner_targets: Vec<(usize, &EndpointRef)> = Vec::new();
800 for (index, action) in scenario.iter().enumerate() {
801 match action {
802 ScenarioAction::Send { to, .. } => {
803 if to.provisioning == Some(Provisioning::Harness) {
804 harness_uris.push(to.endpoint.as_str());
805 }
806 }
807 ScenarioAction::Receive { from, .. } => {
808 if from.provisioning == Some(Provisioning::Harness) {
809 harness_uris.push(from.endpoint.as_str());
810 }
811 }
812 ScenarioAction::Validate {
813 target: ScenarioTarget::Partner(endpoint),
814 ..
815 } => partner_targets.push((index, endpoint)),
816 _ => {}
817 }
818 }
819 for (index, endpoint) in partner_targets {
820 if !harness_uris.contains(&endpoint.endpoint.as_str()) {
821 return Err(DocError::Validation {
822 index,
823 message: format!(
824 "validate `partner` target `{}` does not match any harness endpoint reference declared by this scenario's `send`/`receive` actions",
825 endpoint.endpoint
826 ),
827 });
828 }
829 }
830 // Document-level send bound: optional; a present value goes
831 // through the same humantime grammar as the action deadlines,
832 // naming the field on failure (index 0 — the section, not an
833 // action, failed).
834 let send_deadline = raw
835 .send_deadline
836 .as_deref()
837 .map(|raw_deadline| parse_duration(raw_deadline, 0, "sendDeadline"))
838 .transpose()?;
839 Ok(ScenarioDocument {
840 source_path: path.to_path_buf(),
841 route_source,
842 scenario,
843 partners,
844 env: raw.env,
845 env_passthrough: raw.env_passthrough,
846 profile: raw.profile,
847 send_deadline,
848 inbound,
849 })
850}
851
852/// Converts the raw `inbound:` node. The v1 grammar is a single map
853/// `inbound: {bindVar: NAME}`; unknown fields are rejected naming the
854/// key, mirroring the partners-section strictness. Structure is
855/// validated in every build so grammar errors read identically with
856/// and without the `http` feature; only a structurally valid
857/// declaration reaches the demand gate (ADR-0069 §8), which rejects it
858/// naming the section and the feature when the harness is built
859/// without `http`. Section-level errors use index 0 — the section, not
860/// an action, failed (the `sendDeadline` precedent).
861fn inbound_from_raw(value: serde_yaml::Value) -> Result<InboundListener, DocError> {
862 let section_error = |message: String| DocError::Validation { index: 0, message };
863 let serde_yaml::Value::Mapping(ref map) = value else {
864 return Err(section_error(format!(
865 "`inbound` must be a map with a `bindVar` key, got {value:?}"
866 )));
867 };
868 let mut bind_var: Option<String> = None;
869 for (key, value) in map {
870 match key.as_str() {
871 "bindVar" => {
872 let text = value.as_str().ok_or_else(|| {
873 section_error(format!(
874 "`inbound`: `bindVar` must be a string, got {value:?}"
875 ))
876 })?;
877 bind_var = Some(text.to_string());
878 }
879 other => {
880 return Err(section_error(format!(
881 "`inbound`: unknown field `{other}`; expected `bindVar`"
882 )));
883 }
884 }
885 }
886 let bind_var =
887 bind_var.ok_or_else(|| section_error("`inbound` requires a `bindVar` key".to_string()))?;
888 // Demand-gated activation (ADR-0069 §8): the grammar parsed; the
889 // activation needs the `http` feature, which provisions the
890 // listener.
891 #[cfg(not(feature = "http"))]
892 {
893 let _ = bind_var;
894 Err(section_error(
895 "`inbound` requires the `http` feature, which this harness build does \
896 not enable: rebuild with `--features http` (demand-gated activation)"
897 .to_string(),
898 ))
899 }
900 #[cfg(feature = "http")]
901 Ok(InboundListener { bind_var })
902}
903
904/// Parses inline `routes` through the shared DSL parser. `parse_yaml`
905/// expects a top-level `routes:` key; the inline value (the array under
906/// `routes:`) is wrapped back into that shape, the same as the unit-tier
907/// runner.
908fn parse_inline_routes(value: &serde_yaml::Value) -> Result<Vec<RouteDefinition>, DocError> {
909 let mut mapping = serde_yaml::Mapping::new();
910 mapping.insert("routes", value.clone());
911 let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
912 .map_err(|e| DocError::InlineRoutes(format!("failed to serialize inline routes: {e}")))?;
913 camel_dsl::parse_yaml(&text).map_err(|e| DocError::InlineRoutes(e.to_string()))
914}
915
916/// Converts one raw action item into the public model. An item is a
917/// single-key map (`send`, `receive`, `sleep`, `validate`); dispatch
918/// runs here, not in serde, so every failure carries the action index.
919fn build_action(item: serde_yaml::Value, index: usize) -> Result<ScenarioAction, DocError> {
920 let action_error = |message: String| DocError::Validation { index, message };
921 let serde_yaml::Value::Mapping(ref map) = item else {
922 return Err(action_error(format!(
923 "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got {item:?}"
924 )));
925 };
926 let Some((key, content)) = map.iter().next() else {
927 return Err(action_error(
928 "action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got an empty map"
929 .to_string(),
930 ));
931 };
932 if map.len() != 1 {
933 return Err(action_error(format!(
934 "action must declare exactly one key, got {}",
935 backticked(&map.keys().map(String::as_str).collect::<Vec<_>>())
936 )));
937 }
938 let action_error_from_serde = |e: serde_yaml::Error| action_error(e.to_string());
939 match key.as_str() {
940 "send" => {
941 let raw: RawSend =
942 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
943 let method = match raw.method {
944 Some(method) => {
945 let upper = method.trim().to_ascii_uppercase();
946 if !is_http_token(&upper) {
947 return Err(action_error(format!(
948 "send action `method` must be a valid HTTP method name, got `{method}`"
949 )));
950 }
951 upper
952 }
953 None => {
954 if raw.body.is_some() {
955 "POST".to_string()
956 } else {
957 "GET".to_string()
958 }
959 }
960 };
961 // (rc-qvz6) `expectReply` reads the synchronous reply only
962 // the context-stimulus `direct:` send produces; partner
963 // sends park their roundtrips for a later `receive` and
964 // fake adapters record sends without answering, so the
965 // assertion is rejected at load on every other scheme.
966 let scheme = ref_scheme(&raw.to.endpoint);
967 if raw.expect_reply.is_some() && scheme != Some("direct") {
968 return Err(DocError::ExpectReplyOnUnsupportedSend {
969 index,
970 // A scheme-less reference names no scheme to
971 // render; the explicit phrase keeps the
972 // diagnostic from degrading to an empty name.
973 scheme: scheme.unwrap_or("no scheme").to_string(),
974 });
975 }
976 let expect_reply = raw
977 .expect_reply
978 .map(|value| expectation_from_value(&value, index, "expectReply"))
979 .transpose()?;
980 Ok(ScenarioAction::Send {
981 to: endpoint_from_raw(raw.to)?,
982 body: raw.body,
983 headers: raw.headers,
984 method,
985 expect_reply,
986 })
987 }
988 "receive" => {
989 let raw: RawReceive =
990 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
991 let deadline = raw.deadline.ok_or_else(|| {
992 action_error(
993 "receive action requires a `deadline` (humantime string, e.g. `5s`)"
994 .to_string(),
995 )
996 })?;
997 Ok(ScenarioAction::Receive {
998 from: endpoint_from_raw(raw.from)?,
999 deadline: parse_duration(&deadline, index, "deadline")?,
1000 extract: raw.extract,
1001 })
1002 }
1003 "sleep" => {
1004 let raw: RawSleep =
1005 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
1006 Ok(ScenarioAction::Sleep {
1007 duration: parse_duration(&raw.duration, index, "sleep duration")?,
1008 })
1009 }
1010 "validate" => {
1011 let raw: RawValidate =
1012 serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
1013 let target = build_target(&raw.target, index)?;
1014 let deadline = match raw.deadline.as_deref() {
1015 None => None,
1016 // The poll deadline exists because a partner count
1017 // settles asynchronously; on any other target it has
1018 // no meaning and is a grammar error.
1019 Some(raw_deadline) if matches!(target, ScenarioTarget::Partner(_)) => {
1020 Some(parse_duration(raw_deadline, index, "deadline")?)
1021 }
1022 Some(raw_deadline) => {
1023 return Err(action_error(format!(
1024 "`deadline` is only valid on a `partner` validate target, got `{raw_deadline}`"
1025 )));
1026 }
1027 };
1028 let elapsed_at_least = match raw.elapsed_at_least.as_deref() {
1029 None => None,
1030 // The elapsed bound measures the wire arrival of the
1031 // last received message against the scenario start;
1032 // only that target carries an arrival to measure.
1033 Some(raw_bound) if matches!(target, ScenarioTarget::LastReceived(_)) => {
1034 Some(parse_duration(raw_bound, index, "elapsedAtLeast")?)
1035 }
1036 Some(raw_bound) => {
1037 return Err(action_error(format!(
1038 "`elapsedAtLeast` is only valid on a `lastReceived` validate target, got `{raw_bound}`"
1039 )));
1040 }
1041 };
1042 let expectation = match &target {
1043 ScenarioTarget::Partner(_) => ValidateExpectation::Partner(
1044 partner_expectation_from_value(&raw.expectation, index)?,
1045 ),
1046 _ => ValidateExpectation::Message(expectation_from_value(
1047 &raw.expectation,
1048 index,
1049 "expectation",
1050 )?),
1051 };
1052 Ok(ScenarioAction::Validate {
1053 target,
1054 expectation,
1055 deadline,
1056 elapsed_at_least,
1057 })
1058 }
1059 other => Err(action_error(format!(
1060 "unknown action `{other}`; expected `send`, `receive`, `sleep`, or `validate`"
1061 ))),
1062 }
1063}
1064
1065/// Builds a `validate` target from the raw `target` node: a single-key
1066/// map (`lastReceived`, `variable`, or `partner`).
1067fn build_target(value: &serde_yaml::Value, index: usize) -> Result<ScenarioTarget, DocError> {
1068 let action_error = |message: String| DocError::Validation { index, message };
1069 let serde_yaml::Value::Mapping(map) = value else {
1070 return Err(action_error(format!(
1071 "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got {value:?}"
1072 )));
1073 };
1074 let Some((key, content)) = map.iter().next() else {
1075 return Err(action_error(
1076 "validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got an empty map"
1077 .to_string(),
1078 ));
1079 };
1080 match key.as_str() {
1081 "lastReceived" => {
1082 let raw: RawEndpointRef =
1083 serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1084 Ok(ScenarioTarget::LastReceived(endpoint_from_raw(raw)?))
1085 }
1086 "variable" => match content.as_str() {
1087 Some(name) => Ok(ScenarioTarget::Variable(name.to_string())),
1088 None => Err(action_error(format!(
1089 "validate `variable` target must be a string, got {content:?}"
1090 ))),
1091 },
1092 "partner" => {
1093 let raw: RawEndpointRef =
1094 serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
1095 Ok(ScenarioTarget::Partner(endpoint_from_raw(raw)?))
1096 }
1097 other => Err(action_error(format!(
1098 "unknown validate target `{other}`; expected `lastReceived`, `variable`, or `partner`"
1099 ))),
1100 }
1101}
1102
1103/// Applies the provisioning gate: only `harness` (or absent) passes,
1104/// and a harness entry whose reference scheme binds no partner
1105/// (`direct:`, `fake:`) must not declare a `bindVar` — the variable
1106/// would never receive a bound authority (rc-j87j). A
1107/// `direct:`/`fake:` entry without a `bindVar` stays legal.
1108fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
1109 let provisioning = match raw.provisioning.as_deref() {
1110 None => None,
1111 Some("harness") => Some(Provisioning::Harness),
1112 Some(value) => {
1113 return Err(DocError::UnsupportedProvisioning {
1114 value: value.to_string(),
1115 endpoint: raw.endpoint.clone(),
1116 });
1117 }
1118 };
1119 if provisioning == Some(Provisioning::Harness)
1120 && raw.bind_var.is_some()
1121 && let Some(scheme) = ref_scheme(&raw.endpoint)
1122 && (scheme == "direct" || scheme == "fake")
1123 {
1124 return Err(DocError::ProvisioningWithoutAuthority {
1125 endpoint: raw.endpoint.clone(),
1126 ref_scheme: scheme.to_string(),
1127 });
1128 }
1129 Ok(EndpointRef {
1130 endpoint: raw.endpoint,
1131 provisioning,
1132 bind_var: raw.bind_var,
1133 })
1134}
1135
1136/// The scheme prefix of an endpoint URI: the non-empty text before
1137/// the first `:`, or `None` when the URI carries no scheme — which
1138/// requires the separator; a colon-less string (`orders`) is a bare
1139/// name, not a scheme.
1140fn ref_scheme(endpoint: &str) -> Option<&str> {
1141 let (scheme, _) = endpoint.split_once(':')?;
1142 (!scheme.is_empty()).then_some(scheme)
1143}
1144
1145/// Parses a humantime duration string, naming the action index on
1146/// failure.
1147fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
1148 humantime::parse_duration(raw).map_err(|e| DocError::Validation {
1149 index,
1150 message: format!("invalid {field} `{raw}`: {e}"),
1151 })
1152}
1153
1154/// Whether `s` is a valid HTTP token: non-empty and composed only of
1155/// ASCII alphanumerics or one of ``!#$%&'*+-.^_`|~``. Crate-visible
1156/// for the parse-test module.
1157pub(crate) fn is_http_token(s: &str) -> bool {
1158 !s.is_empty()
1159 && s.chars().all(|c| {
1160 c.is_ascii_alphanumeric()
1161 || matches!(
1162 c,
1163 '!' | '#'
1164 | '$'
1165 | '%'
1166 | '&'
1167 | '\''
1168 | '*'
1169 | '+'
1170 | '-'
1171 | '.'
1172 | '^'
1173 | '_'
1174 | '`'
1175 | '|'
1176 | '~'
1177 )
1178 })
1179}
1180
1181/// Recognized expectation matcher keys.
1182fn is_matcher_key(key: &str) -> bool {
1183 matches!(
1184 key,
1185 "equals" | "regex" | "contains" | "startsWith" | "endsWith" | "exists" | "jsonSubset"
1186 )
1187}
1188
1189/// Applies the expectation dual grammar: a bare value is a literal
1190/// `equals`; an object whose single key is a recognized matcher key is
1191/// that matcher; any other object is a literal `equals`. Payload shapes
1192/// mirror the mock-testkit matcher rules. The field name parameter
1193/// (`expectation`, `expectReply`) keeps one verb parser behind both
1194/// readers (rc-qvz6): the verbs never fork between `validate` and
1195/// send-level reply assertions.
1196fn expectation_from_value(
1197 value: &Value,
1198 index: usize,
1199 field: &'static str,
1200) -> Result<Expectation, DocError> {
1201 let invalid = |message: String| DocError::Validation { index, message };
1202 if let Value::Object(map) = value
1203 && map.len() == 1
1204 && let Some((key, payload)) = map.iter().next()
1205 && is_matcher_key(key)
1206 {
1207 return match key.as_str() {
1208 "equals" => Ok(Expectation::Equals(payload.clone())),
1209 "regex" | "contains" | "startsWith" | "endsWith" => {
1210 let Some(pattern) = payload.as_str() else {
1211 return Err(invalid(format!(
1212 "{field}: `{key}` requires a string payload"
1213 )));
1214 };
1215 if key.as_str() == "regex"
1216 && let Err(e) = regex::Regex::new(pattern)
1217 {
1218 return Err(invalid(format!("{field}: invalid regex `{pattern}`: {e}")));
1219 }
1220 Ok(match key.as_str() {
1221 "regex" => Expectation::Regex(pattern.to_string()),
1222 "contains" => Expectation::Contains(pattern.to_string()),
1223 "startsWith" => Expectation::StartsWith(pattern.to_string()),
1224 _ => Expectation::EndsWith(pattern.to_string()),
1225 })
1226 }
1227 "exists" => {
1228 if payload.is_null() {
1229 Ok(Expectation::Exists)
1230 } else {
1231 Err(invalid(format!("{field}: `exists` takes no argument")))
1232 }
1233 }
1234 _ => {
1235 if payload.is_object() {
1236 Ok(Expectation::JsonSubset(payload.clone()))
1237 } else {
1238 Err(invalid(format!("{field}: `jsonSubset` must be an object")))
1239 }
1240 }
1241 };
1242 }
1243 Ok(Expectation::Equals(value.clone()))
1244}
1245
1246/// Applies the partner expectation grammar: a map with exactly one
1247/// count bound (`count`; or `atLeast`, `atMost`, or their range), an
1248/// optional `method` string, at most one path filter (`path`,
1249/// `pathContains`, `pathMatches` — the regex compiled at load), and
1250/// an optional `query` subset map of string keys to string values;
1251/// unknown keys fail. Field-by-field extraction, like the
1252/// endpoint-reference reader, so errors name the offending key.
1253fn partner_expectation_from_value(
1254 value: &Value,
1255 index: usize,
1256) -> Result<PartnerExpectation, DocError> {
1257 const FIELD: &str = "partner expectation";
1258 const KEYS: &[&str] = &[
1259 "count",
1260 "atLeast",
1261 "atMost",
1262 "method",
1263 "path",
1264 "pathContains",
1265 "pathMatches",
1266 "query",
1267 ];
1268 let invalid = |message: String| DocError::Validation { index, message };
1269 let Value::Object(map) = value else {
1270 return Err(invalid(format!(
1271 "{FIELD} must be a map with a count bound, got {value:?}"
1272 )));
1273 };
1274 let mut count: Option<u64> = None;
1275 let mut at_least: Option<u64> = None;
1276 let mut at_most: Option<u64> = None;
1277 let mut method: Option<String> = None;
1278 let mut path: Option<PathFilter> = None;
1279 let mut path_key: Option<&str> = None;
1280 let mut query: Option<BTreeMap<String, String>> = None;
1281 for (key, payload) in map {
1282 match key.as_str() {
1283 "count" | "atLeast" | "atMost" => {
1284 let bound = payload.as_u64().ok_or_else(|| {
1285 invalid(format!(
1286 "{FIELD}: `{key}` must be a non-negative integer, got {payload}"
1287 ))
1288 })?;
1289 match key.as_str() {
1290 "count" => count = Some(bound),
1291 "atLeast" => at_least = Some(bound),
1292 _ => at_most = Some(bound),
1293 }
1294 }
1295 "method" => {
1296 let text = payload.as_str().ok_or_else(|| {
1297 invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1298 })?;
1299 method = Some(text.to_string());
1300 }
1301 "path" | "pathContains" | "pathMatches" => {
1302 if let Some(first) = path_key {
1303 return Err(invalid(format!(
1304 "{FIELD}: `{first}` and `{key}` are exclusive: at most one path filter"
1305 )));
1306 }
1307 let text = payload.as_str().ok_or_else(|| {
1308 invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
1309 })?;
1310 path = Some(match key.as_str() {
1311 "path" => PathFilter::Exact(text.to_string()),
1312 "pathContains" => PathFilter::Contains(text.to_string()),
1313 _ => {
1314 if let Err(e) = regex::Regex::new(text) {
1315 return Err(invalid(format!("{FIELD}: invalid regex `{text}`: {e}")));
1316 }
1317 PathFilter::Matches(text.to_string())
1318 }
1319 });
1320 path_key = Some(key.as_str());
1321 }
1322 "query" => {
1323 let Value::Object(pairs) = payload else {
1324 return Err(invalid(format!(
1325 "{FIELD}: `query` must be a map of string keys to string values, got {payload}"
1326 )));
1327 };
1328 let mut subset = BTreeMap::new();
1329 for (name, pair) in pairs {
1330 let Some(text) = pair.as_str() else {
1331 return Err(invalid(format!(
1332 "{FIELD}: `query` value for `{name}` must be a string, got {pair}"
1333 )));
1334 };
1335 subset.insert(name.clone(), text.to_string());
1336 }
1337 query = Some(subset);
1338 }
1339 other => {
1340 return Err(invalid(format!(
1341 "{FIELD}: unknown field `{other}`; expected {}",
1342 backticked(KEYS)
1343 )));
1344 }
1345 }
1346 }
1347 if count.is_some() && (at_least.is_some() || at_most.is_some()) {
1348 let mut others: Vec<&str> = Vec::new();
1349 if at_least.is_some() {
1350 others.push("atLeast");
1351 }
1352 if at_most.is_some() {
1353 others.push("atMost");
1354 }
1355 return Err(invalid(format!(
1356 "{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
1357 backticked(&others)
1358 )));
1359 }
1360 let bound = if let Some(exact) = count {
1361 CountBound::Exact(exact)
1362 } else if let (Some(min), Some(max)) = (at_least, at_most) {
1363 if min > max {
1364 return Err(invalid(format!(
1365 "{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
1366 )));
1367 }
1368 CountBound::Range(min, max)
1369 } else if let Some(n) = at_least {
1370 CountBound::AtLeast(n)
1371 } else if let Some(n) = at_most {
1372 CountBound::AtMost(n)
1373 } else {
1374 return Err(invalid(format!(
1375 "{FIELD}: requires a count bound: `count`, `atLeast`, or `atMost`"
1376 )));
1377 };
1378 Ok(PartnerExpectation {
1379 bound,
1380 method,
1381 path,
1382 query,
1383 })
1384}
1385
1386/// Backticks and comma-joins field names for error messages.
1387fn backticked(fields: &[&str]) -> String {
1388 fields
1389 .iter()
1390 .map(|field| format!("`{field}`"))
1391 .collect::<Vec<_>>()
1392 .join(", ")
1393}