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