camel_integration_test/document/error.rs
1//! Load-time errors for scenario documents, and the endpoint-reference
2//! conversion that fails with them (rc-0ahfl, split out of the parent
3//! module).
4//!
5//! Exit-code mapping for the CLI adapter (ADR-0069 section 7):
6//! classification is by variant, never by message text. Every variant
7//! is a load-time failure and maps to exit 2. The `DocError` type is
8//! re-exported at `crate::document::DocError` and at the crate root,
9//! so consumers keep the paths they had before the split.
10
11use std::path::PathBuf;
12use std::time::Duration;
13
14use noyalib::compat::serde_yaml;
15use serde::de::Error as _;
16use serde::{Deserialize, Deserializer};
17
18use super::{EndpointRef, Provisioning, ref_scheme};
19
20/// Raw endpoint reference: bare string or map with `endpoint`,
21/// `provisioning`, and `bindVar`.
22#[derive(Debug, Clone)]
23pub(super) struct RawEndpointRef {
24 /// Endpoint URI as written.
25 pub(super) endpoint: String,
26 /// Raw provisioning source name (`harness`, or a reserved value).
27 pub(super) provisioning: Option<String>,
28 /// Raw bind-variable name.
29 pub(super) bind_var: Option<String>,
30}
31
32impl RawEndpointRef {
33 /// Deserializes from a bare string (shorthand) or a map.
34 fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
35 match value {
36 serde_yaml::Value::String(endpoint) => Ok(Self {
37 endpoint,
38 provisioning: None,
39 bind_var: None,
40 }),
41 serde_yaml::Value::Mapping(ref map) => {
42 // Field-by-field extraction: a hand-rolled map walk gives
43 // errors that name the offending key, which the
44 // deny_unknown_fields machinery of the compat shim
45 // cannot.
46 let mut endpoint: Option<String> = None;
47 let mut provisioning: Option<String> = None;
48 let mut bind_var: Option<String> = None;
49 for (key, value) in map {
50 match key.as_str() {
51 "endpoint" | "provisioning" | "bindVar" => {
52 let text = value.as_str().ok_or_else(|| {
53 format!(
54 "endpoint reference `{key}` must be a string, got {value:?}"
55 )
56 })?;
57 match key.as_str() {
58 "endpoint" => endpoint = Some(text.to_string()),
59 "provisioning" => provisioning = Some(text.to_string()),
60 _ => bind_var = Some(text.to_string()),
61 }
62 }
63 other => {
64 return Err(format!("unknown field `{other}` in endpoint reference"));
65 }
66 }
67 }
68 let endpoint = endpoint
69 .ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
70 Ok(Self {
71 endpoint,
72 provisioning,
73 bind_var,
74 })
75 }
76 other => Err(format!(
77 "endpoint reference must be a string or a map, got {other:?}"
78 )),
79 }
80 }
81}
82
83impl<'de> Deserialize<'de> for RawEndpointRef {
84 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85 where
86 D: Deserializer<'de>,
87 {
88 let value = serde_yaml::Value::deserialize(deserializer)?;
89 RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
90 }
91}
92
93// ---------------------------------------------------------------------------
94// Errors
95// ---------------------------------------------------------------------------
96
97/// Parse and validation errors for scenario documents.
98///
99/// Exit-code mapping for the CLI adapter (ADR-0069 section 7):
100/// classification is by variant, never by message text. Every variant
101/// is a load-time failure and maps to exit 2.
102///
103/// - `doc-validation` class — Display carries the `doc-validation:`
104/// token: `NotTestDocument`, `MissingScenario`, `MixedVocabulary`,
105/// `Validation`, `ReservedEnvKey`, `InlineRoutes`,
106/// `InlineRoutesRejected`, `ProvisioningWithoutAuthority`,
107/// `ExpectReplyOnUnsupportedSend`, `LogsBlock`.
108/// - `infra-unavailable` class — `UnsupportedProvisioning` (reserved
109/// provisioning grammar; Display names the class).
110/// - Unit-tier message parity — `RouteSourceMissing` and
111/// `RouteSourceConflict` render the unit-tier parser's messages
112/// verbatim, without the token, so both parsers report identical
113/// text; the CLI maps them to exit 2 as doc parse errors, the same
114/// as the unit tier does today.
115/// - Read and serde failures — `Io`, `Yaml`, `UnknownField` map to
116/// exit 2 as doc parse errors (unreadable file, broken grammar).
117#[derive(Debug, thiserror::Error)]
118#[non_exhaustive]
119pub enum DocError {
120 /// The document file could not be read.
121 #[error("failed to read test document {path}: {source}")]
122 Io {
123 /// Path of the unreadable document.
124 path: PathBuf,
125 /// Underlying read failure.
126 source: std::io::Error,
127 },
128 /// Malformed YAML or a type mismatch at the serde layer.
129 #[error("invalid test document: {0}")]
130 Yaml(String),
131 /// A `deny_unknown_fields` rejection.
132 #[error("unknown field in test document: {0}")]
133 UnknownField(String),
134 /// The path lacks the reserved `.test.yaml` / `.test.yml` suffix.
135 #[error(
136 "doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
137 )]
138 NotTestDocument {
139 /// The rejected path.
140 path: PathBuf,
141 },
142 /// The document declares no `scenario:` section.
143 #[error("doc-validation: scenario document must declare a `scenario:` section")]
144 MissingScenario,
145 /// The document mixes the scenario vocabulary with unit-tier
146 /// sections.
147 #[error(
148 "doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
149 )]
150 MixedVocabulary {
151 /// The unit-tier fields found, backticked and comma-joined.
152 found: String,
153 },
154 /// No route source is declared. Same message as the unit-tier
155 /// parser.
156 #[error(
157 "exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
158 )]
159 RouteSourceMissing,
160 /// More than one route source is declared. Same message as the
161 /// unit-tier parser.
162 #[error("route sources {present} are mutually exclusive; exactly one route source is required")]
163 RouteSourceConflict {
164 /// The declared keys, backticked and comma-joined.
165 present: String,
166 },
167 /// An action failed validation; `index` is the position in the
168 /// `scenario:` list. An empty `scenario:` list is rejected with
169 /// index 0 (the section, not an action, failed).
170 #[error("doc-validation: scenario[{index}]: {message}")]
171 Validation {
172 /// Zero-based position of the action in the `scenario:` list.
173 index: usize,
174 /// What failed.
175 message: String,
176 },
177 /// The endpoint declares a provisioning source that is reserved in
178 /// v1; only `harness` is supported.
179 #[error(
180 "doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
181 )]
182 UnsupportedProvisioning {
183 /// The rejected provisioning value.
184 value: String,
185 /// The endpoint that declared it.
186 endpoint: String,
187 },
188 /// A `provisioning: harness` endpoint reference declares a
189 /// `bindVar` while its scheme (`direct:` or `fake:`) binds no
190 /// partner, so the variable would never receive a bound authority
191 /// and the entry fails later as a verdict-class var-resolution
192 /// error (rc-j87j). Rejected at load instead.
193 #[error(
194 "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)"
195 )]
196 ProvisioningWithoutAuthority {
197 /// The endpoint whose reference cannot fill the variable.
198 endpoint: String,
199 /// The scheme of the endpoint reference (`direct` or `fake`).
200 ref_scheme: String,
201 },
202 /// A document `env` key equals an endpoint's `bindVar`. The
203 /// reserved set is exactly the `bindVar` values declared by the
204 /// document's own endpoints; the harness binding wins.
205 #[error(
206 "doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
207 )]
208 ReservedEnvKey {
209 /// The reserved key.
210 key: String,
211 /// The endpoint that reserved it.
212 endpoint: String,
213 },
214 /// A `partners` entry failed validation; `endpoint` is the entry
215 /// key of the failing script list.
216 #[error("doc-validation: partners[{endpoint}]: {message}")]
217 Partners {
218 /// The endpoint key of the failing entry.
219 endpoint: String,
220 /// What failed.
221 message: String,
222 },
223 /// Inline `routes` failed to parse.
224 #[error("doc-validation: inline routes: {0}")]
225 InlineRoutes(String),
226 /// The document's route source is inline `routes`. Inline
227 /// definitions cannot boot in v1; the author must declare
228 /// `routeFiles`. Rejected at load, before partners bind, instead
229 /// of failing the boot afterward (rc-9dpx).
230 #[error(
231 "doc-validation: inline `routes` are rejected at load: declare `routeFiles` instead (inline definitions cannot boot in the scenario tier; exit 2)"
232 )]
233 InlineRoutesRejected,
234 /// A send declares `expectReply` on a scheme that produces no
235 /// synchronous reply: only the context-stimulus `direct:` send
236 /// returns one. Partner sends (`http`/`https`) park their
237 /// roundtrips for a later `receive`, and `fake:` adapters record
238 /// sends without answering, so the assertion could never run
239 /// (rc-qvz6). Rejected at load, naming the action index, the
240 /// scheme, and the literal `expectReply` field.
241 #[error(
242 "doc-validation: scenario[{index}]: `expectReply` is only valid on a `direct:` send, not `{scheme}` (exit 2)"
243 )]
244 ExpectReplyOnUnsupportedSend {
245 /// Zero-based position of the action in the `scenario:` list.
246 index: usize,
247 /// The scheme of the send's endpoint reference.
248 scheme: String,
249 },
250 /// A malformed document-level `logs:` block (rc-tdgh5): an unknown
251 /// key, a level outside the accepted set, or a regex that does not
252 /// compile. The detail names the offending clause.
253 #[error("doc-validation: malformed `logs` block: {detail}")]
254 LogsBlock {
255 /// The offending clause and why it failed.
256 detail: String,
257 },
258}
259
260/// Classifies a compat-layer (serde_yaml) error text, mirroring the
261/// unit-tier classifier.
262pub(super) fn classify_yaml_error(raw: &str) -> DocError {
263 if raw.contains("unknown field") {
264 return DocError::UnknownField(raw.to_string());
265 }
266 DocError::Yaml(raw.to_string())
267}
268
269/// Applies the provisioning gate: only `harness` (or absent) passes,
270/// and a harness entry whose reference scheme binds no partner
271/// (`direct:`, `fake:`) must not declare a `bindVar` — the variable
272/// would never receive a bound authority (rc-j87j). A
273/// `direct:`/`fake:` entry without a `bindVar` stays legal.
274pub(super) fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
275 let provisioning = match raw.provisioning.as_deref() {
276 None => None,
277 Some("harness") => Some(Provisioning::Harness),
278 Some(value) => {
279 return Err(DocError::UnsupportedProvisioning {
280 value: value.to_string(),
281 endpoint: raw.endpoint.clone(),
282 });
283 }
284 };
285 if provisioning == Some(Provisioning::Harness)
286 && raw.bind_var.is_some()
287 && let Some(scheme) = ref_scheme(&raw.endpoint)
288 && (scheme == "direct" || scheme == "fake")
289 {
290 return Err(DocError::ProvisioningWithoutAuthority {
291 endpoint: raw.endpoint.clone(),
292 ref_scheme: scheme.to_string(),
293 });
294 }
295 Ok(EndpointRef {
296 endpoint: raw.endpoint,
297 provisioning,
298 bind_var: raw.bind_var,
299 })
300}
301
302/// Parses a humantime duration string, naming the action index on
303/// failure.
304pub(super) fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
305 humantime::parse_duration(raw).map_err(|e| DocError::Validation {
306 index,
307 message: format!("invalid {field} `{raw}`: {e}"),
308 })
309}