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