Skip to main content

proef_core/
lower.rs

1//! Lowering (TECH-SPEC §4.4): bound scenarios → engine batches.
2//!
3//! Macro expansion (`use:`/`with:`, cycle-safe, depth ≤ 32) · recursive `${…}`
4//! resolution (ADR-0005, via [`crate::resolve`]) · the Then-merge rule
5//! (`expect:` macros fold their asserts into the *previous* request entry;
6//! Then-before-When is an error) · batch segmentation (**maximal**: split only
7//! at engine changes and `optional:` boundaries — each optional step is a
8//! singleton batch so its failure can warn without poisoning neighbors).
9//!
10//! Pure: environment snapshot, run id, and World are injected.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::sync::Arc;
14
15use crate::bind::BoundScenario;
16use crate::diag::{Diag, Severity};
17use crate::feature::FeatureFile;
18use crate::pack::{Macro, MacroBody, MacroStep, MacroStepKind, PackSet, PayloadForm};
19use crate::resolve::{self, ResolveCtx, ResolveMode};
20use crate::step::{Guard, LoweredStep, StepBatch, StepKindId, StepPayload, StepRef};
21use crate::world::World;
22
23/// Everything lowering needs, all injected (core purity).
24#[derive(Debug, Clone, Copy)]
25pub struct LowerCtx<'a> {
26    /// The feature the scenario came from (source for diags).
27    pub feature: &'a FeatureFile,
28    /// Loaded macros.
29    pub packs: &'a PackSet,
30    /// Step kind prefix → engine id (from the CLI's registry assembly).
31    pub kind_to_engine: &'a BTreeMap<String, String>,
32    /// Injected environment snapshot.
33    pub env: &'a BTreeMap<String, String>,
34    /// Injected `proef.toml` config scope (`${url:key}` / `${vars:key}`), keyed
35    /// `"<namespace>:<key>"` — the active `[env.<name>]` already deep-merged in.
36    pub config_vars: &'a BTreeMap<String, String>,
37    /// Injected run identifier.
38    pub run_id: &'a str,
39    /// World (globals read at lower time).
40    pub world: &'a World,
41    /// Strict (execution) or dry-run resolution.
42    pub mode: ResolveMode,
43}
44
45/// A fully lowered scenario: ordered engine batches plus what lowering learned.
46#[derive(Debug)]
47pub struct LoweredScenario {
48    /// Scenario name (post-expansion).
49    pub name: String,
50    /// Accumulated tags.
51    pub tags: Vec<String>,
52    /// 1-based header line.
53    pub line: usize,
54    /// Contiguous same-engine batches, in authored order.
55    pub batches: Vec<StepBatch>,
56    /// Secrets referenced anywhere in the scenario, as **hurl variable name →
57    /// secret name** (values never appear). The two differ only when a
58    /// fragment binding renames one — `bind: { auth_token: ${secret:apiToken} }`
59    /// — which is what lets a corpus proef did not write keep its own variable
60    /// names (ADR-0018). Inline `${secret:X}` maps `X` to itself.
61    pub secrets: BTreeMap<String, String>,
62    /// Global keys read anywhere in the scenario (drives `.vars`, ADR-0010).
63    pub globals: BTreeSet<String>,
64    /// Soft findings (dry-run globals, …) as warning diagnostics.
65    pub warnings: Vec<Diag>,
66}
67
68/// Runtime backstop for `use:` recursion (statically checked at pack load).
69const MAX_EXPANSION_DEPTH: usize = 32;
70
71/// Resolved fragment bindings in scope, by hurl variable name (ADR-0018).
72type Bindings = BTreeMap<String, Bound>;
73
74/// One resolved binding, split by how its value reaches hurl.
75#[derive(Debug, Clone, PartialEq, Eq)]
76enum Bound {
77    /// A literal, injected as a per-entry `[Options] variable:` — so it lands
78    /// in the artifact and replays identically under the stock CLI.
79    Value(String),
80    /// A secret, named here and injected by the engine at run time. It must
81    /// **not** take the `[Options]` path: that would write the value into the
82    /// artifact, which ADR-0005 forbids outright.
83    Secret(String),
84}
85
86/// `${secret:NAME}` as an *entire* binding value — the only shape a secret may
87/// take. Anything composite (`Bearer ${secret:token}`) would have to be
88/// materialized to be injected, putting the value in the artifact; the fragment
89/// should spell the literal part itself and bind the secret alone.
90///
91/// Both this and [`mentions_secret`] read the value through the resolver's own
92/// [`crate::resolve::first_reference`], so there is exactly one thing that knows
93/// what a `${…}` reference is — and `$${` stays an escape here too, rather than
94/// a second scanner mistaking an escaped literal for a live secret.
95fn whole_secret(value: &str) -> Option<&str> {
96    let trimmed = value.trim();
97    let (name, start, end) = crate::resolve::first_reference(trimmed)?;
98    if start != 0 || end != trimmed.len() {
99        return None; // something surrounds it — composite
100    }
101    let inner = name.strip_prefix("secret:")?.trim();
102    (!inner.is_empty() && !inner.contains(['{', '$'])).then_some(inner)
103}
104
105/// Does any *live* reference in `value` name the secret namespace? Used to
106/// refuse a composite once [`whole_secret`] has ruled out the sole legal shape.
107fn mentions_secret(value: &str) -> bool {
108    let mut rest = value;
109    while let Some((name, _, end)) = crate::resolve::first_reference(rest) {
110        if name.starts_with("secret:") {
111            return true;
112        }
113        rest = &rest[end..];
114    }
115    false
116}
117
118/// Escape a bound value for a quoted hurl option value. `\` and `"` are the
119/// two characters that would end or re-open the literal; `{{…}}` is left alone
120/// on purpose, since hurl expanding it is the point.
121fn quote_option(value: &str) -> String {
122    value.replace('\\', "\\\\").replace('"', "\\\"")
123}
124
125/// Does this macro reference any fragment? Bindings resolve only when they can
126/// be used — otherwise a pack-scope `${fake:…}` would advance for macros that
127/// never bind anything, and an unresolvable one would fail a macro it does not
128/// concern.
129fn macro_has_ref(macro_: &Macro) -> bool {
130    match &macro_.body {
131        MacroBody::Steps(steps) => steps
132            .iter()
133            .any(|step| matches!(step.kind, MacroStepKind::Ref { .. })),
134        MacroBody::Expect(_) => false,
135    }
136}
137
138/// The bindings a macro's `ref:` steps see before their own: pack scope,
139/// resolved once per scenario and cached, then macro scope, resolved once per
140/// invocation — which is what makes "one binding, one value" mean something
141/// different at each level (ADR-0018). Empty for a macro with no `ref:` step,
142/// so an unused table never advances the `${fake:…}` counter.
143fn scope_bindings(
144    macro_: &Macro,
145    ctx: &LowerCtx<'_>,
146    refs: &mut Refs,
147    sinks: &mut Sinks,
148    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
149    at: &impl Fn(Diag) -> Diag,
150) -> Bindings {
151    let mut scoped = Bindings::new();
152    if !macro_has_ref(macro_) {
153        return scoped;
154    }
155    if let Some(table) = ctx.packs.bind.get(&macro_.pack) {
156        if !refs.pack_bindings.contains_key(&macro_.pack) {
157            let resolved = resolve_bindings(table, refs, sinks, resolve_in, at);
158            refs.pack_bindings.insert(macro_.pack.clone(), resolved);
159        }
160        if let Some(cached) = refs.pack_bindings.get(&macro_.pack) {
161            scoped.extend(cached.clone());
162        }
163    }
164    scoped.extend(resolve_bindings(&macro_.bind, refs, sinks, resolve_in, at));
165    scoped
166}
167
168/// Resolve one `bind:` table in the caller's scope.
169fn resolve_bindings(
170    table: &BTreeMap<String, String>,
171    refs: &mut Refs,
172    sinks: &mut Sinks,
173    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
174    at: &impl Fn(Diag) -> Diag,
175) -> Bindings {
176    let mut out = Bindings::new();
177    for (name, value) in table {
178        if let Some(secret) = whole_secret(value) {
179            out.insert(name.clone(), Bound::Secret(secret.to_owned()));
180            continue;
181        }
182        if mentions_secret(value) {
183            sinks.errors.push(
184                at(Diag::error(
185                    "proef::lower::secret_in_composite_bind",
186                    format!(
187                        "binding `{name}` mixes a secret into a larger value — the result would have to be written into the artifact to be injected"
188                    ),
189                ))
190                .with_help(
191                    "bind the secret on its own and put the surrounding text in the fragment \
192                     (`Authorization: Bearer {{token}}` with `bind: { token: ${secret:…} }`)",
193                ),
194            );
195            continue;
196        }
197        if let Some(resolved) = resolve_in(value, refs, sinks) {
198            out.insert(name.clone(), Bound::Value(resolved));
199        }
200    }
201    out
202}
203
204/// Every capture name produced by the steps lowered so far — what a fragment
205/// may read without binding it.
206fn captures_before(out: &[LoweredStep]) -> BTreeSet<String> {
207    let mut names = BTreeSet::new();
208    for step in out {
209        if let StepPayload::HurlEntries(text) = &step.payload {
210            let lines: Vec<&str> = text.lines().collect();
211            names.extend(crate::emit::capture_names(&lines));
212        }
213    }
214    names
215}
216
217/// The two diagnostic sinks a lowering pass fills.
218///
219/// Bundled rather than passed as two parameters, because they are the same type
220/// and were adjacent: transposing them at any of a dozen call sites compiled
221/// cleanly and routed every error into `warnings`, so a scenario that should
222/// have failed lowered "successfully" and the run exited 0. Named fields make
223/// that mistake unspellable.
224#[derive(Debug, Default)]
225struct Sinks {
226    /// Non-fatal notes surfaced beside a scenario that still runs.
227    warnings: Vec<Diag>,
228    /// Failures — any one of these aborts the scenario.
229    errors: Vec<Diag>,
230}
231
232/// What resolution referenced while lowering one scenario.
233#[derive(Debug, Default)]
234struct Refs {
235    /// hurl variable name → secret name (see [`LoweredScenario::secrets`]).
236    secrets: BTreeMap<String, String>,
237    globals: BTreeSet<String>,
238    /// Pack-scope `bind:` tables resolved once per scenario, by pack name.
239    /// Scope decides *when* a binding resolves: one binding is one value, so a
240    /// pack-scope `${fake:email}` is one identity for the whole scenario, a
241    /// macro-scope one is per invocation, a step-scope one is per step.
242    pack_bindings: BTreeMap<String, Bindings>,
243    /// `${fake:*}` occurrence counter, shared across every step in the
244    /// scenario (not reset per `resolve()` call) so two steps each asking
245    /// for a fresh `${fake:email}` get distinct values. Scoped to one
246    /// `lower()` call — i.e. one scenario — which keeps it a pure function
247    /// of `(run_id, the scenario's own step order)`: `lower()` runs
248    /// single-threaded per scenario (`runner.rs`: scenario-per-OS-thread),
249    /// so no cross-scenario or cross-thread ordering ever reaches it.
250    fakes: usize,
251}
252
253/// Lower one bound scenario into engine batches.
254pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
255    let mut sinks = Sinks::default();
256    let mut refs = Refs::default();
257    let mut lowered: Vec<LoweredStep> = Vec::new();
258    for step in &scenario.steps {
259        let step_ref = StepRef {
260            file: Arc::from(ctx.feature.path.as_str()),
261            line: step.defn.line,
262            text: Arc::from(step.defn.text.as_str()),
263        };
264        let at = |diag: Diag| {
265            diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
266                .with_span(step.defn.span)
267        };
268        let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
269            continue; // binder guarantees existence
270        };
271        expand_macro(
272            macro_,
273            &step.args,
274            &step_ref,
275            ctx,
276            0,
277            &mut lowered,
278            &mut refs,
279            &mut sinks,
280            &at,
281        );
282    }
283
284    // Routing invariant (ADR-0002): every non-glued step's kind must map to a
285    // registered engine. Pack validation and the CLI registry keep this true,
286    // so a miss is drift between them — surfaced as an explicit internal
287    // fault instead of a fabricated engine id that fails later and further
288    // from the cause.
289    for step in &lowered {
290        if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
291            continue; // glued to its host batch — no engine of its own
292        }
293        if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
294            sinks.errors.push(
295                Diag::error(
296                    "proef::lower::kind_unrouted",
297                    format!(
298                        "internal: step kind `{}` is not claimed by any registered engine \
299                         (registry/pack-validation drift)",
300                        step.kind.as_str()
301                    ),
302                )
303                .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
304            );
305        }
306    }
307
308    if sinks.errors.iter().any(|d| d.severity == Severity::Error) {
309        return Err(sinks.errors);
310    }
311
312    Ok(LoweredScenario {
313        name: scenario.name.clone(),
314        tags: scenario.tags.clone(),
315        line: scenario.line,
316        batches: segment(lowered, ctx.kind_to_engine),
317        secrets: refs.secrets,
318        globals: refs.globals,
319        warnings: sinks.warnings,
320    })
321}
322
323/// Expand one macro invocation into lowered steps (recursing through `use:`).
324#[allow(clippy::too_many_arguments)]
325fn expand_macro(
326    macro_: &Macro,
327    args: &BTreeMap<String, String>,
328    step_ref: &StepRef,
329    ctx: &LowerCtx<'_>,
330    depth: usize,
331    out: &mut Vec<LoweredStep>,
332    refs: &mut Refs,
333    sinks: &mut Sinks,
334    at: &impl Fn(Diag) -> Diag,
335) {
336    if depth > MAX_EXPANSION_DEPTH {
337        sinks.errors.push(at(Diag::error(
338            "proef::lower::expansion_too_deep",
339            format!(
340                "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
341                macro_.name
342            ),
343        )));
344        return;
345    }
346
347    let resolve_in = |text: &str, refs: &mut Refs, sinks: &mut Sinks| -> Option<String> {
348        let resolve_ctx = ResolveCtx {
349            args,
350            defaults: &macro_.defaults,
351            env: ctx.env,
352            config_vars: ctx.config_vars,
353            run_id: ctx.run_id,
354            world: ctx.world,
355            mode: ctx.mode,
356        };
357        match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
358            Ok(resolution) => {
359                // Inline `${secret:X}` lowers to the literal `{{X}}`, so the
360                // hurl variable and the secret share a name; only a fragment
361                // binding can make them differ.
362                refs.secrets
363                    .extend(resolution.secrets.into_iter().map(|s| (s.clone(), s)));
364                refs.globals.extend(resolution.globals);
365                push_warnings(sinks, &resolution.warnings, ctx, &macro_.name);
366                Some(resolution.text)
367            }
368            Err(err) => {
369                sinks.errors.push(at(Diag::error(
370                    err.code(),
371                    format!("in macro `{}`: {err}", macro_.name),
372                )));
373                None
374            }
375        }
376    };
377
378    // Bindings in scope for this macro's `ref:` steps: pack scope (resolved
379    // once per scenario, cached) then macro scope (once per invocation, which
380    // is here). Step scope is applied per step in `expand_step`.
381    let scoped = scope_bindings(macro_, ctx, refs, sinks, &resolve_in, at);
382
383    match &macro_.body {
384        MacroBody::Expect(items) => {
385            let mut merged: Option<(StepKindId, bool, usize)> = None;
386            for item in items {
387                let status = match &item.status {
388                    Some(status) => match resolve_in(status, refs, sinks) {
389                        Some(status) => Some(status),
390                        None => continue,
391                    },
392                    None => None,
393                };
394                let fragment = match &item.fragment {
395                    Some(fragment) => match resolve_in(fragment, refs, sinks) {
396                        Some(fragment) => Some(fragment),
397                        None => continue,
398                    },
399                    None => None,
400                };
401                if let Some((kind, optional, lines)) =
402                    merge_expect(status.as_deref(), fragment.as_deref(), out, sinks, at)
403                {
404                    let entry = merged.get_or_insert((kind, optional, 0));
405                    entry.2 += lines;
406                }
407            }
408            // The authored `Then` surfaces as its own step (§2.7): zero bytes
409            // of its own, anchored on the assert lines it appended to the
410            // host entry. It shares the host's fate (`optional` inherited).
411            if let Some((kind, optional, lines)) = merged {
412                out.push(LoweredStep {
413                    step: step_ref.clone(),
414                    kind,
415                    payload: StepPayload::MergedAsserts { lines },
416                    optional,
417                    when: None,
418                    label: None,
419                    // Not the `fragment` bound a few lines up: that one is
420                    // `ExpectItem::fragment`, raw assert *text* (YAML key
421                    // `hurl:`), and predates ADR-0018's named fragments. An
422                    // `expect:` step executes no request, so it refs nothing.
423                    fragment: None,
424                    save_as: std::collections::BTreeMap::new(),
425                });
426            }
427        }
428        MacroBody::Steps(steps) => {
429            for macro_step in steps {
430                expand_step(
431                    macro_step,
432                    step_ref,
433                    ctx,
434                    depth,
435                    out,
436                    refs,
437                    sinks,
438                    at,
439                    &resolve_in,
440                    &scoped,
441                );
442            }
443        }
444    }
445}
446
447/// Expand one pack step (payload or `use:` composition).
448#[allow(clippy::too_many_arguments)]
449fn expand_step(
450    macro_step: &MacroStep,
451    step_ref: &StepRef,
452    ctx: &LowerCtx<'_>,
453    depth: usize,
454    out: &mut Vec<LoweredStep>,
455    refs: &mut Refs,
456    sinks: &mut Sinks,
457    at: &impl Fn(Diag) -> Diag,
458    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
459    scoped: &Bindings,
460) {
461    match &macro_step.kind {
462        MacroStepKind::Ref { target } => expand_ref_step(
463            target, macro_step, step_ref, ctx, out, refs, sinks, at, resolve_in, scoped,
464        ),
465        MacroStepKind::Use { target, with } => {
466            let Some(target_macro) = ctx.packs.find_use_target(target) else {
467                return; // pack validation reported it
468            };
469            // `with:` values resolve in the *parent* scope, then become the
470            // child's args (child defaults fill the rest).
471            let mut child_args = BTreeMap::new();
472            for (key, value) in with {
473                if let Some(resolved) = resolve_in(value, refs, sinks) {
474                    child_args.insert(key.clone(), resolved);
475                }
476            }
477            expand_macro(
478                target_macro,
479                &child_args,
480                step_ref,
481                ctx,
482                depth + 1,
483                out,
484                refs,
485                sinks,
486                at,
487            );
488        }
489        MacroStepKind::Payload { kind, payload } => expand_payload_step(
490            macro_step, kind, payload, step_ref, out, refs, sinks, resolve_in,
491        ),
492    }
493}
494
495/// One `ref:` step: bind, check every read is supplied, then emit the
496/// fragment's own text with the bindings baked in (ADR-0018).
497#[allow(clippy::too_many_arguments)]
498fn expand_ref_step(
499    target: &str,
500    macro_step: &MacroStep,
501    step_ref: &StepRef,
502    ctx: &LowerCtx<'_>,
503    out: &mut Vec<LoweredStep>,
504    refs: &mut Refs,
505    sinks: &mut Sinks,
506    at: &impl Fn(Diag) -> Diag,
507    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
508    scoped: &Bindings,
509) {
510    let Some(fragment) = ctx.packs.find_fragment(target) else {
511        return; // pack validation reported it
512    };
513    // A `ref:` step's functional resolves are its step-scope bindings — those
514    // are what the request is built from — so the label replays from here
515    // (see `finish_step`). Pack- and macro-scope bindings resolved earlier and
516    // are shared, so they are deliberately not replayed.
517    let label_fakes_start = refs.fakes;
518    // Step scope is the most specific, so it lands last and wins.
519    let mut bindings = scoped.clone();
520    bindings.extend(resolve_bindings(
521        &macro_step.bind,
522        refs,
523        sinks,
524        resolve_in,
525        at,
526    ));
527
528    // Every variable the fragment reads must be bound here or captured
529    // by a step before it. hurl's `[Options] variable:` *assigns* into
530    // one shared set (it does not scope), so an unbound name would
531    // silently inherit whatever an earlier entry happened to leave —
532    // running green against the wrong value.
533    let unbound: Vec<&str> = fragment
534        .placeholders
535        .iter()
536        .filter(|name| {
537            !bindings.contains_key(name.as_str())
538                && !refs.secrets.contains_key(name.as_str())
539                // A fragment that answers its own question is answered. This is
540                // what lets the file run standalone under the engine's own
541                // binary with no variables file — ADR-0018's premise — so
542                // refusing it here would reject valid input that the engine
543                // itself accepts. Scoped to *this* fragment: a name another
544                // entry happened to leave in hurl's shared set is exactly the
545                // implicit inheritance this whole check exists to refuse, so
546                // only `[Captures]` carries a value forward.
547                && !fragment.supplied_variables.contains(name)
548        })
549        .map(String::as_str)
550        .collect();
551    // Captures are the expensive half — the scan re-reads every step lowered so
552    // far — so it only runs for names a binding did not already cover, which in
553    // a pack that binds what its fragment reads is none of them.
554    let missing: Vec<&str> = if unbound.is_empty() {
555        Vec::new()
556    } else {
557        let available = captures_before(out);
558        unbound
559            .into_iter()
560            .filter(|name| !available.contains(*name))
561            .collect()
562    };
563    if !missing.is_empty() {
564        // Anchored on the fragment, not on the pack step: the variable is
565        // literally on that line of that file, and ADR-0018 promises a real
566        // file:line. The message names the macro, so the other end of the link
567        // is not lost.
568        let message = format!(
569            "fragment `{}` reads `{}`, which nothing supplies — no `bind:` in scope gives a value, and no earlier step captures it",
570            fragment.name,
571            missing.join("`, `"),
572        );
573        sinks.errors.push(
574            Diag::error("proef::lower::unbound_placeholder", message)
575                .with_source(fragment.file.clone(), Arc::clone(&fragment.source))
576                .maybe_span(crate::pack::locate::line_span(
577                    &fragment.source,
578                    fragment.line,
579                ))
580                .with_help(format!(
581                    "add `bind: {{ {}: … }}` to the step, its macro, or the pack",
582                    missing[0]
583                )),
584        );
585        return;
586    }
587
588    // Split the bindings by how each reaches hurl. Only literals take the
589    // `[Options]` path; a secret's value must never be written into an
590    // artifact (ADR-0005), so it is recorded by name and injected at run time.
591    // `bindings` is consumed here — it is dead afterwards, so neither half
592    // needs to be cloned back out of it.
593    let mut literals: BTreeMap<String, String> = BTreeMap::new();
594    for (name, bound) in bindings {
595        match bound {
596            Bound::Secret(secret) => {
597                refs.secrets.insert(name, secret);
598            }
599            Bound::Value(value) => {
600                literals.insert(name, value);
601            }
602        }
603    }
604    let text = bake_entry_options(
605        &fragment.text,
606        macro_step.retry,
607        macro_step.delay_ms,
608        &literals,
609    );
610    finish_step(
611        macro_step,
612        step_ref,
613        StepKindId::from(fragment.kind.as_str()),
614        StepPayload::HurlEntries(text),
615        // Qualified here rather than at the reader: `target` is what the pack
616        // wrote, which may be the bare name, and a record has to stand on its
617        // own — by the time anyone reads it the pack may say something else.
618        Some(fragment.qualified()),
619        label_fakes_start,
620        out,
621        refs,
622        sinks,
623        resolve_in,
624    );
625}
626
627/// One inline-payload step: resolve `${…}` in place, then bake the step's own
628/// `retry:`/`delay:` into `[Options]` (ADR-0010 — artifacts replay identically).
629#[allow(clippy::too_many_arguments)]
630fn expand_payload_step(
631    macro_step: &MacroStep,
632    kind: &str,
633    payload: &PayloadForm,
634    step_ref: &StepRef,
635    out: &mut Vec<LoweredStep>,
636    refs: &mut Refs,
637    sinks: &mut Sinks,
638    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
639) {
640    // The label annotates *this* step for humans (artifact comments,
641    // events) — it must report the fake values the step actually
642    // used, not mint fresh ones of its own. Remember where the
643    // occurrence counter stood before the functional resolves
644    // (payload, then guard) so the label can replay from the same
645    // base afterward.
646    let label_fakes_start = refs.fakes;
647    let payload = match payload {
648        PayloadForm::Raw(text) => {
649            let Some(resolved) = resolve_in(text, refs, sinks) else {
650                return;
651            };
652            // Bake `retry:`/`delay:` into hurl `[Options]` so artifacts
653            // replay with identical semantics under the stock CLI
654            // (ADR-0010); per-entry [Options] override batch defaults.
655            let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
656                bake_entry_options(
657                    &resolved,
658                    macro_step.retry,
659                    macro_step.delay_ms,
660                    &BTreeMap::new(),
661                )
662            } else {
663                resolved
664            };
665            StepPayload::HurlEntries(resolved)
666        }
667        PayloadForm::Structured(value) => {
668            // `${…}` resolves inside structured payloads exactly as in
669            // raw ones (ADR-0005): every string value, recursively.
670            // Keys are schema, not data — they stay literal.
671            let mut resolve = |text: &str| {
672                // No `$` ⇒ no placeholders and no `$${` escapes: skip
673                // the resolver's copy passes for the common case.
674                if !text.contains('$') {
675                    return Some(text.to_owned());
676                }
677                resolve_in(text, refs, sinks)
678            };
679            match resolve_structured(value, &mut resolve) {
680                Some(resolved) => StepPayload::Structured(resolved),
681                None => return,
682            }
683        }
684    };
685    finish_step(
686        macro_step,
687        step_ref,
688        StepKindId::from(kind),
689        payload,
690        None, // inline `hurl:` block — no fragment to point at
691        label_fakes_start,
692        out,
693        refs,
694        sinks,
695        resolve_in,
696    );
697}
698
699/// The tail every expanded step shares: resolve `when:`, resolve `name:` as a
700/// *replay*, and push. Both body forms (ADR-0018) end here, so the label rules
701/// below are stated and enforced exactly once — a second copy that resolved the
702/// label plainly would silently mint fresh `${fake:…}` values.
703///
704/// `label_fakes_start` is where the occurrence counter stood before *this
705/// step's* functional resolves (the payload for an inline step, the step-scope
706/// `bind:` for a `ref:` one).
707#[allow(clippy::too_many_arguments)]
708fn finish_step(
709    macro_step: &MacroStep,
710    step_ref: &StepRef,
711    kind: StepKindId,
712    payload: StepPayload,
713    // `file.hurl#name` for a `ref:` step, `None` for an inline block — the
714    // provenance a run record carries (ADR-0018).
715    fragment: Option<String>,
716    label_fakes_start: usize,
717    out: &mut Vec<LoweredStep>,
718    refs: &mut Refs,
719    sinks: &mut Sinks,
720    resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
721) {
722    let when = match &macro_step.when {
723        Some(guard) => match resolve_in(guard, refs, sinks) {
724            Some(resolved) => Some(Guard(resolved)),
725            None => return,
726        },
727        None => None,
728    };
729    // Labels resolve like payloads (same scope, same strictness) —
730    // otherwise raw `${…}` leaks into artifact comments and events.
731    // But a label is a *replay*, not a new use: it shares whatever
732    // `${…}` text the payload/guard already resolved (typically the
733    // same captured arg, e.g. `name: search ${term}` next to
734    // `q: ${term}`), so it resolves from a scratch copy of the
735    // counter rewound to `label_fakes_start` — reproducing the
736    // payload's own fake values instead of consuming fresh
737    // occurrences that would then shift every later step's fakes by
738    // one. Restoring to a *fixed* end (wherever payload/guard left
739    // off) is only correct when the label is an exact mirror: a
740    // label with a `${fake:…}` the payload never had still consumes
741    // real occurrences during the replay, and blindly rewinding past
742    // them would hand those numbers back out to a later step —
743    // colliding with a value this label already displayed. So the
744    // real counter is restored to the *high-water mark* of the two —
745    // wherever payload/guard left off, or wherever the label's own
746    // replay reached, whichever is further — never below either.
747    // Mirrored references replay with no trace (unaffected, the
748    // common case); any extra reference the label consumes stays
749    // consumed and can never be reissued.
750    let functional_fakes_end = refs.fakes;
751    let label = match &macro_step.name {
752        Some(name) => {
753            refs.fakes = label_fakes_start;
754            let resolved = resolve_in(name, refs, sinks);
755            refs.fakes = functional_fakes_end.max(refs.fakes);
756            match resolved {
757                Some(resolved) => Some(resolved),
758                None => return,
759            }
760        }
761        None => None,
762    };
763    out.push(LoweredStep {
764        step: step_ref.clone(),
765        kind,
766        payload,
767        optional: macro_step.optional,
768        when,
769        label,
770        fragment,
771        save_as: macro_step.save_as.clone(),
772    });
773}
774
775/// Every string *value* in a structured payload resolved through `resolve`
776/// (`None` propagates a resolution failure — the caller already has diags).
777fn resolve_structured(
778    value: &serde_json::Value,
779    resolve: &mut dyn FnMut(&str) -> Option<String>,
780) -> Option<serde_json::Value> {
781    use serde_json::Value as J;
782    Some(match value {
783        J::String(text) => J::String(resolve(text)?),
784        J::Array(items) => J::Array(
785            items
786                .iter()
787                .map(|item| resolve_structured(item, resolve))
788                .collect::<Option<_>>()?,
789        ),
790        J::Object(map) => {
791            let mut out = serde_json::Map::new();
792            for (key, item) in map {
793                out.insert(key.clone(), resolve_structured(item, resolve)?);
794            }
795            J::Object(out)
796        }
797        other => other.clone(),
798    })
799}
800
801/// Inject `[Options] retry/retry-interval` after each entry's header block.
802///
803/// Textual by necessity (the core owns no hurl parser), safe by construction:
804/// the emitted artifact is parse-validated with the real parser, so a bad
805/// injection cannot survive to execution. An existing `[Options]` section is
806/// extended instead of duplicated (hurl rejects duplicate sections).
807fn bake_entry_options(
808    text: &str,
809    retry: Option<crate::step::Retry>,
810    delay_ms: Option<u64>,
811    bindings: &BTreeMap<String, String>,
812) -> String {
813    let mut option_lines: Vec<String> = Vec::new();
814    if let Some(retry) = retry {
815        option_lines.push(format!("retry: {}", retry.count));
816        option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
817    }
818    if let Some(delay_ms) = delay_ms {
819        option_lines.push(format!("delay: {delay_ms}ms"));
820    }
821    // Always quoted: an unquoted value that happens to read as a number or a
822    // boolean would be stored as one (`variable_value` tries those first), and
823    // `records` vs `2` vs `true` must not become three different types by
824    // accident. hurl evaluates the quoted form as a template, which is what
825    // lets a bound `${url:…}` containing `{{captured}}` finish resolving.
826    for (name, value) in bindings {
827        option_lines.push(format!("variable: {name}=\"{}\"", quote_option(value)));
828    }
829    // Nothing to inject: a `ref:` step whose bindings are all secrets, or whose
830    // variables all come from earlier captures, would otherwise pay the whole
831    // two-pass rewrite to rebuild an identical string — and gain an empty
832    // `[Options]` section for it. (The inline path guards at its call site; a
833    // `ref:` step cannot, since it does not know whether bindings survived the
834    // secret split.)
835    if option_lines.is_empty() {
836        return text.to_owned();
837    }
838    let retry_lines = option_lines.join("\n");
839    // Pre-scan: entries whose author already wrote an `[Options]` section only
840    // get that section extended — auto-injecting a fresh one as well would
841    // leave two `[Options]` sections in one entry, which hurl rejects. Slot 0
842    // is the preamble before the first method line.
843    let mut author_options = vec![false];
844    let mut in_fence = false;
845    for line in text.lines() {
846        let trimmed = line.trim();
847        if trimmed.starts_with("```") {
848            in_fence = !in_fence;
849            continue;
850        }
851        if in_fence {
852            continue;
853        }
854        if is_method_line(trimmed) {
855            author_options.push(false);
856        } else if trimmed == "[Options]"
857            && let Some(last) = author_options.last_mut()
858        {
859            *last = true;
860        }
861    }
862    let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
863    let mut out: Vec<String> = Vec::new();
864    let mut in_entry_head = false; // between a method line and its first section/body
865    let mut injected_current = false;
866    let mut in_fence = false; // inside a ```…``` body — no entry surgery there
867    let mut entry = 0usize;
868    for line in text.lines() {
869        let trimmed = line.trim();
870        if trimmed.starts_with("```") {
871            // A fence opening directly after the entry head is the body — the
872            // options section belongs immediately before it.
873            if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
874                out.push("[Options]".to_owned());
875                out.push(retry_lines.clone());
876                injected_current = true;
877            }
878            in_fence = !in_fence;
879            in_entry_head = false;
880            out.push(line.to_owned());
881            continue;
882        }
883        if in_fence {
884            out.push(line.to_owned());
885            continue;
886        }
887        if is_method_line(trimmed) {
888            in_entry_head = true;
889            injected_current = false;
890            entry += 1;
891            out.push(line.to_owned());
892            continue;
893        }
894        if trimmed == "[Options]" {
895            // Extend the author's own section (once — a second author section
896            // is the pack's own parse error, not ours to widen).
897            out.push(line.to_owned());
898            if !injected_current {
899                out.push(retry_lines.clone());
900                injected_current = true;
901            }
902            in_entry_head = false;
903            continue;
904        }
905        let is_header = in_entry_head && is_header_line(trimmed);
906        if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
907            out.push("[Options]".to_owned());
908            out.push(retry_lines.clone());
909            injected_current = true;
910            in_entry_head = false;
911        }
912        out.push(line.to_owned());
913    }
914    if in_entry_head && !injected_current {
915        out.push("[Options]".to_owned());
916        out.push(retry_lines.clone());
917    }
918    let mut result = out.join("\n");
919    if text.ends_with('\n') {
920        result.push('\n');
921    }
922    result
923}
924
925/// The Then-merge rule (ADR-0004): fold a status assert and/or raw assert
926/// fragment into the previous request entry — error when no request precedes
927/// (Then-before-When).
928/// Merge one `expect:` item into the previous request entry. Returns the
929/// host's `(kind, optional)` plus how many assert lines were appended —
930/// the caller anchors an authored-step row on them (§2.7 visibility).
931fn merge_expect(
932    status: Option<&str>,
933    fragment: Option<&str>,
934    out: &mut [LoweredStep],
935    sinks: &mut Sinks,
936    at: &impl Fn(Diag) -> Diag,
937) -> Option<(StepKindId, bool, usize)> {
938    let Some(previous) = out
939        .iter_mut()
940        .rev()
941        .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
942    else {
943        sinks.errors.push(
944            at(Diag::error(
945                "proef::lower::then_before_when",
946                "this assert-only step has no previous request entry to attach to",
947            ))
948            .with_help("a Then step asserts on the request made by an earlier When step"),
949        );
950        return None;
951    };
952
953    if let Some(status) = status
954        && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
955    {
956        sinks.errors.push(at(Diag::error(
957            "proef::lower::bad_status",
958            format!("expected an HTTP status number, got `{status}`"),
959        )));
960        return None;
961    }
962
963    let host_kind = previous.kind.clone();
964    let host_optional = previous.optional;
965    let StepPayload::HurlEntries(text) = &mut previous.payload else {
966        return None;
967    };
968    // Ensure the *last* entry has a response section, then an [Asserts]
969    // section, then append the asserts. (The emitter parse-validates the
970    // result.) The scan is scoped to the last entry with fences skipped — an
971    // earlier entry's response, or a fenced body line starting with `HTTP`,
972    // must not satisfy it (ADR-0004: merge into the previous entry, singular).
973    let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
974    if !tail_has_http {
975        push_line(text, "HTTP *");
976    }
977    if !tail_has_asserts {
978        push_line(text, "[Asserts]");
979    }
980    let mut appended = 0usize;
981    if let Some(status) = status {
982        push_line(text, &format!("status == {status}"));
983        appended += 1;
984    }
985    if let Some(fragment) = fragment {
986        for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
987            push_line(text, line.trim_end());
988            appended += 1;
989        }
990    }
991    Some((host_kind, host_optional, appended))
992}
993
994/// Is this a `Name: value` HTTP header line per hurl's grammar? The name must
995/// be a non-empty run of token characters before the colon — an XML/JSON/text
996/// body line (`<root xmlns:x=…`, `{"a": 1}`, prose) never qualifies, so the
997/// `[Options]` injection can never land inside a body.
998fn is_header_line(trimmed: &str) -> bool {
999    let Some((name, _)) = trimmed.split_once(':') else {
1000        return false;
1001    };
1002    !name.is_empty()
1003        && name != "HTTP"
1004        && name
1005            .chars()
1006            .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
1007}
1008
1009/// Is this trimmed line an entry-opening method line (`GET http://…`)? Custom
1010/// methods are any ≥ 3-char run of ASCII uppercase / `-` — except `HTTP`,
1011/// which opens a response.
1012///
1013/// Shared with the emitter's capture scan (`emit::capture_names`) — one
1014/// canonical method recogniser, not a duplicate.
1015pub(crate) fn is_method_line(trimmed: &str) -> bool {
1016    trimmed.split_whitespace().next().is_some_and(|word| {
1017        word.len() >= 3
1018            && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
1019            && word != "HTTP"
1020    }) && trimmed.split_whitespace().count() >= 2
1021}
1022
1023/// Does the *last* entry of `text` have a response (`HTTP …`) line, and an
1024/// `[Asserts]` section? Flags reset at every entry-opening method line, and
1025/// fenced (```…```) bodies are skipped, so the end state describes the last
1026/// entry alone.
1027fn last_entry_scan(text: &str) -> (bool, bool) {
1028    let mut in_fence = false;
1029    let (mut has_http, mut has_asserts) = (false, false);
1030    for line in text.lines() {
1031        let trimmed = line.trim();
1032        if trimmed.starts_with("```") {
1033            in_fence = !in_fence;
1034            continue;
1035        }
1036        if in_fence {
1037            continue;
1038        }
1039        if is_method_line(trimmed) {
1040            (has_http, has_asserts) = (false, false);
1041            continue;
1042        }
1043        has_http = has_http || trimmed.starts_with("HTTP");
1044        has_asserts = has_asserts || trimmed == "[Asserts]";
1045    }
1046    (has_http, has_asserts)
1047}
1048
1049fn push_line(text: &mut String, line: &str) {
1050    if !text.is_empty() && !text.ends_with('\n') {
1051        text.push('\n');
1052    }
1053    text.push_str(line);
1054    text.push('\n');
1055}
1056
1057/// Maximal segmentation: contiguous same-engine steps share a batch; a batch
1058/// breaks at engine changes and around `optional:` steps (each optional step
1059/// is a singleton batch so its failure warns without poisoning neighbors).
1060fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
1061    let mut batches: Vec<StepBatch> = Vec::new();
1062    for step in steps {
1063        // Unreachable behind lower()'s kind-routing guard; kept total (the
1064        // kind doubles as the engine id) so core stays panic-free if a future
1065        // caller skips the guard.
1066        let engine = kind_to_engine
1067            .get(step.kind.as_str())
1068            .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
1069        // A merged-asserts step never opens a batch: its asserts live inside
1070        // the previous step's entry, so it must ride in the same dispatch.
1071        let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
1072        let start_new = match batches.last() {
1073            None => true,
1074            Some(last) => {
1075                !glued
1076                    && (last.engine.as_str() != engine
1077                        || step.optional
1078                        || last.steps.last().is_some_and(|s| s.optional))
1079            }
1080        };
1081        if start_new {
1082            batches.push(StepBatch {
1083                index: batches.len(),
1084                engine: crate::engine::EngineId::from(engine.as_str()),
1085                steps: vec![step],
1086            });
1087        } else if let Some(last) = batches.last_mut() {
1088            last.steps.push(step);
1089        }
1090    }
1091    batches
1092}
1093
1094fn push_warnings(sinks: &mut Sinks, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
1095    for text in texts {
1096        sinks.warnings.push(
1097            Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
1098                .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
1099        );
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    #![allow(clippy::unwrap_used, clippy::expect_used)]
1106
1107    use super::*;
1108    use crate::engine::StepKindSpec;
1109    use crate::pack::{self, PackSource};
1110    use crate::step::StepPayload;
1111
1112    const KINDS: &[StepKindSpec] = &[StepKindSpec {
1113        prefix: "hurl",
1114        schema: "true",
1115        validate: None,
1116        fragments: None,
1117    }];
1118
1119    const PACK: &str = r#"macros:
1120  auth:
1121    params: [token]
1122    steps:
1123      - name: authenticate
1124        hurl: |
1125          POST ${url:base}/auth
1126          Authorization: Bearer ${token}
1127          HTTP 200
1128  search:
1129    params: [term]
1130    match: "I search for {term}"
1131    steps:
1132      - use: auth
1133        with: { token: "${secret:apiToken}" }
1134      - name: run the search
1135        hurl: |
1136          GET ${url:base}/search?q=${term}
1137          HTTP 200
1138          [Captures]
1139          recordId: jsonpath "$[0].id"
1140  checkHealth:
1141    match: the service is healthy
1142    steps:
1143      - optional: true
1144        hurl: |
1145          GET ${url:base}/health
1146  expectStatus:
1147    params: [status]
1148    match: "the response status is {status}"
1149    expect:
1150      - status: "${status}"
1151"#;
1152
1153    fn fixture() -> (
1154        crate::feature::FeatureFile,
1155        crate::bind::BoundScenario,
1156        PackSet,
1157    ) {
1158        let packs = pack::load(
1159            &[PackSource {
1160                name: "test.yaml".into(),
1161                text: Arc::from(PACK),
1162            }],
1163            &crate::pack::FragmentCorpus::empty(),
1164            KINDS,
1165        )
1166        .unwrap();
1167        let feature = crate::feature::parse(
1168            "t.feature",
1169            "Feature: F\n  Scenario: S\n    Given the service is healthy\n    When I search for \"Jansen\"\n    Then the response status is 200\n",
1170        )
1171        .unwrap();
1172        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1173        (feature, scenario, packs)
1174    }
1175
1176    fn ctx<'a>(
1177        feature: &'a crate::feature::FeatureFile,
1178        packs: &'a PackSet,
1179        kind_to_engine: &'a BTreeMap<String, String>,
1180        env: &'a BTreeMap<String, String>,
1181        config_vars: &'a BTreeMap<String, String>,
1182        world: &'a World,
1183    ) -> LowerCtx<'a> {
1184        LowerCtx {
1185            feature,
1186            packs,
1187            kind_to_engine,
1188            env,
1189            config_vars,
1190            run_id: "run-0001",
1191            world,
1192            mode: ResolveMode::DryRun,
1193        }
1194    }
1195
1196    #[test]
1197    fn expansion_resolution_merge_and_segmentation_work_together() {
1198        let (feature, scenario, packs) = fixture();
1199        let kind_to_engine: BTreeMap<String, String> =
1200            [("hurl".to_owned(), "hurl".to_owned())].into();
1201        let env = BTreeMap::new();
1202        let config_vars =
1203            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1204        let world = World::default();
1205        let lowered = lower(
1206            &scenario,
1207            &ctx(
1208                &feature,
1209                &packs,
1210                &kind_to_engine,
1211                &env,
1212                &config_vars,
1213                &world,
1214            ),
1215        )
1216        .unwrap();
1217
1218        // Optional health check is a singleton batch; auth + search batch
1219        // together, and the authored `Then` rides along as a visible
1220        // merged-asserts step (§2.7) glued to its host.
1221        assert_eq!(lowered.batches.len(), 2);
1222        assert_eq!(lowered.batches[0].steps.len(), 1);
1223        assert!(lowered.batches[0].steps[0].optional);
1224        assert_eq!(lowered.batches[1].steps.len(), 3);
1225        let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
1226            panic!("expected a merged-asserts step for the Then line");
1227        };
1228        assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
1229
1230        // use:/with: expansion resolved the parent's secret reference.
1231        let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
1232            panic!("expected hurl entries");
1233        };
1234        assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
1235        assert!(
1236            auth.contains("Bearer {{apiToken}}"),
1237            "secret placeholder: {auth}"
1238        );
1239        assert!(lowered.secrets.contains_key("apiToken"));
1240
1241        // The expect macro merged `status == 200` into the *search* entry.
1242        let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
1243            panic!("expected hurl entries");
1244        };
1245        assert!(
1246            search.contains("GET http://fixture.local/search?q=Jansen"),
1247            "{search}"
1248        );
1249        assert!(search.contains("[Asserts]"), "{search}");
1250        assert!(search.trim_end().ends_with("status == 200"), "{search}");
1251
1252        // Anchors point at the feature lines.
1253        assert_eq!(lowered.batches[1].steps[1].step.line, 4);
1254        assert_eq!(
1255            lowered.batches[1].steps[0].label.as_deref(),
1256            Some("authenticate")
1257        );
1258    }
1259
1260    #[test]
1261    fn then_before_when_is_an_error() {
1262        let (_, _, packs) = fixture();
1263        let feature = crate::feature::parse(
1264            "t.feature",
1265            "Feature: F\n  Scenario: S\n    Then the response status is 200\n",
1266        )
1267        .unwrap();
1268        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1269        let kind_to_engine = BTreeMap::new();
1270        let env = BTreeMap::new();
1271        let config_vars = BTreeMap::new();
1272        let world = World::default();
1273        let errs = lower(
1274            &scenario,
1275            &ctx(
1276                &feature,
1277                &packs,
1278                &kind_to_engine,
1279                &env,
1280                &config_vars,
1281                &world,
1282            ),
1283        )
1284        .unwrap_err();
1285        assert_eq!(errs[0].code, "proef::lower::then_before_when");
1286    }
1287
1288    /// Pack validation only sees the *unresolved* pack text (`item.hurl`),
1289    /// which is non-blank here (`"${vars:blank}"`) — the emptiness only
1290    /// appears once `${vars:key}` resolves against a `proef.toml` value that
1291    /// happens to be `""` (a legitimate, env-conditional authoring pattern:
1292    /// extra asserts present in some environments, none in others). That
1293    /// still lowers to a zero-line `MergedAsserts` step, and the sidecar
1294    /// emitter must never turn a zero-line step into an inverted
1295    /// `.map.json` span (ADR-0010: emitted artifacts are the normative
1296    /// contract).
1297    #[test]
1298    fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
1299        const PACK: &str = r#"macros:
1300  ping:
1301    match: the service is pinged
1302    steps:
1303      - hurl: |
1304          GET ${url:base}/ping
1305          HTTP 200
1306  expectBlank:
1307    match: nothing extra is asserted
1308    expect:
1309      - hurl: "${vars:blank}"
1310"#;
1311        let packs = pack::load(
1312            &[PackSource {
1313                name: "test.yaml".into(),
1314                text: Arc::from(PACK),
1315            }],
1316            &crate::pack::FragmentCorpus::empty(),
1317            KINDS,
1318        )
1319        .unwrap();
1320        let feature = crate::feature::parse(
1321            "t.feature",
1322            "Feature: F\n  Scenario: S\n    Given the service is pinged\n    Then nothing extra is asserted\n",
1323        )
1324        .unwrap();
1325        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1326        let kind_to_engine: BTreeMap<String, String> =
1327            [("hurl".to_owned(), "hurl".to_owned())].into();
1328        let env = BTreeMap::new();
1329        let config_vars = BTreeMap::from([
1330            ("url:base".to_owned(), "http://fixture.local".to_owned()),
1331            ("vars:blank".to_owned(), String::new()),
1332        ]);
1333        let world = World::default();
1334        let lowered = lower(
1335            &scenario,
1336            &ctx(
1337                &feature,
1338                &packs,
1339                &kind_to_engine,
1340                &env,
1341                &config_vars,
1342                &world,
1343            ),
1344        )
1345        .unwrap();
1346
1347        assert_eq!(lowered.batches[0].steps.len(), 2);
1348        let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
1349            panic!("expected a merged-asserts step for the Then line");
1350        };
1351        assert_eq!(lines, 0, "the fragment resolved to nothing");
1352
1353        let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
1354        for entry in &artifact.map.entries {
1355            let [start, end] = entry.hurl_lines;
1356            assert!(
1357                start <= end,
1358                "inverted span for a zero-line merge: {start}..{end}"
1359            );
1360        }
1361    }
1362
1363    /// `${…}` resolves inside structured payload string values,
1364    /// recursively — keys stay literal (they are schema, not data).
1365    #[test]
1366    fn structured_payloads_resolve_placeholders_recursively() {
1367        const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
1368            prefix: "alt",
1369            schema: "true",
1370            validate: None,
1371            fragments: None,
1372        }];
1373        let packs = pack::load(
1374            &[PackSource {
1375                name: "alt.yaml".into(),
1376                text: Arc::from(
1377                    "macros:\n  probe:\n    match: the alternate step runs\n    steps:\n      - name: probe\n        alt:\n          target: \"${url:base}/item\"\n          checks: [\"${url:base}\", 7]\n",
1378                ),
1379            }],
1380            &crate::pack::FragmentCorpus::empty(),
1381            ALT_KINDS,
1382        )
1383        .unwrap();
1384        let feature = crate::feature::parse(
1385            "t.feature",
1386            "Feature: F\n  Scenario: S\n    When the alternate step runs\n",
1387        )
1388        .unwrap();
1389        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1390        let kind_to_engine: BTreeMap<String, String> =
1391            [("alt".to_owned(), "alt".to_owned())].into();
1392        let env = BTreeMap::new();
1393        let config_vars =
1394            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1395        let world = World::default();
1396        let lowered = lower(
1397            &scenario,
1398            &ctx(
1399                &feature,
1400                &packs,
1401                &kind_to_engine,
1402                &env,
1403                &config_vars,
1404                &world,
1405            ),
1406        )
1407        .unwrap();
1408        let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
1409            panic!("structured payload expected");
1410        };
1411        assert_eq!(value["target"], "http://fixture.local/item");
1412        assert_eq!(value["checks"][0], "http://fixture.local");
1413        assert_eq!(value["checks"][1], 7);
1414    }
1415
1416    /// The Then-merge scans only the *last* entry: an earlier entry's
1417    /// `HTTP`/`[Asserts]` must not satisfy the check (ADR-0004 — merge into
1418    /// the previous entry, singular).
1419    #[test]
1420    fn expect_merge_scopes_to_the_last_entry() {
1421        let packs = pack::load(
1422            &[PackSource {
1423                name: "multi.yaml".into(),
1424                text: Arc::from(
1425                    "macros:\n  pair:\n    match: both calls run\n    steps:\n      - hurl: |\n          GET http://x/a\n          HTTP 200\n          [Asserts]\n          status == 200\n          GET http://x/b\n  expectStatus:\n    params: [status]\n    match: \"the response status is {status}\"\n    expect:\n      - status: \"${status}\"\n",
1426                ),
1427            }],
1428            &crate::pack::FragmentCorpus::empty(),
1429            KINDS,
1430        )
1431        .unwrap();
1432        let feature = crate::feature::parse(
1433            "t.feature",
1434            "Feature: F\n  Scenario: S\n    When both calls run\n    Then the response status is 201\n",
1435        )
1436        .unwrap();
1437        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1438        let kind_to_engine: BTreeMap<String, String> =
1439            [("hurl".to_owned(), "hurl".to_owned())].into();
1440        let env = BTreeMap::new();
1441        let config_vars = BTreeMap::new();
1442        let world = World::default();
1443        let lowered = lower(
1444            &scenario,
1445            &ctx(
1446                &feature,
1447                &packs,
1448                &kind_to_engine,
1449                &env,
1450                &config_vars,
1451                &world,
1452            ),
1453        )
1454        .unwrap();
1455        let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
1456            panic!("expected hurl entries");
1457        };
1458        // The second entry had no response: the merge must open its own
1459        // `HTTP *` + `[Asserts]` after `GET http://x/b` instead of riding on
1460        // the first entry's.
1461        let tail = text.split("GET http://x/b").nth(1).unwrap();
1462        assert!(tail.contains("HTTP *"), "{text}");
1463        assert!(tail.contains("[Asserts]"), "{text}");
1464        assert!(tail.contains("status == 201"), "{text}");
1465    }
1466
1467    /// An author `[Options]` section placed after another section is extended
1468    /// in place — never paired with a second, injected `[Options]`, which
1469    /// hurl rejects as a duplicate section.
1470    #[test]
1471    fn baked_options_extend_a_late_author_options_section() {
1472        let retry = Some(crate::step::Retry {
1473            count: 2,
1474            interval_ms: 100,
1475        });
1476        let body =
1477            "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
1478        let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1479        assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
1480        assert!(
1481            baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
1482            "{baked}"
1483        );
1484    }
1485
1486    /// The `[Options]` injection must never land inside a body: fenced text,
1487    /// XML (colon-bearing first line), and JSON bodies all stay untouched.
1488    #[test]
1489    fn baked_options_never_enter_bodies() {
1490        let retry = Some(crate::step::Retry {
1491            count: 2,
1492            interval_ms: 100,
1493        });
1494        for body in [
1495            "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
1496            "POST http://x/a\n<root xmlns:x=\"urn:example\">\n  <child>hi</child>\n</root>\nHTTP 200\n",
1497            "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
1498        ] {
1499            let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1500            assert_eq!(
1501                baked.matches("[Options]").count(),
1502                1,
1503                "exactly one options block in:\n{baked}"
1504            );
1505            let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
1506            let body_at = baked
1507                .find("```")
1508                .or_else(|| baked.find('<'))
1509                .or_else(|| baked.find('{'))
1510                .unwrap_or(0);
1511            assert!(options_at < body_at, "options precede the body:\n{baked}");
1512        }
1513    }
1514
1515    #[test]
1516    fn engine_change_splits_batches() {
1517        let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1518            .iter()
1519            .map(|kind| LoweredStep {
1520                step: StepRef {
1521                    file: Arc::from("f"),
1522                    line: 1,
1523                    text: Arc::from("t"),
1524                },
1525                kind: StepKindId::from(*kind),
1526                payload: StepPayload::HurlEntries(String::new()),
1527                optional: false,
1528                when: None,
1529                label: None,
1530                fragment: None,
1531                save_as: BTreeMap::new(),
1532            })
1533            .collect();
1534        let mapping: BTreeMap<String, String> = [
1535            ("hurl".to_owned(), "hurl".to_owned()),
1536            ("alt".to_owned(), "alt".to_owned()),
1537        ]
1538        .into();
1539        let batches = segment(steps, &mapping);
1540        let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1541        assert_eq!(sizes, vec![2, 1, 1]);
1542        assert_eq!(batches[1].engine.as_str(), "alt");
1543        // Scenario-wide ordinals — the sidecar `batch` key engines filter by.
1544        let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1545        assert_eq!(indexes, vec![0, 1, 2]);
1546    }
1547
1548    /// A step's label replays the payload's `${fake:…}` values (the same
1549    /// occurrence) instead of minting a fresh one — so the artifact comment
1550    /// never names data the request didn't actually send — and that replay
1551    /// must leave no trace on the running counter: a later step's own fake
1552    /// still lands on the very next occurrence, not one the label
1553    /// incidentally borrowed.
1554    #[test]
1555    fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
1556        const FAKE_PACK: &str = r#"macros:
1557  searchFor:
1558    params: [term]
1559    match: "the operator searches for {term}"
1560    steps:
1561      - name: "search for ${term}"
1562        hurl: |
1563          GET ${url:base}/search
1564          [Query]
1565          q: ${term}
1566          HTTP 200
1567  pingFake:
1568    match: a fresh fake is requested
1569    steps:
1570      - hurl: |
1571          GET ${url:base}/ping
1572          [Query]
1573          v: ${fake:lastName}
1574          HTTP 200
1575"#;
1576        let packs = pack::load(
1577            &[PackSource {
1578                name: "fakes.yaml".into(),
1579                text: Arc::from(FAKE_PACK),
1580            }],
1581            &crate::pack::FragmentCorpus::empty(),
1582            KINDS,
1583        )
1584        .unwrap();
1585        let feature = crate::feature::parse(
1586            "t.feature",
1587            "Feature: F\n  Scenario: S\n    When the operator searches for ${fake:lastName}\n    Then a fresh fake is requested\n",
1588        )
1589        .unwrap();
1590        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1591        let kind_to_engine: BTreeMap<String, String> =
1592            [("hurl".to_owned(), "hurl".to_owned())].into();
1593        let env = BTreeMap::new();
1594        let config_vars =
1595            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1596        let world = World::default();
1597        let lowered = lower(
1598            &scenario,
1599            &ctx(
1600                &feature,
1601                &packs,
1602                &kind_to_engine,
1603                &env,
1604                &config_vars,
1605                &world,
1606            ),
1607        )
1608        .unwrap();
1609
1610        // Both steps share one hurl batch (no optional/engine boundary).
1611        assert_eq!(lowered.batches[0].steps.len(), 2);
1612        let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
1613            panic!("expected hurl entries");
1614        };
1615        let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1616
1617        // The payload's request resolves the scenario's first ${fake:…} —
1618        // occurrence 0 — and the label mirrors that exact value.
1619        let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1620        assert!(
1621            search.contains(&format!("q: {occurrence_0}")),
1622            "payload: {search}"
1623        );
1624        assert!(label.contains(&occurrence_0), "label: {label}");
1625
1626        // The second step's own fake continues from occurrence 1 — proof
1627        // the label's replay above did not silently consume it.
1628        let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1629            panic!("expected hurl entries");
1630        };
1631        let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1632        assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
1633    }
1634
1635    /// The mirrored-replay above only covers a label that is an *exact*
1636    /// mirror of its payload. A label with *more* `${fake:…}` references
1637    /// than its payload still consumes real occurrences during its replay —
1638    /// blindly rewinding the counter back to wherever the payload/guard left
1639    /// off would hand those consumed occurrences back out to a later step,
1640    /// which would then display a value the label already showed.
1641    #[test]
1642    fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
1643        const FAKE_PACK: &str = r#"macros:
1644  unmirroredLabel:
1645    match: a label mentions more fakes than its payload
1646    steps:
1647      - name: "${fake:lastName} vs ${fake:lastName}"
1648        hurl: |
1649          GET ${url:base}/probe
1650          [Query]
1651          q: ${fake:lastName}
1652          HTTP 200
1653  pingFake:
1654    match: a fresh fake is requested
1655    steps:
1656      - hurl: |
1657          GET ${url:base}/ping
1658          [Query]
1659          v: ${fake:lastName}
1660          HTTP 200
1661"#;
1662        let packs = pack::load(
1663            &[PackSource {
1664                name: "unmirrored.yaml".into(),
1665                text: Arc::from(FAKE_PACK),
1666            }],
1667            &crate::pack::FragmentCorpus::empty(),
1668            KINDS,
1669        )
1670        .unwrap();
1671        let feature = crate::feature::parse(
1672            "t.feature",
1673            "Feature: F\n  Scenario: S\n    When a label mentions more fakes than its payload\n    Then a fresh fake is requested\n",
1674        )
1675        .unwrap();
1676        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1677        let kind_to_engine: BTreeMap<String, String> =
1678            [("hurl".to_owned(), "hurl".to_owned())].into();
1679        let env = BTreeMap::new();
1680        let config_vars =
1681            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1682        let world = World::default();
1683        let lowered = lower(
1684            &scenario,
1685            &ctx(
1686                &feature,
1687                &packs,
1688                &kind_to_engine,
1689                &env,
1690                &config_vars,
1691                &world,
1692            ),
1693        )
1694        .unwrap();
1695
1696        // Both steps share one hurl batch.
1697        assert_eq!(lowered.batches[0].steps.len(), 2);
1698        let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1699        let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1700            panic!("expected hurl entries");
1701        };
1702
1703        // The label's payload uses occurrence 0; the label itself has a
1704        // *second* `${fake:lastName}` the payload never had, which must
1705        // consume occurrence 1 for real.
1706        let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1707        let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1708        assert!(label.contains(&occurrence_0), "label: {label}");
1709        assert!(label.contains(&occurrence_1), "label: {label}");
1710
1711        // The next step's own, independent fake must continue *past* what
1712        // the label already consumed — occurrence 2 — never occurrence 1,
1713        // which the label already displayed.
1714        let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
1715        assert!(
1716            !ping.contains(&format!("v: {occurrence_1}")),
1717            "the next step's fake reused an occurrence the label already \
1718             displayed: {ping}"
1719        );
1720        assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
1721    }
1722
1723    // -----------------------------------------------------------------------
1724    // Fragments (ADR-0018)
1725    // -----------------------------------------------------------------------
1726
1727    /// Stands in for an engine's scanner (core cannot depend on one). `@name`
1728    /// opens a fragment, `?var` is a read, `!var` a capture, `=var` a variable
1729    /// the fragment supplies itself. The `Result` is never `Err` here but the
1730    /// seam's signature requires one.
1731    #[allow(clippy::unnecessary_wraps)]
1732    fn frag_scan(
1733        text: &str,
1734    ) -> Result<Vec<crate::engine::ScannedFragment>, crate::engine::FragmentScanError> {
1735        let mut out: Vec<crate::engine::ScannedFragment> = Vec::new();
1736        for (index, line) in text.lines().enumerate() {
1737            let line = line.trim();
1738            if let Some(name) = line.strip_prefix('@') {
1739                out.push(crate::engine::ScannedFragment {
1740                    name: name.to_owned(),
1741                    text: format!("GET http://x/{name}\nHTTP 200\n"),
1742                    line: index + 1,
1743                    placeholders: Vec::new(),
1744                    declared_options: Vec::new(),
1745                    supplied_variables: Vec::new(),
1746                });
1747            } else if let Some(last) = out.last_mut() {
1748                if let Some(read) = line.strip_prefix('?') {
1749                    last.placeholders.push(read.to_owned());
1750                } else if let Some(supplied) = line.strip_prefix('=') {
1751                    // Honest text, as with captures — and `[Options]` belongs to
1752                    // the *request* half, so it is inserted before the status
1753                    // line rather than appended. `bake_entry_options` slots
1754                    // proef's own lines against this header, so putting it in
1755                    // the wrong half would test a shape hurl cannot parse.
1756                    let head = last.text.find("HTTP ").unwrap_or(last.text.len());
1757                    let section = if last.text[..head].contains("[Options]") {
1758                        format!("variable: {supplied}=from-fragment\n")
1759                    } else {
1760                        format!("[Options]\nvariable: {supplied}=from-fragment\n")
1761                    };
1762                    last.text.insert_str(head, &section);
1763                    last.supplied_variables.push(supplied.to_owned());
1764                } else if let Some(write) = line.strip_prefix('!') {
1765                    // A real scanner reads captures out of the entry text, and
1766                    // so does the core (`emit::capture_names`) — one mechanism,
1767                    // so keep the stand-in's text honest.
1768                    if !last.text.contains("[Captures]") {
1769                        last.text.push_str("[Captures]\n");
1770                    }
1771                    last.text.push_str(write);
1772                    last.text.push_str(": jsonpath \"$.id\"\n");
1773                }
1774            }
1775        }
1776        Ok(out)
1777    }
1778
1779    const FRAG_KINDS: &[StepKindSpec] = &[StepKindSpec {
1780        prefix: "hurl",
1781        schema: "true",
1782        validate: None,
1783        fragments: Some(crate::engine::FragmentSupport {
1784            ext: "frag",
1785            scan: frag_scan,
1786        }),
1787    }];
1788
1789    /// Lower a one-step scenario over the given pack and fragment file.
1790    fn lower_fragments(pack: &str, fragments: &str) -> Result<LoweredScenario, Vec<Diag>> {
1791        let packs = pack::load(
1792            &[PackSource {
1793                name: "p.yaml".into(),
1794                text: Arc::from(pack),
1795            }],
1796            &pack::FragmentCorpus::new(
1797                vec![PackSource {
1798                    name: "api.frag".into(),
1799                    text: Arc::from(fragments),
1800                }],
1801                FRAG_KINDS,
1802            ),
1803            FRAG_KINDS,
1804        )
1805        .unwrap_or_else(|err| panic!("pack should load: {err:?}"));
1806        let feature =
1807            crate::feature::parse("t.feature", "Feature: F\n  Scenario: S\n    When it runs\n")
1808                .unwrap();
1809        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1810        let kind_to_engine = BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]);
1811        let env = BTreeMap::new();
1812        let config_vars = BTreeMap::from([("url:base".to_owned(), "http://api".to_owned())]);
1813        let world = World::new(crate::world::GlobalStore::default());
1814        let ctx = ctx(
1815            &feature,
1816            &packs,
1817            &kind_to_engine,
1818            &env,
1819            &config_vars,
1820            &world,
1821        );
1822        lower(&scenario, &ctx)
1823    }
1824
1825    fn only_entry(lowered: &LoweredScenario) -> &str {
1826        let step = lowered
1827            .batches
1828            .iter()
1829            .flat_map(|b| b.steps.iter())
1830            .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
1831            .expect("one hurl entry");
1832        let StepPayload::HurlEntries(text) = &step.payload else {
1833            unreachable!()
1834        };
1835        text
1836    }
1837
1838    /// The three scopes cascade, most specific winning, and every literal lands
1839    /// as a per-entry `[Options] variable:` so the artifact replays identically
1840    /// under the stock CLI.
1841    #[test]
1842    fn bindings_cascade_and_are_injected_as_entry_options() {
1843        let lowered = lower_fragments(
1844            "bind:\n  base: ${url:base}\n  who: pack\nmacros:\n  m:\n    match: it runs\n    bind:\n      who: macro\n      extra: yes\n    steps:\n      - ref: f\n        bind:\n          who: step\n",
1845            "@f\n?base\n?who\n?extra\n",
1846        )
1847        .expect("lowers");
1848        let text = only_entry(&lowered);
1849        assert!(text.contains("[Options]"), "{text}");
1850        assert!(text.contains(r#"variable: base="http://api""#), "{text}");
1851        assert!(
1852            text.contains(r#"variable: who="step""#),
1853            "step scope wins: {text}"
1854        );
1855        assert!(!text.contains(r#"who="macro""#) && !text.contains(r#"who="pack""#));
1856        assert!(text.contains(r#"variable: extra="yes""#), "{text}");
1857    }
1858
1859    /// A secret binding never reaches the artifact; it is recorded as
1860    /// variable→secret so the engine injects it at run time (ADR-0005), which
1861    /// is what lets a fragment keep the variable name its corpus already uses.
1862    #[test]
1863    fn a_secret_binding_is_renamed_not_written() {
1864        let lowered = lower_fragments(
1865            "macros:\n  m:\n    match: it runs\n    bind:\n      auth_token: ${secret:apiToken}\n    steps:\n      - ref: f\n",
1866            "@f\n?auth_token\n",
1867        )
1868        .expect("lowers");
1869        let text = only_entry(&lowered);
1870        assert!(
1871            !text.contains("auth_token=") && !text.contains("apiToken"),
1872            "no secret may reach the artifact: {text}"
1873        );
1874        assert_eq!(
1875            lowered.secrets.get("auth_token").map(String::as_str),
1876            Some("apiToken")
1877        );
1878    }
1879
1880    #[test]
1881    fn a_secret_mixed_into_a_larger_value_is_refused() {
1882        let diags = lower_fragments(
1883            "macros:\n  m:\n    match: it runs\n    bind:\n      auth: \"Bearer ${secret:apiToken}\"\n    steps:\n      - ref: f\n",
1884            "@f\n?auth\n",
1885        )
1886        .expect_err("should refuse");
1887        assert!(
1888            diags
1889                .iter()
1890                .any(|d| d.code == "proef::lower::secret_in_composite_bind"),
1891            "{diags:?}"
1892        );
1893    }
1894
1895    /// `$${` is the escape (ADR-0005), so `$${secret:x}` is the literal text
1896    /// `${secret:x}` and names no secret at all. Reading the value with the
1897    /// resolver's own scanner is what keeps that true here — a second scanner
1898    /// that merely searched for `"${secret:"` refused this as a composite.
1899    #[test]
1900    fn an_escaped_secret_reference_is_a_literal_not_a_secret() {
1901        let lowered = lower_fragments(
1902            "macros:\n  m:\n    match: it runs\n    bind:\n      hint: $${secret:apiToken}\n    steps:\n      - ref: f\n",
1903            "@f\n?hint\n",
1904        )
1905        .expect("an escaped reference is ordinary text");
1906        assert!(
1907            lowered.secrets.is_empty(),
1908            "nothing was bound to a secret: {:?}",
1909            lowered.secrets
1910        );
1911        assert!(
1912            only_entry(&lowered).contains(r#"variable: hint="${secret:apiToken}""#),
1913            "the literal is injected verbatim: {}",
1914            only_entry(&lowered)
1915        );
1916    }
1917
1918    /// A fragment that answers its own question needs no `bind:`. This is what
1919    /// keeps the file runnable under stock `hurl` with no variables file —
1920    /// ADR-0018's whole premise — so treating a self-supplied variable as
1921    /// unsupplied would refuse valid input that hurl itself accepts.
1922    #[test]
1923    fn a_variable_the_fragment_supplies_itself_needs_no_binding() {
1924        let lowered = lower_fragments(
1925            "macros:\n  m:\n    match: it runs\n    steps:\n      - ref: first\n",
1926            "@first\n=token\n?token\n",
1927        )
1928        .expect("a fragment that supplies its own variable lowers");
1929        let text = lowered
1930            .batches
1931            .iter()
1932            .flat_map(|b| b.steps.iter())
1933            .find_map(|s| match &s.payload {
1934                StepPayload::HurlEntries(text) => Some(text.clone()),
1935                _ => None,
1936            })
1937            .expect("hurl entries");
1938        // The fragment's own line is the only one: proef must not also inject a
1939        // `variable: token=`, or the pair would resolve last-wins in the dark.
1940        assert_eq!(
1941            text.matches("variable: token=").count(),
1942            1,
1943            "exactly one supplier reaches the entry: {text}"
1944        );
1945    }
1946
1947    /// hurl's `[Options] variable:` assigns into one shared set rather than
1948    /// scoping, so an unbound name would silently inherit whatever an earlier
1949    /// entry left behind — green, against the wrong value.
1950    #[test]
1951    fn a_placeholder_nothing_supplies_is_refused() {
1952        let diags = lower_fragments(
1953            "macros:\n  m:\n    match: it runs\n    steps:\n      - ref: f\n",
1954            "@f\n?missingOne\n",
1955        )
1956        .expect_err("should refuse");
1957        let diag = diags
1958            .iter()
1959            .find(|d| d.code == "proef::lower::unbound_placeholder")
1960            .unwrap_or_else(|| panic!("expected unbound_placeholder in {diags:?}"));
1961        assert!(diag.message.contains("missingOne"), "{}", diag.message);
1962        assert!(diag.help.is_some());
1963    }
1964
1965    /// Scope decides *when* a binding resolves, and that is observable through
1966    /// `${fake:…}`. One macro-scope binding is one value for every step of the
1967    /// macro — the shared identity a register-then-verify flow needs — while two
1968    /// separate bindings stay two values. The correctness series made repeated
1969    /// `${fake:…}` *occurrences* distinct; this keeps that true without making a
1970    /// single binding mean two different things.
1971    #[test]
1972    fn one_binding_is_one_value_across_a_macros_steps() {
1973        let lowered = lower_fragments(
1974            "macros:\n  m:\n    match: it runs\n    bind:\n      shared: ${fake:email}\n    steps:\n      - ref: first\n        bind:\n          own: ${fake:email}\n      - ref: second\n        bind:\n          own: ${fake:email}\n",
1975            "@first\n?shared\n?own\n@second\n?shared\n?own\n",
1976        )
1977        .expect("lowers");
1978        let entries: Vec<&str> = lowered
1979            .batches
1980            .iter()
1981            .flat_map(|b| b.steps.iter())
1982            .filter_map(|s| match &s.payload {
1983                StepPayload::HurlEntries(text) => Some(text.as_str()),
1984                _ => None,
1985            })
1986            .collect();
1987        assert_eq!(entries.len(), 2);
1988        let shared = |text: &str| {
1989            text.lines()
1990                .find(|l| l.starts_with("variable: shared="))
1991                .expect("shared binding")
1992                .to_owned()
1993        };
1994        let own = |text: &str| {
1995            text.lines()
1996                .find(|l| l.starts_with("variable: own="))
1997                .expect("own binding")
1998                .to_owned()
1999        };
2000        assert_eq!(
2001            shared(entries[0]),
2002            shared(entries[1]),
2003            "one macro-scope binding is one value for the whole macro"
2004        );
2005        assert_ne!(
2006            own(entries[0]),
2007            own(entries[1]),
2008            "two step-scope bindings are two values"
2009        );
2010    }
2011
2012    /// A `ref:` step's `name:` is a **replay** of what the request was built
2013    /// from, exactly as an inline step's is: it must display the value the
2014    /// request actually sent, and must not consume an occurrence that shifts
2015    /// every later step's fakes by one. Both halves regressed when this path
2016    /// copied the inline tail without its counter rewind.
2017    #[test]
2018    fn a_ref_steps_label_replays_its_binding_instead_of_minting_a_fresh_value() {
2019        let labelled = lower_fragments(
2020            "macros:\n  m:\n    match: it runs\n    steps:\n      - ref: first\n        name: signup ${fake:email}\n        bind:\n          who: ${fake:email}\n      - ref: second\n        bind:\n          who: ${fake:email}\n",
2021            "@first\n?who\n@second\n?who\n",
2022        )
2023        .expect("lowers");
2024        let steps: Vec<&LoweredStep> = labelled
2025            .batches
2026            .iter()
2027            .flat_map(|b| b.steps.iter())
2028            .collect();
2029        assert_eq!(steps.len(), 2);
2030        let who = |step: &LoweredStep| {
2031            let StepPayload::HurlEntries(text) = &step.payload else {
2032                unreachable!()
2033            };
2034            text.lines()
2035                .find_map(|l| l.strip_prefix("variable: who="))
2036                .expect("who binding")
2037                .trim_matches('"')
2038                .to_owned()
2039        };
2040        assert_eq!(
2041            steps[0].label.as_deref(),
2042            Some(format!("signup {}", who(steps[0])).as_str()),
2043            "the label must report the value its own binding sent"
2044        );
2045
2046        // Same pack without the label: a label is a replay, so removing it
2047        // cannot change what any later step resolves to.
2048        let control = lower_fragments(
2049            "macros:\n  m:\n    match: it runs\n    steps:\n      - ref: first\n        bind:\n          who: ${fake:email}\n      - ref: second\n        bind:\n          who: ${fake:email}\n",
2050            "@first\n?who\n@second\n?who\n",
2051        )
2052        .expect("lowers");
2053        let control_steps: Vec<&LoweredStep> = control
2054            .batches
2055            .iter()
2056            .flat_map(|b| b.steps.iter())
2057            .collect();
2058        assert_eq!(
2059            who(steps[1]),
2060            who(control_steps[1]),
2061            "a label must not shift a later step's fake values"
2062        );
2063    }
2064
2065    mod properties {
2066        #![allow(clippy::ignored_unit_patterns)]
2067
2068        use super::*;
2069        use proptest::prelude::*;
2070
2071        proptest! {
2072            /// **No secret value can reach an artifact through a binding, and a
2073            /// rename cannot lose which secret feeds which variable.**
2074            ///
2075            /// The rename is the risky part: `bind: { <var>: ${secret:<name>} }`
2076            /// deliberately decouples the two, and the engine later joins them
2077            /// again to call `insert_secret`. If the pair were dropped or
2078            /// crossed, either the request goes out unauthenticated or the wrong
2079            /// credential is sent — and the emitted text, which the artifact
2080            /// *is* (ADR-0010), must carry neither name as a value.
2081            #[test]
2082            fn a_renamed_secret_binds_by_name_and_never_by_value(
2083                variable in "[a-z][a-z_]{2,12}",
2084                secret in "[a-zA-Z][a-zA-Z0-9]{3,12}",
2085                literal in "[a-z][a-z0-9]{2,10}",
2086            ) {
2087                // `bind:` keys are distinct by construction; skip the collision
2088                // rather than assert on a pack that could not be written.
2089                prop_assume!(variable != "plain");
2090                let pack = format!(
2091                    "macros:\n  m:\n    match: it runs\n    bind:\n      {variable}: ${{secret:{secret}}}\n      plain: {literal}\n    steps:\n      - ref: f\n"
2092                );
2093                let fragments = format!("@f\n?{variable}\n?plain\n");
2094                let lowered = lower_fragments(&pack, &fragments)
2095                    .unwrap_or_else(|d| panic!("should lower: {d:?}"));
2096                let text = only_entry(&lowered);
2097
2098                // The secret takes the `insert_secret` path, so the artifact
2099                // names it nowhere — not as a variable line, not as a value.
2100                let variable_line = format!("variable: {variable}=");
2101                prop_assert!(!text.contains(&variable_line));
2102                prop_assert!(!text.contains(&secret));
2103                // The literal beside it still takes the ordinary path, so the
2104                // assertion above cannot pass by injecting nothing at all.
2105                let plain_line = format!("variable: plain=\"{literal}\"");
2106                prop_assert!(text.contains(&plain_line));
2107                // And the pairing survives: variable → secret, not either alone.
2108                prop_assert_eq!(
2109                    lowered.secrets.get(&variable).map(String::as_str),
2110                    Some(secret.as_str())
2111                );
2112            }
2113        }
2114    }
2115
2116    /// A capture from an earlier step counts as supplied — that is the whole
2117    /// point of chaining requests, and requiring a `bind:` for it would be wrong.
2118    #[test]
2119    fn a_capture_from_an_earlier_step_supplies_a_later_fragment() {
2120        lower_fragments(
2121            "macros:\n  m:\n    match: it runs\n    steps:\n      - ref: first\n      - ref: second\n",
2122            "@first\n!recordId\n@second\n?recordId\n",
2123        )
2124        .expect("a preceding capture supplies it");
2125    }
2126}