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    /// Secret names referenced anywhere in the scenario (values never appear).
57    pub secrets: BTreeSet<String>,
58    /// Global keys read anywhere in the scenario (drives `.vars`, ADR-0010).
59    pub globals: BTreeSet<String>,
60    /// Soft findings (dry-run globals, …) as warning diagnostics.
61    pub warnings: Vec<Diag>,
62}
63
64/// Runtime backstop for `use:` recursion (statically checked at pack load).
65const MAX_EXPANSION_DEPTH: usize = 32;
66
67/// What resolution referenced while lowering one scenario.
68#[derive(Debug, Default)]
69struct Refs {
70    secrets: BTreeSet<String>,
71    globals: BTreeSet<String>,
72    /// `${fake:*}` occurrence counter, shared across every step in the
73    /// scenario (not reset per `resolve()` call) so two steps each asking
74    /// for a fresh `${fake:email}` get distinct values. Scoped to one
75    /// `lower()` call — i.e. one scenario — which keeps it a pure function
76    /// of `(run_id, the scenario's own step order)`: `lower()` runs
77    /// single-threaded per scenario (`runner.rs`: scenario-per-OS-thread),
78    /// so no cross-scenario or cross-thread ordering ever reaches it.
79    fakes: usize,
80}
81
82/// Lower one bound scenario into engine batches.
83pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
84    let mut diags: Vec<Diag> = Vec::new();
85    let mut warnings: Vec<Diag> = Vec::new();
86    let mut refs = Refs::default();
87    let mut lowered: Vec<LoweredStep> = Vec::new();
88    for step in &scenario.steps {
89        let step_ref = StepRef {
90            file: Arc::from(ctx.feature.path.as_str()),
91            line: step.defn.line,
92            text: Arc::from(step.defn.text.as_str()),
93        };
94        let at = |diag: Diag| {
95            diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
96                .with_span(step.defn.span)
97        };
98        let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
99            continue; // binder guarantees existence
100        };
101        expand_macro(
102            macro_,
103            &step.args,
104            &step_ref,
105            ctx,
106            0,
107            &mut lowered,
108            &mut refs,
109            &mut warnings,
110            &mut diags,
111            &at,
112        );
113    }
114
115    // Routing invariant (ADR-0002): every non-glued step's kind must map to a
116    // registered engine. Pack validation and the CLI registry keep this true,
117    // so a miss is drift between them — surfaced as an explicit internal
118    // fault instead of a fabricated engine id that fails later and further
119    // from the cause.
120    for step in &lowered {
121        if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
122            continue; // glued to its host batch — no engine of its own
123        }
124        if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
125            diags.push(
126                Diag::error(
127                    "proef::lower::kind_unrouted",
128                    format!(
129                        "internal: step kind `{}` is not claimed by any registered engine \
130                         (registry/pack-validation drift)",
131                        step.kind.as_str()
132                    ),
133                )
134                .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
135            );
136        }
137    }
138
139    if diags.iter().any(|d| d.severity == Severity::Error) {
140        return Err(diags);
141    }
142
143    Ok(LoweredScenario {
144        name: scenario.name.clone(),
145        tags: scenario.tags.clone(),
146        line: scenario.line,
147        batches: segment(lowered, ctx.kind_to_engine),
148        secrets: refs.secrets,
149        globals: refs.globals,
150        warnings,
151    })
152}
153
154/// Expand one macro invocation into lowered steps (recursing through `use:`).
155#[allow(clippy::too_many_arguments)]
156fn expand_macro(
157    macro_: &Macro,
158    args: &BTreeMap<String, String>,
159    step_ref: &StepRef,
160    ctx: &LowerCtx<'_>,
161    depth: usize,
162    out: &mut Vec<LoweredStep>,
163    refs: &mut Refs,
164    warnings: &mut Vec<Diag>,
165    diags: &mut Vec<Diag>,
166    at: &impl Fn(Diag) -> Diag,
167) {
168    if depth > MAX_EXPANSION_DEPTH {
169        diags.push(at(Diag::error(
170            "proef::lower::expansion_too_deep",
171            format!(
172                "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
173                macro_.name
174            ),
175        )));
176        return;
177    }
178
179    let resolve_in = |text: &str,
180                      refs: &mut Refs,
181                      warnings: &mut Vec<Diag>,
182                      diags: &mut Vec<Diag>|
183     -> Option<String> {
184        let resolve_ctx = ResolveCtx {
185            args,
186            defaults: &macro_.defaults,
187            env: ctx.env,
188            config_vars: ctx.config_vars,
189            run_id: ctx.run_id,
190            world: ctx.world,
191            mode: ctx.mode,
192        };
193        match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
194            Ok(resolution) => {
195                refs.secrets.extend(resolution.secrets);
196                refs.globals.extend(resolution.globals);
197                push_warnings(warnings, &resolution.warnings, ctx, &macro_.name);
198                Some(resolution.text)
199            }
200            Err(err) => {
201                diags.push(at(Diag::error(
202                    err.code(),
203                    format!("in macro `{}`: {err}", macro_.name),
204                )));
205                None
206            }
207        }
208    };
209
210    match &macro_.body {
211        MacroBody::Expect(items) => {
212            let mut merged: Option<(StepKindId, bool, usize)> = None;
213            for item in items {
214                let status = match &item.status {
215                    Some(status) => match resolve_in(status, refs, warnings, diags) {
216                        Some(status) => Some(status),
217                        None => continue,
218                    },
219                    None => None,
220                };
221                let fragment = match &item.fragment {
222                    Some(fragment) => match resolve_in(fragment, refs, warnings, diags) {
223                        Some(fragment) => Some(fragment),
224                        None => continue,
225                    },
226                    None => None,
227                };
228                if let Some((kind, optional, lines)) =
229                    merge_expect(status.as_deref(), fragment.as_deref(), out, diags, at)
230                {
231                    let entry = merged.get_or_insert((kind, optional, 0));
232                    entry.2 += lines;
233                }
234            }
235            // The authored `Then` surfaces as its own step (§2.7): zero bytes
236            // of its own, anchored on the assert lines it appended to the
237            // host entry. It shares the host's fate (`optional` inherited).
238            if let Some((kind, optional, lines)) = merged {
239                out.push(LoweredStep {
240                    step: step_ref.clone(),
241                    kind,
242                    payload: StepPayload::MergedAsserts { lines },
243                    optional,
244                    when: None,
245                    label: None,
246                    save_as: std::collections::BTreeMap::new(),
247                });
248            }
249        }
250        MacroBody::Steps(steps) => {
251            for macro_step in steps {
252                expand_step(
253                    macro_step,
254                    step_ref,
255                    ctx,
256                    depth,
257                    out,
258                    refs,
259                    warnings,
260                    diags,
261                    at,
262                    &resolve_in,
263                );
264            }
265        }
266    }
267}
268
269/// Expand one pack step (payload or `use:` composition).
270#[allow(clippy::too_many_arguments)]
271fn expand_step(
272    macro_step: &MacroStep,
273    step_ref: &StepRef,
274    ctx: &LowerCtx<'_>,
275    depth: usize,
276    out: &mut Vec<LoweredStep>,
277    refs: &mut Refs,
278    warnings: &mut Vec<Diag>,
279    diags: &mut Vec<Diag>,
280    at: &impl Fn(Diag) -> Diag,
281    resolve_in: &impl Fn(&str, &mut Refs, &mut Vec<Diag>, &mut Vec<Diag>) -> Option<String>,
282) {
283    match &macro_step.kind {
284        MacroStepKind::Use { target, with } => {
285            let Some(target_macro) = ctx.packs.find_use_target(target) else {
286                return; // pack validation reported it
287            };
288            // `with:` values resolve in the *parent* scope, then become the
289            // child's args (child defaults fill the rest).
290            let mut child_args = BTreeMap::new();
291            for (key, value) in with {
292                if let Some(resolved) = resolve_in(value, refs, warnings, diags) {
293                    child_args.insert(key.clone(), resolved);
294                }
295            }
296            expand_macro(
297                target_macro,
298                &child_args,
299                step_ref,
300                ctx,
301                depth + 1,
302                out,
303                refs,
304                warnings,
305                diags,
306                at,
307            );
308        }
309        MacroStepKind::Payload { kind, payload } => {
310            // The label annotates *this* step for humans (artifact comments,
311            // events) — it must report the fake values the step actually
312            // used, not mint fresh ones of its own. Remember where the
313            // occurrence counter stood before the functional resolves
314            // (payload, then guard) so the label can replay from the same
315            // base afterward.
316            let label_fakes_start = refs.fakes;
317            let payload = match payload {
318                PayloadForm::Raw(text) => {
319                    let Some(resolved) = resolve_in(text, refs, warnings, diags) else {
320                        return;
321                    };
322                    // Bake `retry:`/`delay:` into hurl `[Options]` so artifacts
323                    // replay with identical semantics under the stock CLI
324                    // (ADR-0010); per-entry [Options] override batch defaults.
325                    let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
326                        bake_entry_options(&resolved, macro_step.retry, macro_step.delay_ms)
327                    } else {
328                        resolved
329                    };
330                    StepPayload::HurlEntries(resolved)
331                }
332                PayloadForm::Structured(value) => {
333                    // `${…}` resolves inside structured payloads exactly as in
334                    // raw ones (ADR-0005): every string value, recursively.
335                    // Keys are schema, not data — they stay literal.
336                    let mut resolve = |text: &str| {
337                        // No `$` ⇒ no placeholders and no `$${` escapes: skip
338                        // the resolver's copy passes for the common case.
339                        if !text.contains('$') {
340                            return Some(text.to_owned());
341                        }
342                        resolve_in(text, refs, warnings, diags)
343                    };
344                    match resolve_structured(value, &mut resolve) {
345                        Some(resolved) => StepPayload::Structured(resolved),
346                        None => return,
347                    }
348                }
349            };
350            let when = match &macro_step.when {
351                Some(guard) => match resolve_in(guard, refs, warnings, diags) {
352                    Some(resolved) => Some(Guard(resolved)),
353                    None => return,
354                },
355                None => None,
356            };
357            // Labels resolve like payloads (same scope, same strictness) —
358            // otherwise raw `${…}` leaks into artifact comments and events.
359            // But a label is a *replay*, not a new use: it shares whatever
360            // `${…}` text the payload/guard already resolved (typically the
361            // same captured arg, e.g. `name: search ${term}` next to
362            // `q: ${term}`), so it resolves from a scratch copy of the
363            // counter rewound to `label_fakes_start` — reproducing the
364            // payload's own fake values instead of consuming fresh
365            // occurrences that would then shift every later step's fakes by
366            // one. Restoring to a *fixed* end (wherever payload/guard left
367            // off) is only correct when the label is an exact mirror: a
368            // label with a `${fake:…}` the payload never had still consumes
369            // real occurrences during the replay, and blindly rewinding past
370            // them would hand those numbers back out to a later step —
371            // colliding with a value this label already displayed. So the
372            // real counter is restored to the *high-water mark* of the two —
373            // wherever payload/guard left off, or wherever the label's own
374            // replay reached, whichever is further — never below either.
375            // Mirrored references replay with no trace (unaffected, the
376            // common case); any extra reference the label consumes stays
377            // consumed and can never be reissued.
378            let functional_fakes_end = refs.fakes;
379            let label = match &macro_step.name {
380                Some(name) => {
381                    refs.fakes = label_fakes_start;
382                    let resolved = resolve_in(name, refs, warnings, diags);
383                    refs.fakes = functional_fakes_end.max(refs.fakes);
384                    match resolved {
385                        Some(resolved) => Some(resolved),
386                        None => return,
387                    }
388                }
389                None => None,
390            };
391            out.push(LoweredStep {
392                step: step_ref.clone(),
393                kind: StepKindId::from(kind.as_str()),
394                payload,
395                optional: macro_step.optional,
396                when,
397                label,
398                save_as: macro_step.save_as.clone(),
399            });
400        }
401    }
402}
403
404/// Every string *value* in a structured payload resolved through `resolve`
405/// (`None` propagates a resolution failure — the caller already has diags).
406fn resolve_structured(
407    value: &serde_json::Value,
408    resolve: &mut dyn FnMut(&str) -> Option<String>,
409) -> Option<serde_json::Value> {
410    use serde_json::Value as J;
411    Some(match value {
412        J::String(text) => J::String(resolve(text)?),
413        J::Array(items) => J::Array(
414            items
415                .iter()
416                .map(|item| resolve_structured(item, resolve))
417                .collect::<Option<_>>()?,
418        ),
419        J::Object(map) => {
420            let mut out = serde_json::Map::new();
421            for (key, item) in map {
422                out.insert(key.clone(), resolve_structured(item, resolve)?);
423            }
424            J::Object(out)
425        }
426        other => other.clone(),
427    })
428}
429
430/// Inject `[Options] retry/retry-interval` after each entry's header block.
431///
432/// Textual by necessity (the core owns no hurl parser), safe by construction:
433/// the emitted artifact is parse-validated with the real parser, so a bad
434/// injection cannot survive to execution. An existing `[Options]` section is
435/// extended instead of duplicated (hurl rejects duplicate sections).
436fn bake_entry_options(
437    text: &str,
438    retry: Option<crate::step::Retry>,
439    delay_ms: Option<u64>,
440) -> String {
441    let mut option_lines: Vec<String> = Vec::new();
442    if let Some(retry) = retry {
443        option_lines.push(format!("retry: {}", retry.count));
444        option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
445    }
446    if let Some(delay_ms) = delay_ms {
447        option_lines.push(format!("delay: {delay_ms}ms"));
448    }
449    let retry_lines = option_lines.join("\n");
450    // Pre-scan: entries whose author already wrote an `[Options]` section only
451    // get that section extended — auto-injecting a fresh one as well would
452    // leave two `[Options]` sections in one entry, which hurl rejects. Slot 0
453    // is the preamble before the first method line.
454    let mut author_options = vec![false];
455    let mut in_fence = false;
456    for line in text.lines() {
457        let trimmed = line.trim();
458        if trimmed.starts_with("```") {
459            in_fence = !in_fence;
460            continue;
461        }
462        if in_fence {
463            continue;
464        }
465        if is_method_line(trimmed) {
466            author_options.push(false);
467        } else if trimmed == "[Options]"
468            && let Some(last) = author_options.last_mut()
469        {
470            *last = true;
471        }
472    }
473    let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
474    let mut out: Vec<String> = Vec::new();
475    let mut in_entry_head = false; // between a method line and its first section/body
476    let mut injected_current = false;
477    let mut in_fence = false; // inside a ```…``` body — no entry surgery there
478    let mut entry = 0usize;
479    for line in text.lines() {
480        let trimmed = line.trim();
481        if trimmed.starts_with("```") {
482            // A fence opening directly after the entry head is the body — the
483            // options section belongs immediately before it.
484            if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
485                out.push("[Options]".to_owned());
486                out.push(retry_lines.clone());
487                injected_current = true;
488            }
489            in_fence = !in_fence;
490            in_entry_head = false;
491            out.push(line.to_owned());
492            continue;
493        }
494        if in_fence {
495            out.push(line.to_owned());
496            continue;
497        }
498        if is_method_line(trimmed) {
499            in_entry_head = true;
500            injected_current = false;
501            entry += 1;
502            out.push(line.to_owned());
503            continue;
504        }
505        if trimmed == "[Options]" {
506            // Extend the author's own section (once — a second author section
507            // is the pack's own parse error, not ours to widen).
508            out.push(line.to_owned());
509            if !injected_current {
510                out.push(retry_lines.clone());
511                injected_current = true;
512            }
513            in_entry_head = false;
514            continue;
515        }
516        let is_header = in_entry_head && is_header_line(trimmed);
517        if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
518            out.push("[Options]".to_owned());
519            out.push(retry_lines.clone());
520            injected_current = true;
521            in_entry_head = false;
522        }
523        out.push(line.to_owned());
524    }
525    if in_entry_head && !injected_current {
526        out.push("[Options]".to_owned());
527        out.push(retry_lines.clone());
528    }
529    let mut result = out.join("\n");
530    if text.ends_with('\n') {
531        result.push('\n');
532    }
533    result
534}
535
536/// The Then-merge rule (ADR-0004): fold a status assert and/or raw assert
537/// fragment into the previous request entry — error when no request precedes
538/// (Then-before-When).
539/// Merge one `expect:` item into the previous request entry. Returns the
540/// host's `(kind, optional)` plus how many assert lines were appended —
541/// the caller anchors an authored-step row on them (§2.7 visibility).
542fn merge_expect(
543    status: Option<&str>,
544    fragment: Option<&str>,
545    out: &mut [LoweredStep],
546    diags: &mut Vec<Diag>,
547    at: &impl Fn(Diag) -> Diag,
548) -> Option<(StepKindId, bool, usize)> {
549    let Some(previous) = out
550        .iter_mut()
551        .rev()
552        .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
553    else {
554        diags.push(
555            at(Diag::error(
556                "proef::lower::then_before_when",
557                "this assert-only step has no previous request entry to attach to",
558            ))
559            .with_help("a Then step asserts on the request made by an earlier When step"),
560        );
561        return None;
562    };
563
564    if let Some(status) = status
565        && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
566    {
567        diags.push(at(Diag::error(
568            "proef::lower::bad_status",
569            format!("expected an HTTP status number, got `{status}`"),
570        )));
571        return None;
572    }
573
574    let host_kind = previous.kind.clone();
575    let host_optional = previous.optional;
576    let StepPayload::HurlEntries(text) = &mut previous.payload else {
577        return None;
578    };
579    // Ensure the *last* entry has a response section, then an [Asserts]
580    // section, then append the asserts. (The emitter parse-validates the
581    // result.) The scan is scoped to the last entry with fences skipped — an
582    // earlier entry's response, or a fenced body line starting with `HTTP`,
583    // must not satisfy it (ADR-0004: merge into the previous entry, singular).
584    let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
585    if !tail_has_http {
586        push_line(text, "HTTP *");
587    }
588    if !tail_has_asserts {
589        push_line(text, "[Asserts]");
590    }
591    let mut appended = 0usize;
592    if let Some(status) = status {
593        push_line(text, &format!("status == {status}"));
594        appended += 1;
595    }
596    if let Some(fragment) = fragment {
597        for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
598            push_line(text, line.trim_end());
599            appended += 1;
600        }
601    }
602    Some((host_kind, host_optional, appended))
603}
604
605/// Is this a `Name: value` HTTP header line per hurl's grammar? The name must
606/// be a non-empty run of token characters before the colon — an XML/JSON/text
607/// body line (`<root xmlns:x=…`, `{"a": 1}`, prose) never qualifies, so the
608/// `[Options]` injection can never land inside a body.
609fn is_header_line(trimmed: &str) -> bool {
610    let Some((name, _)) = trimmed.split_once(':') else {
611        return false;
612    };
613    !name.is_empty()
614        && name != "HTTP"
615        && name
616            .chars()
617            .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
618}
619
620/// Is this trimmed line an entry-opening method line (`GET http://…`)? Custom
621/// methods are any ≥ 3-char run of ASCII uppercase / `-` — except `HTTP`,
622/// which opens a response.
623///
624/// Shared with the emitter's capture scan (`emit::capture_names`) — one
625/// canonical method recogniser, not a duplicate.
626pub(crate) fn is_method_line(trimmed: &str) -> bool {
627    trimmed.split_whitespace().next().is_some_and(|word| {
628        word.len() >= 3
629            && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
630            && word != "HTTP"
631    }) && trimmed.split_whitespace().count() >= 2
632}
633
634/// Does the *last* entry of `text` have a response (`HTTP …`) line, and an
635/// `[Asserts]` section? Flags reset at every entry-opening method line, and
636/// fenced (```…```) bodies are skipped, so the end state describes the last
637/// entry alone.
638fn last_entry_scan(text: &str) -> (bool, bool) {
639    let mut in_fence = false;
640    let (mut has_http, mut has_asserts) = (false, false);
641    for line in text.lines() {
642        let trimmed = line.trim();
643        if trimmed.starts_with("```") {
644            in_fence = !in_fence;
645            continue;
646        }
647        if in_fence {
648            continue;
649        }
650        if is_method_line(trimmed) {
651            (has_http, has_asserts) = (false, false);
652            continue;
653        }
654        has_http = has_http || trimmed.starts_with("HTTP");
655        has_asserts = has_asserts || trimmed == "[Asserts]";
656    }
657    (has_http, has_asserts)
658}
659
660fn push_line(text: &mut String, line: &str) {
661    if !text.is_empty() && !text.ends_with('\n') {
662        text.push('\n');
663    }
664    text.push_str(line);
665    text.push('\n');
666}
667
668/// Maximal segmentation: contiguous same-engine steps share a batch; a batch
669/// breaks at engine changes and around `optional:` steps (each optional step
670/// is a singleton batch so its failure warns without poisoning neighbors).
671fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
672    let mut batches: Vec<StepBatch> = Vec::new();
673    for step in steps {
674        // Unreachable behind lower()'s kind-routing guard; kept total (the
675        // kind doubles as the engine id) so core stays panic-free if a future
676        // caller skips the guard.
677        let engine = kind_to_engine
678            .get(step.kind.as_str())
679            .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
680        // A merged-asserts step never opens a batch: its asserts live inside
681        // the previous step's entry, so it must ride in the same dispatch.
682        let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
683        let start_new = match batches.last() {
684            None => true,
685            Some(last) => {
686                !glued
687                    && (last.engine.as_str() != engine
688                        || step.optional
689                        || last.steps.last().is_some_and(|s| s.optional))
690            }
691        };
692        if start_new {
693            batches.push(StepBatch {
694                index: batches.len(),
695                engine: crate::engine::EngineId::from(engine.as_str()),
696                steps: vec![step],
697            });
698        } else if let Some(last) = batches.last_mut() {
699            last.steps.push(step);
700        }
701    }
702    batches
703}
704
705fn push_warnings(warnings: &mut Vec<Diag>, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
706    for text in texts {
707        warnings.push(
708            Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
709                .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
710        );
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    #![allow(clippy::unwrap_used)]
717
718    use super::*;
719    use crate::engine::StepKindSpec;
720    use crate::pack::{self, PackSource};
721    use crate::step::StepPayload;
722
723    const KINDS: &[StepKindSpec] = &[StepKindSpec {
724        prefix: "hurl",
725        schema: "true",
726        validate: None,
727    }];
728
729    const PACK: &str = r#"macros:
730  auth:
731    params: [token]
732    steps:
733      - name: authenticate
734        hurl: |
735          POST ${url:base}/auth
736          Authorization: Bearer ${token}
737          HTTP 200
738  search:
739    params: [term]
740    match: "I search for {term}"
741    steps:
742      - use: auth
743        with: { token: "${secret:apiToken}" }
744      - name: run the search
745        hurl: |
746          GET ${url:base}/search?q=${term}
747          HTTP 200
748          [Captures]
749          recordId: jsonpath "$[0].id"
750  checkHealth:
751    match: the service is healthy
752    steps:
753      - optional: true
754        hurl: |
755          GET ${url:base}/health
756  expectStatus:
757    params: [status]
758    match: "the response status is {status}"
759    expect:
760      - status: "${status}"
761"#;
762
763    fn fixture() -> (
764        crate::feature::FeatureFile,
765        crate::bind::BoundScenario,
766        PackSet,
767    ) {
768        let packs = pack::load(
769            &[PackSource {
770                name: "test.yaml".into(),
771                text: Arc::from(PACK),
772            }],
773            KINDS,
774        )
775        .unwrap();
776        let feature = crate::feature::parse(
777            "t.feature",
778            "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",
779        )
780        .unwrap();
781        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
782        (feature, scenario, packs)
783    }
784
785    fn ctx<'a>(
786        feature: &'a crate::feature::FeatureFile,
787        packs: &'a PackSet,
788        kind_to_engine: &'a BTreeMap<String, String>,
789        env: &'a BTreeMap<String, String>,
790        config_vars: &'a BTreeMap<String, String>,
791        world: &'a World,
792    ) -> LowerCtx<'a> {
793        LowerCtx {
794            feature,
795            packs,
796            kind_to_engine,
797            env,
798            config_vars,
799            run_id: "run-0001",
800            world,
801            mode: ResolveMode::DryRun,
802        }
803    }
804
805    #[test]
806    fn expansion_resolution_merge_and_segmentation_work_together() {
807        let (feature, scenario, packs) = fixture();
808        let kind_to_engine: BTreeMap<String, String> =
809            [("hurl".to_owned(), "hurl".to_owned())].into();
810        let env = BTreeMap::new();
811        let config_vars =
812            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
813        let world = World::default();
814        let lowered = lower(
815            &scenario,
816            &ctx(
817                &feature,
818                &packs,
819                &kind_to_engine,
820                &env,
821                &config_vars,
822                &world,
823            ),
824        )
825        .unwrap();
826
827        // Optional health check is a singleton batch; auth + search batch
828        // together, and the authored `Then` rides along as a visible
829        // merged-asserts step (§2.7) glued to its host.
830        assert_eq!(lowered.batches.len(), 2);
831        assert_eq!(lowered.batches[0].steps.len(), 1);
832        assert!(lowered.batches[0].steps[0].optional);
833        assert_eq!(lowered.batches[1].steps.len(), 3);
834        let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
835            panic!("expected a merged-asserts step for the Then line");
836        };
837        assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
838
839        // use:/with: expansion resolved the parent's secret reference.
840        let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
841            panic!("expected hurl entries");
842        };
843        assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
844        assert!(
845            auth.contains("Bearer {{apiToken}}"),
846            "secret placeholder: {auth}"
847        );
848        assert!(lowered.secrets.contains("apiToken"));
849
850        // The expect macro merged `status == 200` into the *search* entry.
851        let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
852            panic!("expected hurl entries");
853        };
854        assert!(
855            search.contains("GET http://fixture.local/search?q=Jansen"),
856            "{search}"
857        );
858        assert!(search.contains("[Asserts]"), "{search}");
859        assert!(search.trim_end().ends_with("status == 200"), "{search}");
860
861        // Anchors point at the feature lines.
862        assert_eq!(lowered.batches[1].steps[1].step.line, 4);
863        assert_eq!(
864            lowered.batches[1].steps[0].label.as_deref(),
865            Some("authenticate")
866        );
867    }
868
869    #[test]
870    fn then_before_when_is_an_error() {
871        let (_, _, packs) = fixture();
872        let feature = crate::feature::parse(
873            "t.feature",
874            "Feature: F\n  Scenario: S\n    Then the response status is 200\n",
875        )
876        .unwrap();
877        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
878        let kind_to_engine = BTreeMap::new();
879        let env = BTreeMap::new();
880        let config_vars = BTreeMap::new();
881        let world = World::default();
882        let errs = lower(
883            &scenario,
884            &ctx(
885                &feature,
886                &packs,
887                &kind_to_engine,
888                &env,
889                &config_vars,
890                &world,
891            ),
892        )
893        .unwrap_err();
894        assert_eq!(errs[0].code, "proef::lower::then_before_when");
895    }
896
897    /// Pack validation only sees the *unresolved* pack text (`item.hurl`),
898    /// which is non-blank here (`"${vars:blank}"`) — the emptiness only
899    /// appears once `${vars:key}` resolves against a `proef.toml` value that
900    /// happens to be `""` (a legitimate, env-conditional authoring pattern:
901    /// extra asserts present in some environments, none in others). That
902    /// still lowers to a zero-line `MergedAsserts` step, and the sidecar
903    /// emitter must never turn a zero-line step into an inverted
904    /// `.map.json` span (ADR-0010: emitted artifacts are the normative
905    /// contract).
906    #[test]
907    fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
908        const PACK: &str = r#"macros:
909  ping:
910    match: the service is pinged
911    steps:
912      - hurl: |
913          GET ${url:base}/ping
914          HTTP 200
915  expectBlank:
916    match: nothing extra is asserted
917    expect:
918      - hurl: "${vars:blank}"
919"#;
920        let packs = pack::load(
921            &[PackSource {
922                name: "test.yaml".into(),
923                text: Arc::from(PACK),
924            }],
925            KINDS,
926        )
927        .unwrap();
928        let feature = crate::feature::parse(
929            "t.feature",
930            "Feature: F\n  Scenario: S\n    Given the service is pinged\n    Then nothing extra is asserted\n",
931        )
932        .unwrap();
933        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
934        let kind_to_engine: BTreeMap<String, String> =
935            [("hurl".to_owned(), "hurl".to_owned())].into();
936        let env = BTreeMap::new();
937        let config_vars = BTreeMap::from([
938            ("url:base".to_owned(), "http://fixture.local".to_owned()),
939            ("vars:blank".to_owned(), String::new()),
940        ]);
941        let world = World::default();
942        let lowered = lower(
943            &scenario,
944            &ctx(
945                &feature,
946                &packs,
947                &kind_to_engine,
948                &env,
949                &config_vars,
950                &world,
951            ),
952        )
953        .unwrap();
954
955        assert_eq!(lowered.batches[0].steps.len(), 2);
956        let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
957            panic!("expected a merged-asserts step for the Then line");
958        };
959        assert_eq!(lines, 0, "the fragment resolved to nothing");
960
961        let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
962        for entry in &artifact.map.entries {
963            let [start, end] = entry.hurl_lines;
964            assert!(
965                start <= end,
966                "inverted span for a zero-line merge: {start}..{end}"
967            );
968        }
969    }
970
971    /// `${…}` resolves inside structured payload string values,
972    /// recursively — keys stay literal (they are schema, not data).
973    #[test]
974    fn structured_payloads_resolve_placeholders_recursively() {
975        const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
976            prefix: "alt",
977            schema: "true",
978            validate: None,
979        }];
980        let packs = pack::load(
981            &[PackSource {
982                name: "alt.yaml".into(),
983                text: Arc::from(
984                    "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",
985                ),
986            }],
987            ALT_KINDS,
988        )
989        .unwrap();
990        let feature = crate::feature::parse(
991            "t.feature",
992            "Feature: F\n  Scenario: S\n    When the alternate step runs\n",
993        )
994        .unwrap();
995        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
996        let kind_to_engine: BTreeMap<String, String> =
997            [("alt".to_owned(), "alt".to_owned())].into();
998        let env = BTreeMap::new();
999        let config_vars =
1000            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1001        let world = World::default();
1002        let lowered = lower(
1003            &scenario,
1004            &ctx(
1005                &feature,
1006                &packs,
1007                &kind_to_engine,
1008                &env,
1009                &config_vars,
1010                &world,
1011            ),
1012        )
1013        .unwrap();
1014        let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
1015            panic!("structured payload expected");
1016        };
1017        assert_eq!(value["target"], "http://fixture.local/item");
1018        assert_eq!(value["checks"][0], "http://fixture.local");
1019        assert_eq!(value["checks"][1], 7);
1020    }
1021
1022    /// The Then-merge scans only the *last* entry: an earlier entry's
1023    /// `HTTP`/`[Asserts]` must not satisfy the check (ADR-0004 — merge into
1024    /// the previous entry, singular).
1025    #[test]
1026    fn expect_merge_scopes_to_the_last_entry() {
1027        let packs = pack::load(
1028            &[PackSource {
1029                name: "multi.yaml".into(),
1030                text: Arc::from(
1031                    "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",
1032                ),
1033            }],
1034            KINDS,
1035        )
1036        .unwrap();
1037        let feature = crate::feature::parse(
1038            "t.feature",
1039            "Feature: F\n  Scenario: S\n    When both calls run\n    Then the response status is 201\n",
1040        )
1041        .unwrap();
1042        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1043        let kind_to_engine: BTreeMap<String, String> =
1044            [("hurl".to_owned(), "hurl".to_owned())].into();
1045        let env = BTreeMap::new();
1046        let config_vars = BTreeMap::new();
1047        let world = World::default();
1048        let lowered = lower(
1049            &scenario,
1050            &ctx(
1051                &feature,
1052                &packs,
1053                &kind_to_engine,
1054                &env,
1055                &config_vars,
1056                &world,
1057            ),
1058        )
1059        .unwrap();
1060        let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
1061            panic!("expected hurl entries");
1062        };
1063        // The second entry had no response: the merge must open its own
1064        // `HTTP *` + `[Asserts]` after `GET http://x/b` instead of riding on
1065        // the first entry's.
1066        let tail = text.split("GET http://x/b").nth(1).unwrap();
1067        assert!(tail.contains("HTTP *"), "{text}");
1068        assert!(tail.contains("[Asserts]"), "{text}");
1069        assert!(tail.contains("status == 201"), "{text}");
1070    }
1071
1072    /// An author `[Options]` section placed after another section is extended
1073    /// in place — never paired with a second, injected `[Options]`, which
1074    /// hurl rejects as a duplicate section.
1075    #[test]
1076    fn baked_options_extend_a_late_author_options_section() {
1077        let retry = Some(crate::step::Retry {
1078            count: 2,
1079            interval_ms: 100,
1080        });
1081        let body =
1082            "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
1083        let baked = bake_entry_options(body, retry, None);
1084        assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
1085        assert!(
1086            baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
1087            "{baked}"
1088        );
1089    }
1090
1091    /// The `[Options]` injection must never land inside a body: fenced text,
1092    /// XML (colon-bearing first line), and JSON bodies all stay untouched.
1093    #[test]
1094    fn baked_options_never_enter_bodies() {
1095        let retry = Some(crate::step::Retry {
1096            count: 2,
1097            interval_ms: 100,
1098        });
1099        for body in [
1100            "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
1101            "POST http://x/a\n<root xmlns:x=\"urn:example\">\n  <child>hi</child>\n</root>\nHTTP 200\n",
1102            "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
1103        ] {
1104            let baked = bake_entry_options(body, retry, None);
1105            assert_eq!(
1106                baked.matches("[Options]").count(),
1107                1,
1108                "exactly one options block in:\n{baked}"
1109            );
1110            let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
1111            let body_at = baked
1112                .find("```")
1113                .or_else(|| baked.find('<'))
1114                .or_else(|| baked.find('{'))
1115                .unwrap_or(0);
1116            assert!(options_at < body_at, "options precede the body:\n{baked}");
1117        }
1118    }
1119
1120    #[test]
1121    fn engine_change_splits_batches() {
1122        let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1123            .iter()
1124            .map(|kind| LoweredStep {
1125                step: StepRef {
1126                    file: Arc::from("f"),
1127                    line: 1,
1128                    text: Arc::from("t"),
1129                },
1130                kind: StepKindId::from(*kind),
1131                payload: StepPayload::HurlEntries(String::new()),
1132                optional: false,
1133                when: None,
1134                label: None,
1135                save_as: BTreeMap::new(),
1136            })
1137            .collect();
1138        let mapping: BTreeMap<String, String> = [
1139            ("hurl".to_owned(), "hurl".to_owned()),
1140            ("alt".to_owned(), "alt".to_owned()),
1141        ]
1142        .into();
1143        let batches = segment(steps, &mapping);
1144        let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1145        assert_eq!(sizes, vec![2, 1, 1]);
1146        assert_eq!(batches[1].engine.as_str(), "alt");
1147        // Scenario-wide ordinals — the sidecar `batch` key engines filter by.
1148        let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1149        assert_eq!(indexes, vec![0, 1, 2]);
1150    }
1151
1152    /// A step's label replays the payload's `${fake:…}` values (the same
1153    /// occurrence) instead of minting a fresh one — so the artifact comment
1154    /// never names data the request didn't actually send — and that replay
1155    /// must leave no trace on the running counter: a later step's own fake
1156    /// still lands on the very next occurrence, not one the label
1157    /// incidentally borrowed.
1158    #[test]
1159    fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
1160        const FAKE_PACK: &str = r#"macros:
1161  searchFor:
1162    params: [term]
1163    match: "the operator searches for {term}"
1164    steps:
1165      - name: "search for ${term}"
1166        hurl: |
1167          GET ${url:base}/search
1168          [Query]
1169          q: ${term}
1170          HTTP 200
1171  pingFake:
1172    match: a fresh fake is requested
1173    steps:
1174      - hurl: |
1175          GET ${url:base}/ping
1176          [Query]
1177          v: ${fake:lastName}
1178          HTTP 200
1179"#;
1180        let packs = pack::load(
1181            &[PackSource {
1182                name: "fakes.yaml".into(),
1183                text: Arc::from(FAKE_PACK),
1184            }],
1185            KINDS,
1186        )
1187        .unwrap();
1188        let feature = crate::feature::parse(
1189            "t.feature",
1190            "Feature: F\n  Scenario: S\n    When the operator searches for ${fake:lastName}\n    Then a fresh fake is requested\n",
1191        )
1192        .unwrap();
1193        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1194        let kind_to_engine: BTreeMap<String, String> =
1195            [("hurl".to_owned(), "hurl".to_owned())].into();
1196        let env = BTreeMap::new();
1197        let config_vars =
1198            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1199        let world = World::default();
1200        let lowered = lower(
1201            &scenario,
1202            &ctx(
1203                &feature,
1204                &packs,
1205                &kind_to_engine,
1206                &env,
1207                &config_vars,
1208                &world,
1209            ),
1210        )
1211        .unwrap();
1212
1213        // Both steps share one hurl batch (no optional/engine boundary).
1214        assert_eq!(lowered.batches[0].steps.len(), 2);
1215        let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
1216            panic!("expected hurl entries");
1217        };
1218        let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1219
1220        // The payload's request resolves the scenario's first ${fake:…} —
1221        // occurrence 0 — and the label mirrors that exact value.
1222        let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1223        assert!(
1224            search.contains(&format!("q: {occurrence_0}")),
1225            "payload: {search}"
1226        );
1227        assert!(label.contains(&occurrence_0), "label: {label}");
1228
1229        // The second step's own fake continues from occurrence 1 — proof
1230        // the label's replay above did not silently consume it.
1231        let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1232            panic!("expected hurl entries");
1233        };
1234        let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1235        assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
1236    }
1237
1238    /// The mirrored-replay above only covers a label that is an *exact*
1239    /// mirror of its payload. A label with *more* `${fake:…}` references
1240    /// than its payload still consumes real occurrences during its replay —
1241    /// blindly rewinding the counter back to wherever the payload/guard left
1242    /// off would hand those consumed occurrences back out to a later step,
1243    /// which would then display a value the label already showed.
1244    #[test]
1245    fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
1246        const FAKE_PACK: &str = r#"macros:
1247  unmirroredLabel:
1248    match: a label mentions more fakes than its payload
1249    steps:
1250      - name: "${fake:lastName} vs ${fake:lastName}"
1251        hurl: |
1252          GET ${url:base}/probe
1253          [Query]
1254          q: ${fake:lastName}
1255          HTTP 200
1256  pingFake:
1257    match: a fresh fake is requested
1258    steps:
1259      - hurl: |
1260          GET ${url:base}/ping
1261          [Query]
1262          v: ${fake:lastName}
1263          HTTP 200
1264"#;
1265        let packs = pack::load(
1266            &[PackSource {
1267                name: "unmirrored.yaml".into(),
1268                text: Arc::from(FAKE_PACK),
1269            }],
1270            KINDS,
1271        )
1272        .unwrap();
1273        let feature = crate::feature::parse(
1274            "t.feature",
1275            "Feature: F\n  Scenario: S\n    When a label mentions more fakes than its payload\n    Then a fresh fake is requested\n",
1276        )
1277        .unwrap();
1278        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1279        let kind_to_engine: BTreeMap<String, String> =
1280            [("hurl".to_owned(), "hurl".to_owned())].into();
1281        let env = BTreeMap::new();
1282        let config_vars =
1283            BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1284        let world = World::default();
1285        let lowered = lower(
1286            &scenario,
1287            &ctx(
1288                &feature,
1289                &packs,
1290                &kind_to_engine,
1291                &env,
1292                &config_vars,
1293                &world,
1294            ),
1295        )
1296        .unwrap();
1297
1298        // Both steps share one hurl batch.
1299        assert_eq!(lowered.batches[0].steps.len(), 2);
1300        let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1301        let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1302            panic!("expected hurl entries");
1303        };
1304
1305        // The label's payload uses occurrence 0; the label itself has a
1306        // *second* `${fake:lastName}` the payload never had, which must
1307        // consume occurrence 1 for real.
1308        let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1309        let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1310        assert!(label.contains(&occurrence_0), "label: {label}");
1311        assert!(label.contains(&occurrence_1), "label: {label}");
1312
1313        // The next step's own, independent fake must continue *past* what
1314        // the label already consumed — occurrence 2 — never occurrence 1,
1315        // which the label already displayed.
1316        let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
1317        assert!(
1318            !ping.contains(&format!("v: {occurrence_1}")),
1319            "the next step's fake reused an occurrence the label already \
1320             displayed: {ping}"
1321        );
1322        assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
1323    }
1324}