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) macros: BTreeMap<String, RawMacro>,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53#[serde(deny_unknown_fields)]
54pub(crate) struct RawMacro {
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
135                .macros
136                .get(name)
137                .filter(|m| pack_ref_matches(&m.pack, pack_ref)),
138            None => self.macros.get(target),
139        }
140    }
141}
142
143/// Path-boundary-aware pack-qualifier match: `api.yaml` qualifies
144/// `packs/api.yaml` but never `legacy-api.yaml` — a suffix only counts when
145/// it starts at a `/` boundary (or spans the whole name).
146fn pack_ref_matches(pack: &str, pack_ref: &str) -> bool {
147    let bounded_suffix = |hay: &str, needle: &str| {
148        hay.strip_suffix(needle)
149            .is_some_and(|rest| rest.is_empty() || rest.ends_with('/'))
150    };
151    bounded_suffix(pack, pack_ref) || bounded_suffix(pack_ref, pack)
152}
153
154/// One loaded macro.
155#[derive(Debug, Clone)]
156pub struct Macro {
157    /// Macro name (globally unique across loaded packs).
158    pub name: String,
159    /// Source pack name this macro came from.
160    pub pack: String,
161    /// Declared params (required unless defaulted).
162    pub params: Vec<String>,
163    /// Default values for optional params.
164    pub defaults: BTreeMap<String, String>,
165    /// The Gherkin-reachable `match:` pattern (absent = `use:`-only macro).
166    pub pattern: Option<String>,
167    /// Documentation string.
168    pub description: Option<String>,
169    /// Macro tags.
170    pub tags: Vec<String>,
171    /// Request steps or assert-only body.
172    pub body: MacroBody,
173    /// The pack source text (for diagnostics).
174    pub source: Arc<str>,
175    /// Span of the macro's name in the pack file, when locatable.
176    pub span: Option<Span>,
177}
178
179/// A macro is either a sequence of request steps or an assert-only `expect:`
180/// (merged into the previous request entry — the Then-step rule, ADR-0004).
181#[derive(Debug, Clone)]
182pub enum MacroBody {
183    /// Request steps.
184    Steps(Vec<MacroStep>),
185    /// Assert-only items.
186    Expect(Vec<ExpectItem>),
187}
188
189/// One step of a request macro.
190#[derive(Debug, Clone)]
191pub struct MacroStep {
192    /// Entry label (events/console).
193    pub name: Option<String>,
194    /// Delay before the request in milliseconds (baked into `[Options]`).
195    pub delay_ms: Option<u64>,
196    /// Payload or composition.
197    pub kind: MacroStepKind,
198    /// `optional:` — failure warns and the batch segments around it.
199    pub optional: bool,
200    /// `when:` skip guard (runs iff non-empty after resolution).
201    pub when: Option<String>,
202    /// Finite retry policy.
203    pub retry: Option<Retry>,
204    /// `saveAs:` promotions (capture name → `global`).
205    pub save_as: BTreeMap<String, String>,
206}
207
208/// Payload or composition of a [`MacroStep`].
209#[derive(Debug, Clone)]
210pub enum MacroStepKind {
211    /// An engine payload (`hurl: |` raw block, or structured for future engines).
212    Payload {
213        /// The step kind key as written (`hurl`, …).
214        kind: String,
215        /// The payload itself.
216        payload: PayloadForm,
217    },
218    /// Composition: inline another macro's steps.
219    Use {
220        /// Target macro (`name` or `pack.yaml#name`).
221        target: String,
222        /// Arguments for the target's params.
223        with: BTreeMap<String, String>,
224    },
225}
226
227/// The two payload shapes (ADR-0004: raw text is primary; structured is
228/// reserved for future non-hurl engines).
229#[derive(Debug, Clone)]
230pub enum PayloadForm {
231    /// Raw engine text (`hurl:` block scalar), `${…}` still unresolved.
232    Raw(String),
233    /// Structured payload for future engines.
234    Structured(serde_json::Value),
235}
236
237/// One assert-only item: a `status:` shorthand and/or raw hurl assert lines
238/// (both may contain `${…}`).
239#[derive(Debug, Clone)]
240pub struct ExpectItem {
241    /// Expected HTTP status.
242    pub status: Option<String>,
243    /// Raw assert lines appended to the previous entry's `[Asserts]`.
244    pub fragment: Option<String>,
245}
246
247// ---------------------------------------------------------------------------
248// Loading
249// ---------------------------------------------------------------------------
250
251/// Parse and validate `sources` against the registered engine step `kinds`
252/// (validation passes 1–8, TECH-SPEC §4.1). All diagnostics are collected —
253/// one bad pack does not hide problems in another.
254pub fn load(sources: &[PackSource], kinds: &[StepKindSpec]) -> Result<PackSet, FrontError> {
255    let mut diags: Vec<Diag> = Vec::new();
256    let mut set = PackSet::default();
257    let mut raw_packs: Vec<(usize, String, RawPack)> = Vec::new();
258
259    for (index, source) in sources.iter().enumerate() {
260        match serde_norway::from_str::<RawPack>(&source.text) {
261            Ok(raw) => raw_packs.push((index, source.name.clone(), raw)),
262            Err(err) => {
263                let span = err
264                    .location()
265                    .map(|loc| Span::clamped(loc.index(), loc.index() + 1, source.text.len()));
266                let mut diag = Diag::error(
267                    "proef::pack::yaml",
268                    format!("pack is not valid YAML for the pack schema: {err}"),
269                )
270                .with_source(source.name.clone(), Arc::clone(&source.text));
271                if let Some(span) = span {
272                    diag = diag.with_span(span);
273                }
274                diags.push(diag);
275            }
276        }
277    }
278
279    // Normalize each raw macro (structural checks happen inline).
280    for (source_index, pack_name, raw) in &raw_packs {
281        let source = &sources[*source_index];
282        for (macro_name, raw_macro) in &raw.macros {
283            let normalized =
284                validate::normalize_macro(macro_name, raw_macro, pack_name, source, &mut diags);
285            if let Some(macro_) = normalized {
286                // Pass 3: duplicate macro names across packs.
287                if let Some(existing) = set.macros.get(macro_name) {
288                    diags.push(
289                        Diag::error(
290                            "proef::pack::duplicate_macro",
291                            format!(
292                                "macro `{macro_name}` is defined in both `{}` and `{pack_name}`",
293                                existing.pack
294                            ),
295                        )
296                        .with_source(source.name.clone(), Arc::clone(&source.text))
297                        .maybe_span(macro_.span)
298                        .with_help("macro names are global — rename one of the definitions"),
299                    );
300                } else {
301                    set.macros.insert(macro_name.clone(), macro_);
302                }
303            }
304        }
305    }
306
307    validate::run_cross_macro_passes(&set, kinds, &mut diags);
308
309    if diags
310        .iter()
311        .any(|d| d.severity == crate::diag::Severity::Error)
312    {
313        Err(FrontError::Diagnostics(diags))
314    } else {
315        Ok(set)
316    }
317}
318
319impl Diag {
320    /// Attach a span when one is available (loader convenience).
321    #[must_use]
322    pub(crate) fn maybe_span(self, span: Option<Span>) -> Self {
323        match span {
324            Some(span) => self.with_span(span),
325            None => self,
326        }
327    }
328}