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/// Every fragment file's text, scanned **at most once** however many times the
35/// packs around it are loaded (ADR-0018).
36///
37/// One `proef test` loads packs up to four times — the suite, then `[run] setup`
38/// and `[run] teardown`, each validated and then run — against different feature
39/// paths but always the *same* corpus. Rescanning per load measured ~75% of a
40/// run's total work on a 200-file corpus, and it grows with the corpus, which is
41/// the direction adoption goes.
42///
43/// The memo lives here rather than in the caller because the scan must stay
44/// **lazy**: `load_collecting` runs it only when some pack actually has a
45/// `ref:`, which is what makes CONFIG.md's "pointing at a corpus you did not
46/// write costs nothing" true of the scan. A caller that scanned eagerly in
47/// order to share the result would buy speed by breaking that promise. Nothing
48/// here reads a file — the texts arrive already read, so core stays sans-IO.
49#[derive(Debug)]
50pub struct FragmentCorpus {
51    sources: Vec<PackSource>,
52    /// Captured at construction so the memo cannot be filled under one set of
53    /// kinds and then read under another.
54    kinds: Vec<StepKindSpec>,
55    /// Files the caller could not read, already shaped as diagnostics.
56    ///
57    /// Held rather than raised at read time because a corpus is *foreign by
58    /// design*: one unreadable file — a binary, a latin-1 export — must not
59    /// take down commands that never look at fragments at all. They surface
60    /// through the same gate as scan diagnostics, so a suite with no `ref:`
61    /// stays silent and "pointing at a corpus you did not write costs nothing"
62    /// keeps meaning what it says.
63    read_errors: Vec<Diag>,
64    scanned: std::sync::OnceLock<Scanned>,
65}
66
67/// One scan's product: the named fragments, plus what was wrong with the files.
68#[derive(Debug, Default)]
69pub(crate) struct Scanned {
70    pub(crate) fragments: Arc<BTreeMap<String, Fragment>>,
71    /// Per file, the 1-based lines of entries carrying no annotation. Keyed by
72    /// source name so a listing can group them under the file they belong to;
73    /// a file with none contributes no entry.
74    pub(crate) unannotated: BTreeMap<String, Vec<usize>>,
75    pub(crate) diags: Vec<Diag>,
76}
77
78impl FragmentCorpus {
79    /// A corpus over already-read file texts.
80    pub fn new(sources: Vec<PackSource>, kinds: &[StepKindSpec]) -> Self {
81        Self {
82            sources,
83            kinds: kinds.to_vec(),
84            read_errors: Vec::new(),
85            scanned: std::sync::OnceLock::new(),
86        }
87    }
88
89    /// Record files the caller could not read. They are reported like any other
90    /// per-file corpus problem — never sinking their siblings, and never at all
91    /// unless something `ref:`s the corpus.
92    #[must_use]
93    pub fn with_read_errors(mut self, errors: Vec<Diag>) -> Self {
94        self.read_errors = errors;
95        self
96    }
97
98    /// The diagnostic for a corpus file that could not be read at all.
99    ///
100    /// Here rather than at each caller because there are two — the CLI walks the
101    /// fragment root, the editor reads through its overlay provider — and they
102    /// had drifted: the CLI's carried the "the rest of the corpus still loads"
103    /// help that is the whole point of per-file resilience, and the editor's did
104    /// not, so the same unreadable file explained itself in the terminal and
105    /// went unexplained in the pane beside the code. The *reading* differs and
106    /// always will; what it means when reading fails does not.
107    #[must_use]
108    pub fn unreadable_file(name: &str, cause: &str) -> Diag {
109        Diag::error(
110            "proef::pack::unreadable_fragment_file",
111            format!("cannot read fragment file {name}: {cause}"),
112        )
113        .with_help(
114            "the rest of the corpus still loads — remove the file from the \
115             fragments root, or fix its encoding if a `ref:` needs it",
116        )
117    }
118
119    /// The empty corpus — no `[run] fragments` configured, so no `ref:` can
120    /// resolve and nothing is ever scanned.
121    pub fn empty() -> Self {
122        Self::new(Vec::new(), &[])
123    }
124
125    /// The scan, run on first use and shared by every load thereafter.
126    pub(crate) fn scanned(&self) -> &Scanned {
127        self.scanned.get_or_init(|| {
128            let mut scanned = scan_fragments(&self.sources, &self.kinds);
129            // Unreadable files first: they explain an `unknown_ref` that would
130            // otherwise read as a typo.
131            let mut diags = self.read_errors.clone();
132            diags.append(&mut scanned.diags);
133            scanned.diags = diags;
134            scanned
135        })
136    }
137
138    /// Every annotated fragment in the corpus, keyed by name. Scans on first
139    /// use, like every other reader.
140    ///
141    /// Public because the scan is otherwise gated: [`load`] parses the corpus
142    /// only when some pack actually names a fragment, so `PackSet::fragments`
143    /// is empty for a suite that references none — which is exactly the suite a
144    /// listing has the most to say about.
145    pub fn fragments(&self) -> &BTreeMap<String, Fragment> {
146        &self.scanned().fragments
147    }
148
149    /// Per file, the 1-based lines of entries carrying no `# @proef`
150    /// annotation — the one class of corpus content nothing else can report,
151    /// since an unannotated entry has no name to be listed by.
152    pub fn unannotated(&self) -> &BTreeMap<String, Vec<usize>> {
153        &self.scanned().unannotated
154    }
155
156    /// Whatever was wrong with the corpus: unreadable files first, then scan
157    /// failures. Already shaped as diagnostics.
158    pub fn diagnostics(&self) -> &[Diag] {
159        &self.scanned().diags
160    }
161}
162
163/// The built-in packs embedded into every proef binary.
164pub fn builtin_sources() -> Vec<PackSource> {
165    vec![PackSource {
166        name: "builtin:core.yaml".to_owned(),
167        text: Arc::from(include_str!("../../helpers/core.yaml")),
168    }]
169}
170
171// ---------------------------------------------------------------------------
172// Raw serde model (wire shape — TECH-SPEC §6)
173// ---------------------------------------------------------------------------
174
175#[derive(Debug, Deserialize, schemars::JsonSchema)]
176#[serde(deny_unknown_fields)]
177pub(crate) struct RawPack {
178    pub(crate) macros: BTreeMap<String, RawMacro>,
179    /// Pack-scope fragment bindings (ADR-0018): the plumbing every macro in the
180    /// file needs, written once. Macro and step scope override it.
181    #[serde(default)]
182    pub(crate) bind: BTreeMap<String, String>,
183}
184
185#[derive(Debug, Deserialize, schemars::JsonSchema)]
186#[serde(deny_unknown_fields)]
187pub(crate) struct RawMacro {
188    #[serde(default)]
189    pub(crate) params: Vec<String>,
190    #[serde(default)]
191    pub(crate) defaults: BTreeMap<String, String>,
192    #[serde(rename = "match")]
193    pub(crate) match_: Option<String>,
194    pub(crate) description: Option<String>,
195    #[serde(default)]
196    pub(crate) tags: Vec<String>,
197    #[serde(default)]
198    pub(crate) steps: Vec<RawStep>,
199    pub(crate) expect: Option<Vec<RawExpectItem>>,
200    /// Macro-scope fragment bindings (ADR-0018).
201    #[serde(default)]
202    pub(crate) bind: BTreeMap<String, String>,
203}
204
205#[derive(Debug, Deserialize, schemars::JsonSchema)]
206pub(crate) struct RawStep {
207    pub(crate) name: Option<String>,
208    #[serde(default)]
209    pub(crate) optional: bool,
210    pub(crate) when: Option<String>,
211    pub(crate) retry: Option<RawRetry>,
212    /// Delay before the request, in milliseconds (baked into `[Options]`).
213    pub(crate) delay: Option<u64>,
214    #[serde(rename = "saveAs")]
215    pub(crate) save_as: Option<BTreeMap<String, String>>,
216    #[serde(rename = "use")]
217    pub(crate) use_: Option<String>,
218    pub(crate) with: Option<BTreeMap<String, String>>,
219    /// A named fragment this step executes (ADR-0018) — the alternative to an
220    /// inline payload, never both on one step.
221    #[serde(rename = "ref")]
222    pub(crate) ref_: Option<String>,
223    /// Step-scope fragment bindings, the most specific of the three.
224    #[serde(default)]
225    pub(crate) bind: BTreeMap<String, String>,
226    /// The dynamic payload key (`hurl:`, or a future engine's kind) — validated
227    /// against registered engine step kinds in pass 8.
228    #[serde(flatten)]
229    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
230    pub(crate) payload: BTreeMap<String, serde_norway::Value>,
231}
232
233#[derive(Debug, Deserialize, schemars::JsonSchema)]
234#[serde(deny_unknown_fields)]
235pub(crate) struct RawRetry {
236    pub(crate) count: u32,
237    #[serde(default = "default_retry_interval")]
238    pub(crate) interval_ms: u64,
239}
240
241fn default_retry_interval() -> u64 {
242    1000
243}
244
245#[derive(Debug, Deserialize, schemars::JsonSchema)]
246#[serde(deny_unknown_fields)]
247pub(crate) struct RawExpectItem {
248    pub(crate) status: Option<String>,
249    /// Raw hurl assert lines appended to the previous entry's `[Asserts]`.
250    pub(crate) hurl: Option<String>,
251}
252
253// ---------------------------------------------------------------------------
254// Loaded model (what binding and lowering consume)
255// ---------------------------------------------------------------------------
256
257/// A validated set of packs: every macro, indexed by (globally unique) name,
258/// plus the fragments packs may reference and the pack-scope bindings.
259#[derive(Debug, Default)]
260pub struct PackSet {
261    /// All macros by name (pass 3 guarantees global uniqueness).
262    pub macros: BTreeMap<String, Macro>,
263    /// All named fragments by name — globally unique for the same reason macro
264    /// names are: a `ref:` names one thing, wherever it was declared (ADR-0018).
265    ///
266    /// Shared rather than owned: one scan serves every load of the same corpus
267    /// (see [`FragmentCorpus`]), so handing it to four loads costs four
268    /// refcounts, not four copies of the corpus.
269    pub fragments: Arc<BTreeMap<String, Fragment>>,
270    /// Pack-scope `bind:` tables, keyed by pack name so a binding stays
271    /// attributable to the file that declared it.
272    pub bind: BTreeMap<String, BTreeMap<String, String>>,
273}
274
275impl Fragment {
276    /// This fragment as `file.hurl#name` — the spelling a `ref:` accepts, and
277    /// what a run record carries so it can be read back long after the pack
278    /// that named it changed.
279    ///
280    /// Next to [`PackSet::find_fragment`], which parses the same form via
281    /// `pack_ref_matches`: producing it 400 lines from where it is consumed is
282    /// how the two stop agreeing on what the separator means.
283    #[must_use]
284    pub fn qualified(&self) -> String {
285        format!("{}#{}", self.file, self.name)
286    }
287}
288
289impl PackSet {
290    /// `(pattern, macro name)` pairs for the step binder — macros with a
291    /// `match:` only.
292    pub fn step_defs(&self) -> Vec<(&str, &str)> {
293        self.macros
294            .values()
295            .filter_map(|m| m.pattern.as_deref().map(|p| (p, m.name.as_str())))
296            .collect()
297    }
298
299    /// Resolve a `use:` target (`name` or `pack.yaml#name`) to a macro.
300    pub fn find_use_target(&self, target: &str) -> Option<&Macro> {
301        match target.split_once('#') {
302            Some((pack_ref, name)) => self
303                .macros
304                .get(name)
305                .filter(|m| pack_ref_matches(&m.pack, pack_ref)),
306            None => self.macros.get(target),
307        }
308    }
309
310    /// Resolve a `ref:` target (`name` or `file.hurl#name`) to a fragment —
311    /// the same two spellings `use:` accepts, qualified the same way, because
312    /// they answer the same question.
313    pub fn find_fragment(&self, target: &str) -> Option<&Fragment> {
314        match target.split_once('#') {
315            Some((file_ref, name)) => self
316                .fragments
317                .get(name)
318                .filter(|f| pack_ref_matches(&f.file, file_ref)),
319            None => self.fragments.get(target),
320        }
321    }
322}
323
324/// One named entry of a fragment file (ADR-0018).
325///
326/// Every field but `name` is *read* from the entry by the claiming engine's own
327/// parser — nothing is declared twice, so nothing can drift from the file.
328#[derive(Debug, Clone)]
329pub struct Fragment {
330    /// The name its `# @proef` annotation gave it (globally unique).
331    pub name: String,
332    /// Source file name as authored — the `file.hurl#name` qualifier and the
333    /// diagnostic source.
334    pub file: String,
335    /// The step kind whose engine scanned this file, taken from the
336    /// `StepKindSpec` whose `fragments.ext` claimed it. A `ref:` step routes by
337    /// this exactly as an inline step routes by its payload key (ADR-0002).
338    pub kind: String,
339    /// The entry's own text, annotation included.
340    pub text: String,
341    /// 1-based line the entry starts on.
342    pub line: usize,
343    /// Variables the entry reads: its required inputs.
344    pub placeholders: Vec<String>,
345    /// Option families the entry sets for itself (`"retry"`, `"delay"`).
346    pub declared_options: Vec<String>,
347    /// Variables the entry supplies to itself (`[Options] variable:`): both an
348    /// answer to its own placeholders and a clash with a `bind:` of that name.
349    pub supplied_variables: Vec<String>,
350    /// The fragment file's text (for diagnostics).
351    pub source: Arc<str>,
352}
353
354/// Path-boundary-aware pack-qualifier match: `api.yaml` qualifies
355/// `packs/api.yaml` but never `legacy-api.yaml` — a suffix only counts when
356/// it starts at a `/` boundary (or spans the whole name).
357fn pack_ref_matches(pack: &str, pack_ref: &str) -> bool {
358    let bounded_suffix = |hay: &str, needle: &str| {
359        hay.strip_suffix(needle)
360            .is_some_and(|rest| rest.is_empty() || rest.ends_with('/'))
361    };
362    bounded_suffix(pack, pack_ref) || bounded_suffix(pack_ref, pack)
363}
364
365/// One loaded macro.
366#[derive(Debug, Clone)]
367pub struct Macro {
368    /// Macro name (globally unique across loaded packs).
369    pub name: String,
370    /// Source pack name this macro came from.
371    pub pack: String,
372    /// Declared params (required unless defaulted).
373    pub params: Vec<String>,
374    /// Default values for optional params.
375    pub defaults: BTreeMap<String, String>,
376    /// The Gherkin-reachable `match:` pattern (absent = `use:`-only macro).
377    pub pattern: Option<String>,
378    /// Documentation string.
379    pub description: Option<String>,
380    /// Macro tags.
381    pub tags: Vec<String>,
382    /// Request steps or assert-only body.
383    pub body: MacroBody,
384    /// Macro-scope fragment bindings (ADR-0018), overriding pack scope.
385    pub bind: BTreeMap<String, String>,
386    /// The pack source text (for diagnostics).
387    pub source: Arc<str>,
388    /// Span of the macro's name in the pack file, when locatable.
389    pub span: Option<Span>,
390    /// Span of the macro's `match:` line in the pack file, when locatable.
391    pub match_span: Option<Span>,
392}
393
394/// A macro is either a sequence of request steps or an assert-only `expect:`
395/// (merged into the previous request entry — the Then-step rule, ADR-0004).
396#[derive(Debug, Clone)]
397pub enum MacroBody {
398    /// Request steps.
399    Steps(Vec<MacroStep>),
400    /// Assert-only items.
401    Expect(Vec<ExpectItem>),
402}
403
404/// One step of a request macro.
405#[derive(Debug, Clone)]
406pub struct MacroStep {
407    /// Entry label (events/console).
408    pub name: Option<String>,
409    /// Delay before the request in milliseconds (baked into `[Options]`).
410    pub delay_ms: Option<u64>,
411    /// Payload or composition.
412    pub kind: MacroStepKind,
413    /// `optional:` — failure warns and the batch segments around it.
414    pub optional: bool,
415    /// `when:` skip guard (runs iff non-empty after resolution).
416    pub when: Option<String>,
417    /// Finite retry policy.
418    pub retry: Option<Retry>,
419    /// `saveAs:` promotions (capture name → `global`).
420    pub save_as: BTreeMap<String, String>,
421    /// Step-scope fragment bindings (ADR-0018), the most specific of the three.
422    pub bind: BTreeMap<String, String>,
423}
424
425impl MacroStep {
426    /// The [`crate::engine::OPTION_FAMILIES`] this step sets for itself.
427    ///
428    /// The single derivation of "which options does the YAML declare", so the
429    /// double-declaration rule reads the same answer for both body forms —
430    /// an inline block's `[Options]` and a fragment's `declared_options` are
431    /// checked against *this*, not against two hand-written lists that could
432    /// drift apart and let hurl's silent last-wins back in.
433    ///
434    /// A new family is added here and in `OPTION_FAMILIES` together; both
435    /// call sites then cover it with no further edit.
436    pub fn declared_options(&self) -> impl Iterator<Item = &'static str> {
437        [
438            ("retry", self.retry.is_some()),
439            ("delay", self.delay_ms.is_some()),
440        ]
441        .into_iter()
442        .filter_map(|(family, declared)| declared.then_some(family))
443    }
444}
445
446/// Payload or composition of a [`MacroStep`].
447#[derive(Debug, Clone)]
448pub enum MacroStepKind {
449    /// An engine payload (`hurl: |` raw block, or structured for future engines).
450    Payload {
451        /// The step kind key as written (`hurl`, …).
452        kind: String,
453        /// The payload itself.
454        payload: PayloadForm,
455    },
456    /// Composition: inline another macro's steps.
457    Use {
458        /// Target macro (`name` or `pack.yaml#name`).
459        target: String,
460        /// Arguments for the target's params.
461        with: BTreeMap<String, String>,
462    },
463    /// A named fragment declared in an engine-native file (ADR-0018).
464    Ref {
465        /// Target fragment (`name` or `file.hurl#name`).
466        target: String,
467    },
468}
469
470/// The two payload shapes (ADR-0004: raw text is primary; structured is
471/// reserved for future non-hurl engines).
472#[derive(Debug, Clone)]
473pub enum PayloadForm {
474    /// Raw engine text (`hurl:` block scalar), `${…}` still unresolved.
475    Raw(String),
476    /// Structured payload for future engines.
477    Structured(serde_json::Value),
478}
479
480/// One assert-only item: a `status:` shorthand and/or raw hurl assert lines
481/// (both may contain `${…}`).
482#[derive(Debug, Clone)]
483pub struct ExpectItem {
484    /// Expected HTTP status.
485    pub status: Option<String>,
486    /// Raw assert lines appended to the previous entry's `[Asserts]`.
487    pub fragment: Option<String>,
488}
489
490// ---------------------------------------------------------------------------
491// Loading
492// ---------------------------------------------------------------------------
493
494/// Parse and validate `sources` against the registered engine step `kinds`
495/// (validation passes 1–13, TECH-SPEC §4.1), returning the partial [`PackSet`]
496/// built from every pack that parses+normalizes AND all diagnostics collected
497/// along the way. A pack that fails to parse contributes only its diagnostic
498/// and is excluded from the set — it never sinks its siblings. This is the
499/// collect-all half that the LSP's `analyze_suite` needs so one broken pack
500/// does not zero the whole suite; `load` is the fail-fast wrapper for a run.
501pub(crate) fn load_collecting(
502    sources: &[PackSource],
503    fragments: &FragmentCorpus,
504    kinds: &[StepKindSpec],
505) -> (PackSet, Vec<Diag>) {
506    let mut diags: Vec<Diag> = Vec::new();
507    let mut set = PackSet::default();
508    let mut raw_packs: Vec<(usize, String, RawPack)> = Vec::new();
509
510    for (index, source) in sources.iter().enumerate() {
511        match serde_norway::from_str::<RawPack>(&source.text) {
512            Ok(raw) => raw_packs.push((index, source.name.clone(), raw)),
513            Err(err) => {
514                let span = err
515                    .location()
516                    .map(|loc| Span::clamped(loc.index(), loc.index() + 1, source.text.len()));
517                let mut diag = Diag::error(
518                    "proef::pack::yaml",
519                    format!("pack is not valid YAML for the pack schema: {err}"),
520                )
521                .with_source(source.name.clone(), Arc::clone(&source.text));
522                if let Some(span) = span {
523                    diag = diag.with_span(span);
524                }
525                diags.push(diag);
526            }
527        }
528    }
529
530    // Fragments parse only when some pack actually names one. hurl-parsing a
531    // corpus is the dominant cost of loading it and the root may be large and
532    // external, so a suite that references none must not pay it on every
533    // `test`, `dry-run`, `flows` and `macros`.
534    //
535    // This skips the *parse*, not the read: `sources` and `fragments` both
536    // arrive already read, because core performs no IO (the caller does, and
537    // hands the bytes in). So "pointing at a corpus you did not write costs
538    // nothing" (CONFIG.md) is exact about the scan and approximate about the
539    // file read — do not restate it here as though the whole cost were gated.
540    if raw_packs.iter().any(|(_, _, raw)| {
541        raw.macros
542            .values()
543            .any(|m| m.steps.iter().any(|s| s.ref_.is_some()))
544    }) {
545        let scanned = fragments.scanned();
546        set.fragments = Arc::clone(&scanned.fragments);
547        diags.extend(scanned.diags.iter().cloned());
548    }
549
550    // Normalize each raw macro (structural checks happen inline).
551    for (source_index, pack_name, raw) in &raw_packs {
552        let source = &sources[*source_index];
553        for (macro_name, raw_macro) in &raw.macros {
554            let normalized =
555                validate::normalize_macro(macro_name, raw_macro, pack_name, source, &mut diags);
556            if let Some(macro_) = normalized {
557                // Pass 3: duplicate macro names across packs.
558                if let Some(existing) = set.macros.get(macro_name) {
559                    diags.push(
560                        Diag::error(
561                            "proef::pack::duplicate_macro",
562                            format!(
563                                "macro `{macro_name}` is defined in both `{}` and `{pack_name}`",
564                                existing.pack
565                            ),
566                        )
567                        .with_source(source.name.clone(), Arc::clone(&source.text))
568                        .maybe_span(macro_.span)
569                        .with_help("macro names are global — rename one of the definitions"),
570                    );
571                } else {
572                    set.macros.insert(macro_name.clone(), macro_);
573                }
574            }
575        }
576        if !raw.bind.is_empty() {
577            set.bind.insert(pack_name.clone(), raw.bind.clone());
578        }
579    }
580
581    validate::run_cross_macro_passes(&set, kinds, &mut diags);
582    (set, diags)
583}
584
585/// Scan every fragment file through the claiming engine's parser, indexing the
586/// annotated entries by name. Unannotated entries are dropped without comment:
587/// a corpus proef did not write is mostly those, and naming is the author's
588/// way of saying which ones proef may use.
589///
590/// A file the engine cannot parse contributes its diagnostic and nothing else —
591/// the same "never sinks its siblings" rule packs get.
592fn scan_fragments(sources: &[PackSource], kinds: &[StepKindSpec]) -> Scanned {
593    let mut fragments: BTreeMap<String, Fragment> = BTreeMap::new();
594    let mut unannotated: BTreeMap<String, Vec<usize>> = BTreeMap::new();
595    let mut diags: Vec<Diag> = Vec::new();
596    for source in sources {
597        // The extension decides which kind claims the file. A file no kind
598        // claims is skipped rather than handed to whichever scanner happens to
599        // be first: that guess would blame one engine's parser for another
600        // engine's file, and route the fragment to the wrong engine at run time.
601        let Some((kind_name, scan)) = kinds.iter().find_map(|kind| {
602            let support = kind.fragments?;
603            support
604                .claims(&source.name)
605                .then_some((kind.prefix, support.scan))
606        }) else {
607            continue;
608        };
609        let scanned = match scan(&source.text) {
610            Ok(scanned) => scanned,
611            Err(err) => {
612                diags.push(
613                    Diag::error(
614                        "proef::pack::bad_annotation",
615                        format!("{}: {}", source.name, err.message),
616                    )
617                    .with_source(source.name.clone(), Arc::clone(&source.text))
618                    .maybe_span(locate::line_span(&source.text, err.line)),
619                );
620                continue;
621            }
622        };
623        if !scanned.unannotated.is_empty() {
624            unannotated.insert(source.name.clone(), scanned.unannotated);
625        }
626        for entry in scanned.fragments {
627            let name = entry.name;
628            if let Some(existing) = fragments.get(&name) {
629                // Two branches, because the cross-file remedy is wrong for a
630                // same-file collision: `file.hurl#name` qualifies by *file*, so
631                // it cannot separate two entries inside one. Annotating a corpus
632                // adds many names to few files, which makes same-file the likely
633                // collision — and "declared in both `x` and `x`" reads as a bug
634                // in proef rather than a duplicate in the corpus.
635                let (message, help) = if existing.file == source.name {
636                    (
637                        format!(
638                            "fragment `{name}` is declared twice in `{}` (first at line {})",
639                            source.name, existing.line
640                        ),
641                        "fragment names are global — rename one of the two annotations",
642                    )
643                } else {
644                    (
645                        format!(
646                            "fragment `{name}` is declared in both `{}` and `{}`",
647                            existing.file, source.name
648                        ),
649                        "fragment names are global — rename one, or qualify the `ref:` \
650                         as `file.hurl#name`",
651                    )
652                };
653                diags.push(
654                    Diag::error("proef::pack::duplicate_fragment", message)
655                        .with_source(source.name.clone(), Arc::clone(&source.text))
656                        .maybe_span(locate::line_span(&source.text, entry.line))
657                        .with_help(help),
658                );
659                continue;
660            }
661            fragments.insert(
662                name.clone(),
663                Fragment {
664                    name,
665                    file: source.name.clone(),
666                    kind: kind_name.to_owned(),
667                    text: entry.text,
668                    line: entry.line,
669                    placeholders: entry.placeholders,
670                    declared_options: entry.declared_options,
671                    supplied_variables: entry.supplied_variables,
672                    source: Arc::clone(&source.text),
673                },
674            );
675        }
676    }
677    Scanned {
678        fragments: Arc::new(fragments),
679        unannotated,
680        diags,
681    }
682}
683
684/// Parse and validate `sources`, failing on the first error-severity diagnostic
685/// (the fail-fast contract a real `proef` run depends on). All diagnostics are
686/// still collected — one bad pack does not hide problems in another.
687pub fn load(
688    sources: &[PackSource],
689    fragments: &FragmentCorpus,
690    kinds: &[StepKindSpec],
691) -> Result<PackSet, FrontError> {
692    let (set, diags) = load_collecting(sources, fragments, kinds);
693    if diags
694        .iter()
695        .any(|d| d.severity == crate::diag::Severity::Error)
696    {
697        Err(FrontError::Diagnostics(diags))
698    } else {
699        Ok(set)
700    }
701}
702
703impl Diag {
704    /// Attach a span when one is available (loader convenience).
705    #[must_use]
706    pub(crate) fn maybe_span(self, span: Option<Span>) -> Self {
707        match span {
708            Some(span) => self.with_span(span),
709            None => self,
710        }
711    }
712}