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 (directives + 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 run identifier.
35    pub run_id: &'a str,
36    /// World (globals read at lower time).
37    pub world: &'a World,
38    /// Strict (execution) or dry-run resolution.
39    pub mode: ResolveMode,
40}
41
42/// A fully lowered scenario: ordered engine batches plus what lowering learned.
43#[derive(Debug)]
44pub struct LoweredScenario {
45    /// Scenario name (post-expansion).
46    pub name: String,
47    /// Accumulated tags.
48    pub tags: Vec<String>,
49    /// 1-based header line.
50    pub line: usize,
51    /// Contiguous same-engine batches, in authored order.
52    pub batches: Vec<StepBatch>,
53    /// Secret names referenced anywhere in the scenario (values never appear).
54    pub secrets: BTreeSet<String>,
55    /// Global keys read anywhere in the scenario (drives `.vars`, ADR-0010).
56    pub globals: BTreeSet<String>,
57    /// Soft findings (dry-run globals, …) as warning diagnostics.
58    pub warnings: Vec<Diag>,
59}
60
61/// Runtime backstop for `use:` recursion (statically checked at pack load).
62const MAX_EXPANSION_DEPTH: usize = 32;
63
64/// What resolution referenced while lowering one scenario.
65#[derive(Debug, Default)]
66struct Refs {
67    secrets: BTreeSet<String>,
68    globals: BTreeSet<String>,
69}
70
71/// Lower one bound scenario into engine batches.
72pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
73    let mut diags: Vec<Diag> = Vec::new();
74    let mut warnings: Vec<Diag> = Vec::new();
75    let mut refs = Refs::default();
76
77    // Directives resolve first (env/run only — no step scope).
78    let Some(directives) = resolve_directives(ctx, &mut refs, &mut warnings, &mut diags) else {
79        return Err(diags);
80    };
81
82    let mut lowered: Vec<LoweredStep> = Vec::new();
83    for step in &scenario.steps {
84        let step_ref = StepRef {
85            file: Arc::from(ctx.feature.path.as_str()),
86            line: step.defn.line,
87            text: Arc::from(step.defn.text.as_str()),
88        };
89        let at = |diag: Diag| {
90            diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
91                .with_span(step.defn.span)
92        };
93        let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
94            continue; // binder guarantees existence
95        };
96        expand_macro(
97            macro_,
98            &step.args,
99            &step_ref,
100            &directives,
101            ctx,
102            0,
103            &mut lowered,
104            &mut refs,
105            &mut warnings,
106            &mut diags,
107            &at,
108        );
109    }
110
111    if diags.iter().any(|d| d.severity == Severity::Error) {
112        return Err(diags);
113    }
114
115    Ok(LoweredScenario {
116        name: scenario.name.clone(),
117        tags: scenario.tags.clone(),
118        line: scenario.line,
119        batches: segment(lowered, ctx.kind_to_engine),
120        secrets: refs.secrets,
121        globals: refs.globals,
122        warnings,
123    })
124}
125
126/// Resolve `# key: value` directive values (they may reference env/run).
127/// Failures land in `diags`; `None` means at least one directive is broken.
128fn resolve_directives(
129    ctx: &LowerCtx<'_>,
130    refs: &mut Refs,
131    warnings: &mut Vec<Diag>,
132    diags: &mut Vec<Diag>,
133) -> Option<BTreeMap<String, String>> {
134    let empty = BTreeMap::new();
135    let mut resolved = BTreeMap::new();
136    for (key, value) in &ctx.feature.directives {
137        let resolve_ctx = ResolveCtx {
138            args: &empty,
139            defaults: &empty,
140            directives: &resolved, // earlier directives are visible to later ones
141            env: ctx.env,
142            run_id: ctx.run_id,
143            world: ctx.world,
144            mode: ctx.mode,
145        };
146        match resolve::resolve(value, &resolve_ctx) {
147            Ok(resolution) => {
148                refs.secrets.extend(resolution.secrets);
149                refs.globals.extend(resolution.globals);
150                push_warnings(warnings, &resolution.warnings, ctx, key);
151                resolved.insert(key.clone(), resolution.text);
152            }
153            Err(err) => {
154                diags.push(
155                    Diag::error(
156                        err.code(),
157                        format!("directive `# {key}:` does not resolve: {err}"),
158                    )
159                    .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
160                );
161                return None;
162            }
163        }
164    }
165    Some(resolved)
166}
167
168/// Expand one macro invocation into lowered steps (recursing through `use:`).
169#[allow(clippy::too_many_arguments)]
170fn expand_macro(
171    macro_: &Macro,
172    args: &BTreeMap<String, String>,
173    step_ref: &StepRef,
174    directives: &BTreeMap<String, String>,
175    ctx: &LowerCtx<'_>,
176    depth: usize,
177    out: &mut Vec<LoweredStep>,
178    refs: &mut Refs,
179    warnings: &mut Vec<Diag>,
180    diags: &mut Vec<Diag>,
181    at: &impl Fn(Diag) -> Diag,
182) {
183    if depth > MAX_EXPANSION_DEPTH {
184        diags.push(at(Diag::error(
185            "proef::lower::expansion_too_deep",
186            format!(
187                "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
188                macro_.name
189            ),
190        )));
191        return;
192    }
193
194    let resolve_in = |text: &str,
195                      refs: &mut Refs,
196                      warnings: &mut Vec<Diag>,
197                      diags: &mut Vec<Diag>|
198     -> Option<String> {
199        let resolve_ctx = ResolveCtx {
200            args,
201            defaults: &macro_.defaults,
202            directives,
203            env: ctx.env,
204            run_id: ctx.run_id,
205            world: ctx.world,
206            mode: ctx.mode,
207        };
208        match resolve::resolve(text, &resolve_ctx) {
209            Ok(resolution) => {
210                refs.secrets.extend(resolution.secrets);
211                refs.globals.extend(resolution.globals);
212                push_warnings(warnings, &resolution.warnings, ctx, &macro_.name);
213                Some(resolution.text)
214            }
215            Err(err) => {
216                diags.push(at(Diag::error(
217                    err.code(),
218                    format!("in macro `{}`: {err}", macro_.name),
219                )));
220                None
221            }
222        }
223    };
224
225    match &macro_.body {
226        MacroBody::Expect(items) => {
227            let mut merged: Option<(StepKindId, bool, usize)> = None;
228            for item in items {
229                let status = match &item.status {
230                    Some(status) => match resolve_in(status, refs, warnings, diags) {
231                        Some(status) => Some(status),
232                        None => continue,
233                    },
234                    None => None,
235                };
236                let fragment = match &item.fragment {
237                    Some(fragment) => match resolve_in(fragment, refs, warnings, diags) {
238                        Some(fragment) => Some(fragment),
239                        None => continue,
240                    },
241                    None => None,
242                };
243                if let Some((kind, optional, lines)) =
244                    merge_expect(status.as_deref(), fragment.as_deref(), out, diags, at)
245                {
246                    let entry = merged.get_or_insert((kind, optional, 0));
247                    entry.2 += lines;
248                }
249            }
250            // The authored `Then` surfaces as its own step (§2.7): zero bytes
251            // of its own, anchored on the assert lines it appended to the
252            // host entry. It shares the host's fate (`optional` inherited).
253            if let Some((kind, optional, lines)) = merged {
254                out.push(LoweredStep {
255                    step: step_ref.clone(),
256                    kind,
257                    payload: StepPayload::MergedAsserts { lines },
258                    optional,
259                    when: None,
260                    label: None,
261                    save_as: std::collections::BTreeMap::new(),
262                });
263            }
264        }
265        MacroBody::Steps(steps) => {
266            for macro_step in steps {
267                expand_step(
268                    macro_step,
269                    step_ref,
270                    directives,
271                    ctx,
272                    depth,
273                    out,
274                    refs,
275                    warnings,
276                    diags,
277                    at,
278                    &resolve_in,
279                );
280            }
281        }
282    }
283}
284
285/// Expand one pack step (payload or `use:` composition).
286#[allow(clippy::too_many_arguments)]
287fn expand_step(
288    macro_step: &MacroStep,
289    step_ref: &StepRef,
290    directives: &BTreeMap<String, String>,
291    ctx: &LowerCtx<'_>,
292    depth: usize,
293    out: &mut Vec<LoweredStep>,
294    refs: &mut Refs,
295    warnings: &mut Vec<Diag>,
296    diags: &mut Vec<Diag>,
297    at: &impl Fn(Diag) -> Diag,
298    resolve_in: &impl Fn(&str, &mut Refs, &mut Vec<Diag>, &mut Vec<Diag>) -> Option<String>,
299) {
300    match &macro_step.kind {
301        MacroStepKind::Use { target, with } => {
302            let Some(target_macro) = ctx.packs.find_use_target(target) else {
303                return; // pack validation reported it
304            };
305            // `with:` values resolve in the *parent* scope, then become the
306            // child's args (child defaults fill the rest).
307            let mut child_args = BTreeMap::new();
308            for (key, value) in with {
309                if let Some(resolved) = resolve_in(value, refs, warnings, diags) {
310                    child_args.insert(key.clone(), resolved);
311                }
312            }
313            expand_macro(
314                target_macro,
315                &child_args,
316                step_ref,
317                directives,
318                ctx,
319                depth + 1,
320                out,
321                refs,
322                warnings,
323                diags,
324                at,
325            );
326        }
327        MacroStepKind::Payload { kind, payload } => {
328            let payload = match payload {
329                PayloadForm::Raw(text) => {
330                    let Some(resolved) = resolve_in(text, refs, warnings, diags) else {
331                        return;
332                    };
333                    // Bake `retry:`/`delay:` into hurl `[Options]` so artifacts
334                    // replay with identical semantics under the stock CLI
335                    // (ADR-0010); per-entry [Options] override batch defaults.
336                    let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
337                        bake_entry_options(&resolved, macro_step.retry, macro_step.delay_ms)
338                    } else {
339                        resolved
340                    };
341                    StepPayload::HurlEntries(resolved)
342                }
343                PayloadForm::Structured(value) => {
344                    // `${…}` resolves inside structured payloads exactly as in
345                    // raw ones (ADR-0005): every string value, recursively.
346                    // Keys are schema, not data — they stay literal.
347                    let mut resolve = |text: &str| {
348                        // No `$` ⇒ no placeholders and no `$${` escapes: skip
349                        // the resolver's copy passes for the common case.
350                        if !text.contains('$') {
351                            return Some(text.to_owned());
352                        }
353                        resolve_in(text, refs, warnings, diags)
354                    };
355                    match resolve_structured(value, &mut resolve) {
356                        Some(resolved) => StepPayload::Structured(resolved),
357                        None => return,
358                    }
359                }
360            };
361            let when = match &macro_step.when {
362                Some(guard) => match resolve_in(guard, refs, warnings, diags) {
363                    Some(resolved) => Some(Guard(resolved)),
364                    None => return,
365                },
366                None => None,
367            };
368            // Labels resolve like payloads (same scope, same strictness) —
369            // otherwise raw `${…}` leaks into artifact comments and events.
370            let label = match &macro_step.name {
371                Some(name) => match resolve_in(name, refs, warnings, diags) {
372                    Some(resolved) => Some(resolved),
373                    None => return,
374                },
375                None => None,
376            };
377            out.push(LoweredStep {
378                step: step_ref.clone(),
379                kind: StepKindId::from(kind.as_str()),
380                payload,
381                optional: macro_step.optional,
382                when,
383                label,
384                save_as: macro_step.save_as.clone(),
385            });
386        }
387    }
388}
389
390/// Every string *value* in a structured payload resolved through `resolve`
391/// (`None` propagates a resolution failure — the caller already has diags).
392fn resolve_structured(
393    value: &serde_json::Value,
394    resolve: &mut dyn FnMut(&str) -> Option<String>,
395) -> Option<serde_json::Value> {
396    use serde_json::Value as J;
397    Some(match value {
398        J::String(text) => J::String(resolve(text)?),
399        J::Array(items) => J::Array(
400            items
401                .iter()
402                .map(|item| resolve_structured(item, resolve))
403                .collect::<Option<_>>()?,
404        ),
405        J::Object(map) => {
406            let mut out = serde_json::Map::new();
407            for (key, item) in map {
408                out.insert(key.clone(), resolve_structured(item, resolve)?);
409            }
410            J::Object(out)
411        }
412        other => other.clone(),
413    })
414}
415
416/// Inject `[Options] retry/retry-interval` after each entry's header block.
417///
418/// Textual by necessity (the core owns no hurl parser), safe by construction:
419/// the emitted artifact is parse-validated with the real parser, so a bad
420/// injection cannot survive to execution. An existing `[Options]` section is
421/// extended instead of duplicated (hurl rejects duplicate sections).
422fn bake_entry_options(
423    text: &str,
424    retry: Option<crate::step::Retry>,
425    delay_ms: Option<u64>,
426) -> String {
427    let mut option_lines: Vec<String> = Vec::new();
428    if let Some(retry) = retry {
429        option_lines.push(format!("retry: {}", retry.count));
430        option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
431    }
432    if let Some(delay_ms) = delay_ms {
433        option_lines.push(format!("delay: {delay_ms}ms"));
434    }
435    let retry_lines = option_lines.join("\n");
436    let mut out: Vec<String> = Vec::new();
437    let mut in_entry_head = false; // between a method line and its first section/body
438    let mut injected_current = false;
439    let mut in_fence = false; // inside a ```…``` body — no entry surgery there
440    for line in text.lines() {
441        let trimmed = line.trim();
442        if trimmed.starts_with("```") {
443            // A fence opening directly after the entry head is the body — the
444            // options section belongs immediately before it.
445            if !in_fence && in_entry_head && !injected_current {
446                out.push("[Options]".to_owned());
447                out.push(retry_lines.clone());
448                injected_current = true;
449            }
450            in_fence = !in_fence;
451            in_entry_head = false;
452            out.push(line.to_owned());
453            continue;
454        }
455        if in_fence {
456            out.push(line.to_owned());
457            continue;
458        }
459        let is_method_line = trimmed.split_whitespace().next().is_some_and(|word| {
460            word.len() >= 3
461                && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
462                && word != "HTTP"
463        }) && trimmed.split_whitespace().count() >= 2;
464        if is_method_line {
465            in_entry_head = true;
466            injected_current = false;
467            out.push(line.to_owned());
468            continue;
469        }
470        if trimmed == "[Options]" {
471            // Extend the author's own section.
472            out.push(line.to_owned());
473            out.push(retry_lines.clone());
474            injected_current = true;
475            in_entry_head = false;
476            continue;
477        }
478        let is_header = in_entry_head && is_header_line(trimmed);
479        if in_entry_head && !is_header && !injected_current {
480            out.push("[Options]".to_owned());
481            out.push(retry_lines.clone());
482            injected_current = true;
483            in_entry_head = false;
484        }
485        out.push(line.to_owned());
486    }
487    if in_entry_head && !injected_current {
488        out.push("[Options]".to_owned());
489        out.push(retry_lines.clone());
490    }
491    let mut result = out.join("\n");
492    if text.ends_with('\n') {
493        result.push('\n');
494    }
495    result
496}
497
498/// The Then-merge rule (ADR-0004): fold a status assert and/or raw assert
499/// fragment into the previous request entry — error when no request precedes
500/// (Then-before-When).
501/// Merge one `expect:` item into the previous request entry. Returns the
502/// host's `(kind, optional)` plus how many assert lines were appended —
503/// the caller anchors an authored-step row on them (§2.7 visibility).
504fn merge_expect(
505    status: Option<&str>,
506    fragment: Option<&str>,
507    out: &mut [LoweredStep],
508    diags: &mut Vec<Diag>,
509    at: &impl Fn(Diag) -> Diag,
510) -> Option<(StepKindId, bool, usize)> {
511    let Some(previous) = out
512        .iter_mut()
513        .rev()
514        .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
515    else {
516        diags.push(
517            at(Diag::error(
518                "proef::lower::then_before_when",
519                "this assert-only step has no previous request entry to attach to",
520            ))
521            .with_help("a Then step asserts on the request made by an earlier When step"),
522        );
523        return None;
524    };
525
526    if let Some(status) = status
527        && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
528    {
529        diags.push(at(Diag::error(
530            "proef::lower::bad_status",
531            format!("expected an HTTP status number, got `{status}`"),
532        )));
533        return None;
534    }
535
536    let host_kind = previous.kind.clone();
537    let host_optional = previous.optional;
538    let StepPayload::HurlEntries(text) = &mut previous.payload else {
539        return None;
540    };
541    // Ensure the last entry has a response section, then an [Asserts] section,
542    // then append the asserts. (The emitter parse-validates the result.)
543    if !text.lines().any(|l| l.trim_start().starts_with("HTTP")) {
544        push_line(text, "HTTP *");
545    }
546    if !text.lines().any(|l| l.trim() == "[Asserts]") {
547        push_line(text, "[Asserts]");
548    }
549    let mut appended = 0usize;
550    if let Some(status) = status {
551        push_line(text, &format!("status == {status}"));
552        appended += 1;
553    }
554    if let Some(fragment) = fragment {
555        for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
556            push_line(text, line.trim_end());
557            appended += 1;
558        }
559    }
560    Some((host_kind, host_optional, appended))
561}
562
563/// Is this a `Name: value` HTTP header line per hurl's grammar? The name must
564/// be a non-empty run of token characters before the colon — an XML/JSON/text
565/// body line (`<root xmlns:x=…`, `{"a": 1}`, prose) never qualifies, so the
566/// `[Options]` injection can never land inside a body.
567fn is_header_line(trimmed: &str) -> bool {
568    let Some((name, _)) = trimmed.split_once(':') else {
569        return false;
570    };
571    !name.is_empty()
572        && name != "HTTP"
573        && name
574            .chars()
575            .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
576}
577
578fn push_line(text: &mut String, line: &str) {
579    if !text.is_empty() && !text.ends_with('\n') {
580        text.push('\n');
581    }
582    text.push_str(line);
583    text.push('\n');
584}
585
586/// Maximal segmentation: contiguous same-engine steps share a batch; a batch
587/// breaks at engine changes and around `optional:` steps (each optional step
588/// is a singleton batch so its failure warns without poisoning neighbors).
589fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
590    let mut batches: Vec<StepBatch> = Vec::new();
591    for step in steps {
592        let engine = kind_to_engine
593            .get(step.kind.as_str())
594            .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
595        // A merged-asserts step never opens a batch: its asserts live inside
596        // the previous step's entry, so it must ride in the same dispatch.
597        let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
598        let start_new = match batches.last() {
599            None => true,
600            Some(last) => {
601                !glued
602                    && (last.engine.as_str() != engine
603                        || step.optional
604                        || last.steps.last().is_some_and(|s| s.optional))
605            }
606        };
607        if start_new {
608            batches.push(StepBatch {
609                index: batches.len(),
610                engine: crate::engine::EngineId::from(engine.as_str()),
611                steps: vec![step],
612            });
613        } else if let Some(last) = batches.last_mut() {
614            last.steps.push(step);
615        }
616    }
617    batches
618}
619
620fn push_warnings(warnings: &mut Vec<Diag>, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
621    for text in texts {
622        warnings.push(
623            Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
624                .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
625        );
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    #![allow(clippy::unwrap_used)]
632
633    use super::*;
634    use crate::engine::StepKindSpec;
635    use crate::pack::{self, PackSource};
636    use crate::step::StepPayload;
637
638    const KINDS: &[StepKindSpec] = &[StepKindSpec {
639        prefix: "hurl",
640        schema: "true",
641        validate: None,
642    }];
643
644    const PACK: &str = r#"templates:
645  auth:
646    params: [token]
647    steps:
648      - name: authenticate
649        hurl: |
650          POST ${baseURL}/auth
651          Authorization: Bearer ${token}
652          HTTP 200
653  search:
654    params: [term]
655    match: "I search for {term}"
656    steps:
657      - use: auth
658        with: { token: "${secret:apiToken}" }
659      - name: run the search
660        hurl: |
661          GET ${baseURL}/search?q=${term}
662          HTTP 200
663          [Captures]
664          clientId: jsonpath "$[0].id"
665  checkHealth:
666    match: the service is healthy
667    steps:
668      - optional: true
669        hurl: |
670          GET ${baseURL}/health
671  expectStatus:
672    params: [status]
673    match: "the response status is {status}"
674    expect:
675      - status: "${status}"
676"#;
677
678    fn fixture() -> (
679        crate::feature::FeatureFile,
680        crate::bind::BoundScenario,
681        PackSet,
682    ) {
683        let packs = pack::load(
684            &[PackSource {
685                name: "test.yaml".into(),
686                text: Arc::from(PACK),
687            }],
688            KINDS,
689        )
690        .unwrap();
691        let feature = crate::feature::parse(
692            "t.feature",
693            "# baseURL: http://fixture.local\nFeature: F\n  Scenario: S\n    Given the service is healthy\n    When I search for \"Jansen\"\n    Then the response status is 200\n",
694        )
695        .unwrap();
696        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
697        (feature, scenario, packs)
698    }
699
700    fn ctx<'a>(
701        feature: &'a crate::feature::FeatureFile,
702        packs: &'a PackSet,
703        kind_to_engine: &'a BTreeMap<String, String>,
704        env: &'a BTreeMap<String, String>,
705        world: &'a World,
706    ) -> LowerCtx<'a> {
707        LowerCtx {
708            feature,
709            packs,
710            kind_to_engine,
711            env,
712            run_id: "run-0001",
713            world,
714            mode: ResolveMode::DryRun,
715        }
716    }
717
718    #[test]
719    fn expansion_resolution_merge_and_segmentation_work_together() {
720        let (feature, scenario, packs) = fixture();
721        let kind_to_engine: BTreeMap<String, String> =
722            [("hurl".to_owned(), "hurl".to_owned())].into();
723        let env = BTreeMap::new();
724        let world = World::default();
725        let lowered = lower(
726            &scenario,
727            &ctx(&feature, &packs, &kind_to_engine, &env, &world),
728        )
729        .unwrap();
730
731        // Optional health check is a singleton batch; auth + search batch
732        // together, and the authored `Then` rides along as a visible
733        // merged-asserts step (§2.7) glued to its host.
734        assert_eq!(lowered.batches.len(), 2);
735        assert_eq!(lowered.batches[0].steps.len(), 1);
736        assert!(lowered.batches[0].steps[0].optional);
737        assert_eq!(lowered.batches[1].steps.len(), 3);
738        let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
739            panic!("expected a merged-asserts step for the Then line");
740        };
741        assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
742
743        // use:/with: expansion resolved the parent's secret reference.
744        let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
745            panic!("expected hurl entries");
746        };
747        assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
748        assert!(
749            auth.contains("Bearer {{apiToken}}"),
750            "secret placeholder: {auth}"
751        );
752        assert!(lowered.secrets.contains("apiToken"));
753
754        // The expect macro merged `status == 200` into the *search* entry.
755        let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
756            panic!("expected hurl entries");
757        };
758        assert!(
759            search.contains("GET http://fixture.local/search?q=Jansen"),
760            "{search}"
761        );
762        assert!(search.contains("[Asserts]"), "{search}");
763        assert!(search.trim_end().ends_with("status == 200"), "{search}");
764
765        // Anchors point at the feature lines.
766        assert_eq!(lowered.batches[1].steps[1].step.line, 5);
767        assert_eq!(
768            lowered.batches[1].steps[0].label.as_deref(),
769            Some("authenticate")
770        );
771    }
772
773    #[test]
774    fn then_before_when_is_an_error() {
775        let (_, _, packs) = fixture();
776        let feature = crate::feature::parse(
777            "t.feature",
778            "Feature: F\n  Scenario: S\n    Then the response status is 200\n",
779        )
780        .unwrap();
781        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
782        let kind_to_engine = BTreeMap::new();
783        let env = BTreeMap::new();
784        let world = World::default();
785        let errs = lower(
786            &scenario,
787            &ctx(&feature, &packs, &kind_to_engine, &env, &world),
788        )
789        .unwrap_err();
790        assert_eq!(errs[0].code, "proef::lower::then_before_when");
791    }
792
793    /// `${…}` resolves inside structured payload string values,
794    /// recursively — keys stay literal (they are schema, not data).
795    #[test]
796    fn structured_payloads_resolve_placeholders_recursively() {
797        const WEB_KINDS: &[StepKindSpec] = &[StepKindSpec {
798            prefix: "web",
799            schema: "true",
800            validate: None,
801        }];
802        let packs = pack::load(
803            &[PackSource {
804                name: "web.yaml".into(),
805                text: Arc::from(
806                    "templates:\n  open:\n    match: the page is opened\n    steps:\n      - name: open\n        web:\n          goto: \"${baseURL}/page\"\n          checks: [\"${baseURL}\", 7]\n",
807                ),
808            }],
809            WEB_KINDS,
810        )
811        .unwrap();
812        let feature = crate::feature::parse(
813            "t.feature",
814            "# baseURL: http://fixture.local\nFeature: F\n  Scenario: S\n    When the page is opened\n",
815        )
816        .unwrap();
817        let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
818        let kind_to_engine: BTreeMap<String, String> =
819            [("web".to_owned(), "web".to_owned())].into();
820        let env = BTreeMap::new();
821        let world = World::default();
822        let lowered = lower(
823            &scenario,
824            &ctx(&feature, &packs, &kind_to_engine, &env, &world),
825        )
826        .unwrap();
827        let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
828            panic!("structured payload expected");
829        };
830        assert_eq!(value["goto"], "http://fixture.local/page");
831        assert_eq!(value["checks"][0], "http://fixture.local");
832        assert_eq!(value["checks"][1], 7);
833    }
834
835    /// The `[Options]` injection must never land inside a body: fenced text,
836    /// XML (colon-bearing first line), and JSON bodies all stay untouched.
837    #[test]
838    fn baked_options_never_enter_bodies() {
839        let retry = Some(crate::step::Retry {
840            count: 2,
841            interval_ms: 100,
842        });
843        for body in [
844            "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
845            "POST http://x/a\n<root xmlns:x=\"urn:example\">\n  <child>hi</child>\n</root>\nHTTP 200\n",
846            "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
847        ] {
848            let baked = bake_entry_options(body, retry, None);
849            assert_eq!(
850                baked.matches("[Options]").count(),
851                1,
852                "exactly one options block in:\n{baked}"
853            );
854            let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
855            let body_at = baked
856                .find("```")
857                .or_else(|| baked.find('<'))
858                .or_else(|| baked.find('{'))
859                .unwrap_or(0);
860            assert!(options_at < body_at, "options precede the body:\n{baked}");
861        }
862    }
863
864    #[test]
865    fn engine_change_splits_batches() {
866        let steps: Vec<LoweredStep> = ["hurl", "hurl", "web", "hurl"]
867            .iter()
868            .map(|kind| LoweredStep {
869                step: StepRef {
870                    file: Arc::from("f"),
871                    line: 1,
872                    text: Arc::from("t"),
873                },
874                kind: StepKindId::from(*kind),
875                payload: StepPayload::HurlEntries(String::new()),
876                optional: false,
877                when: None,
878                label: None,
879                save_as: BTreeMap::new(),
880            })
881            .collect();
882        let mapping: BTreeMap<String, String> = [
883            ("hurl".to_owned(), "hurl".to_owned()),
884            ("web".to_owned(), "web".to_owned()),
885        ]
886        .into();
887        let batches = segment(steps, &mapping);
888        let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
889        assert_eq!(sizes, vec![2, 1, 1]);
890        assert_eq!(batches[1].engine.as_str(), "web");
891        // Scenario-wide ordinals — the sidecar `batch` key engines filter by.
892        let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
893        assert_eq!(indexes, vec![0, 1, 2]);
894    }
895}