Skip to main content

proef_core/pack/
mod.rs

1//! Macro packs: the YAML binding skeleton with embedded raw payload blocks
2//! (ADR-0004, TECH-SPEC §6).
3//!
4//! Packs are parsed with `serde_norway` (`deny_unknown_fields` on the fixed
5//! schema; the *payload* key of a step — `hurl:`, or a future engine's kind — is
6//! dynamic and checked against the registered engines' [`StepKindSpec`]s in
7//! validation pass 8). Loading is pure: the CLI discovers files and hands
8//! [`PackSource`]s in; built-in packs are embedded at build time.
9
10mod locate;
11mod schema;
12mod validate;
13
14pub use schema::json_schema;
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18
19use serde::Deserialize;
20
21use crate::diag::{Diag, FrontError, Span};
22use crate::engine::StepKindSpec;
23use crate::step::Retry;
24
25/// One pack input: a name (path as authored, or `builtin:…`) plus its text.
26#[derive(Debug, Clone)]
27pub struct PackSource {
28    /// Display name (file path as authored, or `builtin:<name>`).
29    pub name: String,
30    /// The raw YAML text.
31    pub text: Arc<str>,
32}
33
34/// The built-in packs embedded into every proef binary.
35pub fn builtin_sources() -> Vec<PackSource> {
36    vec![PackSource {
37        name: "builtin:core.yaml".to_owned(),
38        text: Arc::from(include_str!("../../helpers/core.yaml")),
39    }]
40}
41
42// ---------------------------------------------------------------------------
43// Raw serde model (wire shape — TECH-SPEC §6)
44// ---------------------------------------------------------------------------
45
46#[derive(Debug, Deserialize, schemars::JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub(crate) struct RawPack {
49    pub(crate) templates: BTreeMap<String, RawTemplate>,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53#[serde(deny_unknown_fields)]
54pub(crate) struct RawTemplate {
55    #[serde(default)]
56    pub(crate) params: Vec<String>,
57    #[serde(default)]
58    pub(crate) defaults: BTreeMap<String, String>,
59    #[serde(rename = "match")]
60    pub(crate) match_: Option<String>,
61    pub(crate) description: Option<String>,
62    #[serde(default)]
63    pub(crate) tags: Vec<String>,
64    #[serde(default)]
65    pub(crate) steps: Vec<RawStep>,
66    pub(crate) expect: Option<Vec<RawExpectItem>>,
67}
68
69#[derive(Debug, Deserialize, schemars::JsonSchema)]
70pub(crate) struct RawStep {
71    pub(crate) name: Option<String>,
72    #[serde(default)]
73    pub(crate) optional: bool,
74    pub(crate) when: Option<String>,
75    pub(crate) retry: Option<RawRetry>,
76    /// Delay before the request, in milliseconds (baked into `[Options]`).
77    pub(crate) delay: Option<u64>,
78    #[serde(rename = "saveAs")]
79    pub(crate) save_as: Option<BTreeMap<String, String>>,
80    #[serde(rename = "use")]
81    pub(crate) use_: Option<String>,
82    pub(crate) with: Option<BTreeMap<String, String>>,
83    /// The dynamic payload key (`hurl:`, or a future engine's kind) — validated
84    /// against registered engine step kinds in pass 8.
85    #[serde(flatten)]
86    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
87    pub(crate) payload: BTreeMap<String, serde_norway::Value>,
88}
89
90#[derive(Debug, Deserialize, schemars::JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub(crate) struct RawRetry {
93    pub(crate) count: u32,
94    #[serde(default = "default_retry_interval")]
95    pub(crate) interval_ms: u64,
96}
97
98fn default_retry_interval() -> u64 {
99    1000
100}
101
102#[derive(Debug, Deserialize, schemars::JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub(crate) struct RawExpectItem {
105    pub(crate) status: Option<String>,
106    /// Raw hurl assert lines appended to the previous entry's `[Asserts]`.
107    pub(crate) hurl: Option<String>,
108}
109
110// ---------------------------------------------------------------------------
111// Loaded model (what binding and lowering consume)
112// ---------------------------------------------------------------------------
113
114/// A validated set of packs: every macro, indexed by (globally unique) name.
115#[derive(Debug, Default)]
116pub struct PackSet {
117    /// All macros by name (pass 3 guarantees global uniqueness).
118    pub macros: BTreeMap<String, Macro>,
119}
120
121impl PackSet {
122    /// `(pattern, macro name)` pairs for the step binder — macros with a
123    /// `match:` only.
124    pub fn step_defs(&self) -> Vec<(&str, &str)> {
125        self.macros
126            .values()
127            .filter_map(|m| m.pattern.as_deref().map(|p| (p, m.name.as_str())))
128            .collect()
129    }
130
131    /// Resolve a `use:` target (`name` or `pack.yaml#name`) to a macro.
132    pub fn find_use_target(&self, target: &str) -> Option<&Macro> {
133        match target.split_once('#') {
134            Some((pack_ref, name)) => self.macros.get(name).filter(|m| {
135                m.pack == pack_ref || m.pack.ends_with(pack_ref) || pack_ref.ends_with(&m.pack)
136            }),
137            None => self.macros.get(target),
138        }
139    }
140}
141
142/// One loaded macro.
143#[derive(Debug, Clone)]
144pub struct Macro {
145    /// Macro name (globally unique across loaded packs).
146    pub name: String,
147    /// Source pack name this macro came from.
148    pub pack: String,
149    /// Declared params (required unless defaulted).
150    pub params: Vec<String>,
151    /// Default values for optional params.
152    pub defaults: BTreeMap<String, String>,
153    /// The Gherkin-reachable `match:` pattern (absent = `use:`-only macro).
154    pub pattern: Option<String>,
155    /// Documentation string.
156    pub description: Option<String>,
157    /// Macro tags.
158    pub tags: Vec<String>,
159    /// Request steps or assert-only body.
160    pub body: MacroBody,
161    /// The pack source text (for diagnostics).
162    pub source: Arc<str>,
163    /// Span of the macro's name in the pack file, when locatable.
164    pub span: Option<Span>,
165}
166
167/// A macro is either a sequence of request steps or an assert-only `expect:`
168/// (merged into the previous request entry — the Then-step rule, ADR-0004).
169#[derive(Debug, Clone)]
170pub enum MacroBody {
171    /// Request steps.
172    Steps(Vec<MacroStep>),
173    /// Assert-only items.
174    Expect(Vec<ExpectItem>),
175}
176
177/// One step of a request macro.
178#[derive(Debug, Clone)]
179pub struct MacroStep {
180    /// Entry label (events/console).
181    pub name: Option<String>,
182    /// Delay before the request in milliseconds (baked into `[Options]`).
183    pub delay_ms: Option<u64>,
184    /// Payload or composition.
185    pub kind: MacroStepKind,
186    /// `optional:` — failure warns and the batch segments around it.
187    pub optional: bool,
188    /// `when:` skip guard (runs iff non-empty after resolution).
189    pub when: Option<String>,
190    /// Finite retry policy.
191    pub retry: Option<Retry>,
192    /// `saveAs:` promotions (capture name → `global`).
193    pub save_as: BTreeMap<String, String>,
194}
195
196/// Payload or composition of a [`MacroStep`].
197#[derive(Debug, Clone)]
198pub enum MacroStepKind {
199    /// An engine payload (`hurl: |` raw block, or structured for future engines).
200    Payload {
201        /// The step kind key as written (`hurl`, …).
202        kind: String,
203        /// The payload itself.
204        payload: PayloadForm,
205    },
206    /// Composition: inline another macro's steps.
207    Use {
208        /// Target macro (`name` or `pack.yaml#name`).
209        target: String,
210        /// Arguments for the target's params.
211        with: BTreeMap<String, String>,
212    },
213}
214
215/// The two payload shapes (ADR-0004: raw text is primary; structured is
216/// reserved for future non-hurl engines).
217#[derive(Debug, Clone)]
218pub enum PayloadForm {
219    /// Raw engine text (`hurl:` block scalar), `${…}` still unresolved.
220    Raw(String),
221    /// Structured payload for future engines.
222    Structured(serde_json::Value),
223}
224
225/// One assert-only item: a `status:` shorthand and/or raw hurl assert lines
226/// (both may contain `${…}`).
227#[derive(Debug, Clone)]
228pub struct ExpectItem {
229    /// Expected HTTP status.
230    pub status: Option<String>,
231    /// Raw assert lines appended to the previous entry's `[Asserts]`.
232    pub fragment: Option<String>,
233}
234
235// ---------------------------------------------------------------------------
236// Loading
237// ---------------------------------------------------------------------------
238
239/// Parse and validate `sources` against the registered engine step `kinds`
240/// (validation passes 1–8, TECH-SPEC §4.1). All diagnostics are collected —
241/// one bad pack does not hide problems in another.
242pub fn load(sources: &[PackSource], kinds: &[StepKindSpec]) -> Result<PackSet, FrontError> {
243    let mut diags: Vec<Diag> = Vec::new();
244    let mut set = PackSet::default();
245    let mut raw_packs: Vec<(usize, String, RawPack)> = Vec::new();
246
247    for (index, source) in sources.iter().enumerate() {
248        match serde_norway::from_str::<RawPack>(&source.text) {
249            Ok(raw) => raw_packs.push((index, source.name.clone(), raw)),
250            Err(err) => {
251                let span = err
252                    .location()
253                    .map(|loc| Span::clamped(loc.index(), loc.index() + 1, source.text.len()));
254                let mut diag = Diag::error(
255                    "proef::pack::yaml",
256                    format!("pack is not valid YAML for the pack schema: {err}"),
257                )
258                .with_source(source.name.clone(), Arc::clone(&source.text));
259                if let Some(span) = span {
260                    diag = diag.with_span(span);
261                }
262                diags.push(diag);
263            }
264        }
265    }
266
267    // Normalize templates into macros (structural checks happen inline).
268    for (source_index, pack_name, raw) in &raw_packs {
269        let source = &sources[*source_index];
270        for (template_name, template) in &raw.templates {
271            let normalized = validate::normalize_template(
272                template_name,
273                template,
274                pack_name,
275                source,
276                &mut diags,
277            );
278            if let Some(macro_) = normalized {
279                // Pass 3: duplicate macro names across packs.
280                if let Some(existing) = set.macros.get(template_name) {
281                    diags.push(
282                        Diag::error(
283                            "proef::pack::duplicate_macro",
284                            format!(
285                                "macro `{template_name}` is defined in both `{}` and `{pack_name}`",
286                                existing.pack
287                            ),
288                        )
289                        .with_source(source.name.clone(), Arc::clone(&source.text))
290                        .maybe_span(macro_.span)
291                        .with_help("macro names are global — rename one of the definitions"),
292                    );
293                } else {
294                    set.macros.insert(template_name.clone(), macro_);
295                }
296            }
297        }
298    }
299
300    validate::run_cross_macro_passes(&set, kinds, &mut diags);
301
302    if diags
303        .iter()
304        .any(|d| d.severity == crate::diag::Severity::Error)
305    {
306        Err(FrontError::Diagnostics(diags))
307    } else {
308        Ok(set)
309    }
310}
311
312impl Diag {
313    /// Attach a span when one is available (loader convenience).
314    #[must_use]
315    pub(crate) fn maybe_span(self, span: Option<Span>) -> Self {
316        match span {
317            Some(span) => self.with_span(span),
318            None => self,
319        }
320    }
321}