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
10pub(crate) mod 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    /// Span of the macro's `match:` line in the pack file, when locatable.
178    pub match_span: Option<Span>,
179}
180
181/// A macro is either a sequence of request steps or an assert-only `expect:`
182/// (merged into the previous request entry — the Then-step rule, ADR-0004).
183#[derive(Debug, Clone)]
184pub enum MacroBody {
185    /// Request steps.
186    Steps(Vec<MacroStep>),
187    /// Assert-only items.
188    Expect(Vec<ExpectItem>),
189}
190
191/// One step of a request macro.
192#[derive(Debug, Clone)]
193pub struct MacroStep {
194    /// Entry label (events/console).
195    pub name: Option<String>,
196    /// Delay before the request in milliseconds (baked into `[Options]`).
197    pub delay_ms: Option<u64>,
198    /// Payload or composition.
199    pub kind: MacroStepKind,
200    /// `optional:` — failure warns and the batch segments around it.
201    pub optional: bool,
202    /// `when:` skip guard (runs iff non-empty after resolution).
203    pub when: Option<String>,
204    /// Finite retry policy.
205    pub retry: Option<Retry>,
206    /// `saveAs:` promotions (capture name → `global`).
207    pub save_as: BTreeMap<String, String>,
208}
209
210/// Payload or composition of a [`MacroStep`].
211#[derive(Debug, Clone)]
212pub enum MacroStepKind {
213    /// An engine payload (`hurl: |` raw block, or structured for future engines).
214    Payload {
215        /// The step kind key as written (`hurl`, …).
216        kind: String,
217        /// The payload itself.
218        payload: PayloadForm,
219    },
220    /// Composition: inline another macro's steps.
221    Use {
222        /// Target macro (`name` or `pack.yaml#name`).
223        target: String,
224        /// Arguments for the target's params.
225        with: BTreeMap<String, String>,
226    },
227}
228
229/// The two payload shapes (ADR-0004: raw text is primary; structured is
230/// reserved for future non-hurl engines).
231#[derive(Debug, Clone)]
232pub enum PayloadForm {
233    /// Raw engine text (`hurl:` block scalar), `${…}` still unresolved.
234    Raw(String),
235    /// Structured payload for future engines.
236    Structured(serde_json::Value),
237}
238
239/// One assert-only item: a `status:` shorthand and/or raw hurl assert lines
240/// (both may contain `${…}`).
241#[derive(Debug, Clone)]
242pub struct ExpectItem {
243    /// Expected HTTP status.
244    pub status: Option<String>,
245    /// Raw assert lines appended to the previous entry's `[Asserts]`.
246    pub fragment: Option<String>,
247}
248
249// ---------------------------------------------------------------------------
250// Loading
251// ---------------------------------------------------------------------------
252
253/// Parse and validate `sources` against the registered engine step `kinds`
254/// (validation passes 1–8, TECH-SPEC §4.1), returning the partial [`PackSet`]
255/// built from every pack that parses+normalizes AND all diagnostics collected
256/// along the way. A pack that fails to parse contributes only its diagnostic
257/// and is excluded from the set — it never sinks its siblings. This is the
258/// collect-all half that the LSP's `analyze_suite` needs so one broken pack
259/// does not zero the whole suite; `load` is the fail-fast wrapper for a run.
260pub(crate) fn load_collecting(
261    sources: &[PackSource],
262    kinds: &[StepKindSpec],
263) -> (PackSet, Vec<Diag>) {
264    let mut diags: Vec<Diag> = Vec::new();
265    let mut set = PackSet::default();
266    let mut raw_packs: Vec<(usize, String, RawPack)> = Vec::new();
267
268    for (index, source) in sources.iter().enumerate() {
269        match serde_norway::from_str::<RawPack>(&source.text) {
270            Ok(raw) => raw_packs.push((index, source.name.clone(), raw)),
271            Err(err) => {
272                let span = err
273                    .location()
274                    .map(|loc| Span::clamped(loc.index(), loc.index() + 1, source.text.len()));
275                let mut diag = Diag::error(
276                    "proef::pack::yaml",
277                    format!("pack is not valid YAML for the pack schema: {err}"),
278                )
279                .with_source(source.name.clone(), Arc::clone(&source.text));
280                if let Some(span) = span {
281                    diag = diag.with_span(span);
282                }
283                diags.push(diag);
284            }
285        }
286    }
287
288    // Normalize each raw macro (structural checks happen inline).
289    for (source_index, pack_name, raw) in &raw_packs {
290        let source = &sources[*source_index];
291        for (macro_name, raw_macro) in &raw.macros {
292            let normalized =
293                validate::normalize_macro(macro_name, raw_macro, pack_name, source, &mut diags);
294            if let Some(macro_) = normalized {
295                // Pass 3: duplicate macro names across packs.
296                if let Some(existing) = set.macros.get(macro_name) {
297                    diags.push(
298                        Diag::error(
299                            "proef::pack::duplicate_macro",
300                            format!(
301                                "macro `{macro_name}` is defined in both `{}` and `{pack_name}`",
302                                existing.pack
303                            ),
304                        )
305                        .with_source(source.name.clone(), Arc::clone(&source.text))
306                        .maybe_span(macro_.span)
307                        .with_help("macro names are global — rename one of the definitions"),
308                    );
309                } else {
310                    set.macros.insert(macro_name.clone(), macro_);
311                }
312            }
313        }
314    }
315
316    validate::run_cross_macro_passes(&set, kinds, &mut diags);
317    (set, diags)
318}
319
320/// Parse and validate `sources`, failing on the first error-severity diagnostic
321/// (the fail-fast contract a real `proef` run depends on). All diagnostics are
322/// still collected — one bad pack does not hide problems in another.
323pub fn load(sources: &[PackSource], kinds: &[StepKindSpec]) -> Result<PackSet, FrontError> {
324    let (set, diags) = load_collecting(sources, kinds);
325    if diags
326        .iter()
327        .any(|d| d.severity == crate::diag::Severity::Error)
328    {
329        Err(FrontError::Diagnostics(diags))
330    } else {
331        Ok(set)
332    }
333}
334
335impl Diag {
336    /// Attach a span when one is available (loader convenience).
337    #[must_use]
338    pub(crate) fn maybe_span(self, span: Option<Span>) -> Self {
339        match span {
340            Some(span) => self.with_span(span),
341            None => self,
342        }
343    }
344}