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