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