1use 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#[derive(Debug, Clone, Copy)]
25pub struct LowerCtx<'a> {
26 pub feature: &'a FeatureFile,
28 pub packs: &'a PackSet,
30 pub kind_to_engine: &'a BTreeMap<String, String>,
32 pub env: &'a BTreeMap<String, String>,
34 pub run_id: &'a str,
36 pub world: &'a World,
38 pub mode: ResolveMode,
40}
41
42#[derive(Debug)]
44pub struct LoweredScenario {
45 pub name: String,
47 pub tags: Vec<String>,
49 pub line: usize,
51 pub batches: Vec<StepBatch>,
53 pub secrets: BTreeSet<String>,
55 pub globals: BTreeSet<String>,
57 pub warnings: Vec<Diag>,
59}
60
61const MAX_EXPANSION_DEPTH: usize = 32;
63
64#[derive(Debug, Default)]
66struct Refs {
67 secrets: BTreeSet<String>,
68 globals: BTreeSet<String>,
69}
70
71pub 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 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; };
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
126fn 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, 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#[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: ¯o_.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, ¯o_.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 ¯o_.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 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#[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 ¯o_step.kind {
301 MacroStepKind::Use { target, with } => {
302 let Some(target_macro) = ctx.packs.find_use_target(target) else {
303 return; };
305 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 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 let mut resolve = |text: &str| {
348 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 ¯o_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 let label = match ¯o_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
390fn 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
416fn 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; let mut injected_current = false;
439 let mut in_fence = false; for line in text.lines() {
441 let trimmed = line.trim();
442 if trimmed.starts_with("```") {
443 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 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
498fn 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 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
563fn 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
586fn 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 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 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 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 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 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 #[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 #[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 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
893 assert_eq!(indexes, vec![0, 1, 2]);
894 }
895}