Skip to main content

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}