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 config_vars: &'a BTreeMap<String, String>,
37 pub run_id: &'a str,
39 pub world: &'a World,
41 pub mode: ResolveMode,
43}
44
45#[derive(Debug)]
47pub struct LoweredScenario {
48 pub name: String,
50 pub tags: Vec<String>,
52 pub line: usize,
54 pub batches: Vec<StepBatch>,
56 pub secrets: BTreeMap<String, String>,
62 pub globals: BTreeSet<String>,
64 pub warnings: Vec<Diag>,
66}
67
68const MAX_EXPANSION_DEPTH: usize = 32;
70
71type Bindings = BTreeMap<String, Bound>;
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76enum Bound {
77 Value(String),
80 Secret(String),
84}
85
86fn whole_secret(value: &str) -> Option<&str> {
96 let trimmed = value.trim();
97 let (name, start, end) = crate::resolve::first_reference(trimmed)?;
98 if start != 0 || end != trimmed.len() {
99 return None; }
101 let inner = name.strip_prefix("secret:")?.trim();
102 (!inner.is_empty() && !inner.contains(['{', '$'])).then_some(inner)
103}
104
105fn mentions_secret(value: &str) -> bool {
108 let mut rest = value;
109 while let Some((name, _, end)) = crate::resolve::first_reference(rest) {
110 if name.starts_with("secret:") {
111 return true;
112 }
113 rest = &rest[end..];
114 }
115 false
116}
117
118fn quote_option(value: &str) -> String {
122 value.replace('\\', "\\\\").replace('"', "\\\"")
123}
124
125pub(crate) fn macro_has_ref(macro_: &Macro) -> bool {
130 match ¯o_.body {
131 MacroBody::Steps(steps) => steps
132 .iter()
133 .any(|step| matches!(step.kind, MacroStepKind::Ref { .. })),
134 MacroBody::Expect(_) => false,
135 }
136}
137
138fn scope_bindings(
144 macro_: &Macro,
145 ctx: &LowerCtx<'_>,
146 refs: &mut Refs,
147 sinks: &mut Sinks,
148 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
149 at: &impl Fn(Diag) -> Diag,
150) -> Bindings {
151 let mut scoped = Bindings::new();
152 if !macro_has_ref(macro_) {
153 return scoped;
154 }
155 if let Some(table) = ctx.packs.bind.get(¯o_.pack) {
156 if !refs.pack_bindings.contains_key(¯o_.pack) {
157 let resolved = resolve_bindings(table, refs, sinks, resolve_in, at);
158 refs.pack_bindings.insert(macro_.pack.clone(), resolved);
159 }
160 if let Some(cached) = refs.pack_bindings.get(¯o_.pack) {
161 scoped.extend(cached.clone());
162 }
163 }
164 scoped.extend(resolve_bindings(¯o_.bind, refs, sinks, resolve_in, at));
165 scoped
166}
167
168fn resolve_bindings(
170 table: &BTreeMap<String, String>,
171 refs: &mut Refs,
172 sinks: &mut Sinks,
173 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
174 at: &impl Fn(Diag) -> Diag,
175) -> Bindings {
176 let mut out = Bindings::new();
177 for (name, value) in table {
178 if let Some(secret) = whole_secret(value) {
179 out.insert(name.clone(), Bound::Secret(secret.to_owned()));
180 continue;
181 }
182 if mentions_secret(value) {
183 sinks.errors.push(
184 at(Diag::error(
185 "proef::lower::secret_in_composite_bind",
186 format!(
187 "binding `{name}` mixes a secret into a larger value — the result would have to be written into the artifact to be injected"
188 ),
189 ))
190 .with_help(
191 "bind the secret on its own and put the surrounding text in the fragment \
192 (`Authorization: Bearer {{token}}` with `bind: { token: ${secret:…} }`)",
193 ),
194 );
195 continue;
196 }
197 if let Some(resolved) = resolve_in(value, refs, sinks) {
198 if resolved.contains('\n') {
207 sinks.errors.push(
208 at(Diag::error(
209 "proef::lower::multiline_bind",
210 format!(
211 "binding `{name}` resolves to a multi-line value, which a hurl \
212 `[Options] variable:` cannot carry — it is a single-line scalar"
213 ),
214 ))
215 .with_help(
216 "a multi-line body is what the inline form is for: use `hurl: |` and \
217 splice it with `${…}` (a `${docstring}` body is the usual case)",
218 ),
219 );
220 continue;
221 }
222 out.insert(name.clone(), Bound::Value(resolved));
223 }
224 }
225 out
226}
227
228fn captures_before(out: &[LoweredStep]) -> BTreeSet<String> {
231 let mut names = BTreeSet::new();
232 for step in out {
233 if let StepPayload::HurlEntries(text) = &step.payload {
234 let lines: Vec<&str> = text.lines().collect();
235 names.extend(crate::emit::capture_names(&lines));
236 }
237 }
238 names
239}
240
241#[derive(Debug, Default)]
249struct Sinks {
250 warnings: Vec<Diag>,
252 errors: Vec<Diag>,
254}
255
256#[derive(Debug, Default)]
258struct Refs {
259 secrets: BTreeMap<String, String>,
261 globals: BTreeSet<String>,
262 pack_bindings: BTreeMap<String, Bindings>,
267 fakes: usize,
275}
276
277pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
279 let mut sinks = Sinks::default();
280 let mut refs = Refs::default();
281 let mut lowered: Vec<LoweredStep> = Vec::new();
282 for step in &scenario.steps {
283 let step_ref = StepRef {
284 file: Arc::from(ctx.feature.path.as_str()),
285 line: step.defn.line,
286 text: Arc::from(step.defn.text.as_str()),
287 };
288 let at = |diag: Diag| {
289 diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
290 .with_span(step.defn.span)
291 };
292 let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
293 continue; };
295 expand_macro(
296 macro_,
297 &step.args,
298 &step_ref,
299 ctx,
300 0,
301 &mut lowered,
302 &mut refs,
303 &mut sinks,
304 &at,
305 );
306 }
307
308 for step in &lowered {
314 if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
315 continue; }
317 if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
318 sinks.errors.push(
319 Diag::error(
320 "proef::lower::kind_unrouted",
321 format!(
322 "internal: step kind `{}` is not claimed by any registered engine \
323 (registry/pack-validation drift)",
324 step.kind.as_str()
325 ),
326 )
327 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
328 );
329 }
330 }
331
332 if sinks.errors.iter().any(|d| d.severity == Severity::Error) {
333 return Err(sinks.errors);
334 }
335
336 Ok(LoweredScenario {
337 name: scenario.name.clone(),
338 tags: scenario.tags.clone(),
339 line: scenario.line,
340 batches: segment(lowered, ctx.kind_to_engine),
341 secrets: refs.secrets,
342 globals: refs.globals,
343 warnings: sinks.warnings,
344 })
345}
346
347#[allow(clippy::too_many_arguments)]
349fn expand_macro(
350 macro_: &Macro,
351 args: &BTreeMap<String, String>,
352 step_ref: &StepRef,
353 ctx: &LowerCtx<'_>,
354 depth: usize,
355 out: &mut Vec<LoweredStep>,
356 refs: &mut Refs,
357 sinks: &mut Sinks,
358 at: &impl Fn(Diag) -> Diag,
359) {
360 if depth > MAX_EXPANSION_DEPTH {
361 sinks.errors.push(at(Diag::error(
362 "proef::lower::expansion_too_deep",
363 format!(
364 "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
365 macro_.name
366 ),
367 )));
368 return;
369 }
370
371 let resolve_in = |text: &str, refs: &mut Refs, sinks: &mut Sinks| -> Option<String> {
372 let resolve_ctx = ResolveCtx {
373 args,
374 defaults: ¯o_.defaults,
375 env: ctx.env,
376 config_vars: ctx.config_vars,
377 run_id: ctx.run_id,
378 world: ctx.world,
379 mode: ctx.mode,
380 };
381 match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
382 Ok(resolution) => {
383 refs.secrets
387 .extend(resolution.secrets.into_iter().map(|s| (s.clone(), s)));
388 refs.globals.extend(resolution.globals);
389 push_warnings(sinks, &resolution.warnings, ctx, ¯o_.name);
390 Some(resolution.text)
391 }
392 Err(err) => {
393 sinks.errors.push(at(Diag::error(
394 err.code(),
395 format!("in macro `{}`: {err}", macro_.name),
396 )));
397 None
398 }
399 }
400 };
401
402 let scoped = scope_bindings(macro_, ctx, refs, sinks, &resolve_in, at);
406
407 match ¯o_.body {
408 MacroBody::Expect(items) => {
409 let mut merged: Option<(StepKindId, bool, usize)> = None;
410 for item in items {
411 let status = match &item.status {
412 Some(status) => match resolve_in(status, refs, sinks) {
413 Some(status) => Some(status),
414 None => continue,
415 },
416 None => None,
417 };
418 let fragment = match &item.fragment {
419 Some(fragment) => match resolve_in(fragment, refs, sinks) {
420 Some(fragment) => Some(fragment),
421 None => continue,
422 },
423 None => None,
424 };
425 if let Some((kind, optional, lines)) =
426 merge_expect(status.as_deref(), fragment.as_deref(), out, sinks, at)
427 {
428 let entry = merged.get_or_insert((kind, optional, 0));
429 entry.2 += lines;
430 }
431 }
432 if let Some((kind, optional, lines)) = merged {
436 out.push(LoweredStep {
437 step: step_ref.clone(),
438 kind,
439 payload: StepPayload::MergedAsserts { lines },
440 optional,
441 when: None,
442 label: None,
443 fragment: None,
448 save_as: std::collections::BTreeMap::new(),
449 });
450 }
451 }
452 MacroBody::Steps(steps) => {
453 for macro_step in steps {
454 expand_step(
455 macro_step,
456 step_ref,
457 ctx,
458 depth,
459 out,
460 refs,
461 sinks,
462 at,
463 &resolve_in,
464 &scoped,
465 );
466 }
467 }
468 }
469}
470
471#[allow(clippy::too_many_arguments)]
473fn expand_step(
474 macro_step: &MacroStep,
475 step_ref: &StepRef,
476 ctx: &LowerCtx<'_>,
477 depth: usize,
478 out: &mut Vec<LoweredStep>,
479 refs: &mut Refs,
480 sinks: &mut Sinks,
481 at: &impl Fn(Diag) -> Diag,
482 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
483 scoped: &Bindings,
484) {
485 match ¯o_step.kind {
486 MacroStepKind::Ref { target } => expand_ref_step(
487 target, macro_step, step_ref, ctx, out, refs, sinks, at, resolve_in, scoped,
488 ),
489 MacroStepKind::Use { target, with } => {
490 let Some(target_macro) = ctx.packs.find_use_target(target) else {
491 return; };
493 let mut child_args = BTreeMap::new();
496 for (key, value) in with {
497 if let Some(resolved) = resolve_in(value, refs, sinks) {
498 child_args.insert(key.clone(), resolved);
499 }
500 }
501 expand_macro(
502 target_macro,
503 &child_args,
504 step_ref,
505 ctx,
506 depth + 1,
507 out,
508 refs,
509 sinks,
510 at,
511 );
512 }
513 MacroStepKind::Payload { kind, payload } => expand_payload_step(
514 macro_step, kind, payload, step_ref, out, refs, sinks, resolve_in,
515 ),
516 }
517}
518
519#[allow(clippy::too_many_arguments)]
522fn expand_ref_step(
523 target: &str,
524 macro_step: &MacroStep,
525 step_ref: &StepRef,
526 ctx: &LowerCtx<'_>,
527 out: &mut Vec<LoweredStep>,
528 refs: &mut Refs,
529 sinks: &mut Sinks,
530 at: &impl Fn(Diag) -> Diag,
531 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
532 scoped: &Bindings,
533) {
534 let Some(fragment) = ctx.packs.find_fragment(target) else {
535 return; };
537 let label_fakes_start = refs.fakes;
542 let mut bindings = scoped.clone();
544 bindings.extend(resolve_bindings(
545 ¯o_step.bind,
546 refs,
547 sinks,
548 resolve_in,
549 at,
550 ));
551
552 let unbound: Vec<&str> = fragment
558 .placeholders
559 .iter()
560 .filter(|name| {
561 !bindings.contains_key(name.as_str())
562 && !refs.secrets.contains_key(name.as_str())
563 && !fragment.supplied_variables.contains(name)
572 })
573 .map(String::as_str)
574 .collect();
575 let missing: Vec<&str> = if unbound.is_empty() {
579 Vec::new()
580 } else {
581 let available = captures_before(out);
582 unbound
583 .into_iter()
584 .filter(|name| !available.contains(*name))
585 .collect()
586 };
587 if !missing.is_empty() {
588 let message = format!(
599 "fragment `{}` reads `{}`, which nothing supplies — no `bind:` in scope gives a value, no earlier step captures it, and the fragment sets no `[Options] variable:` of its own",
600 fragment.name,
601 missing.join("`, `"),
602 );
603 sinks.errors.push(
604 Diag::error("proef::lower::unbound_placeholder", message)
605 .with_source(fragment.file.clone(), Arc::clone(&fragment.source))
606 .maybe_span(crate::pack::locate::line_span(
607 &fragment.source,
608 fragment.line,
609 ))
610 .with_help(format!(
611 "add `bind: {{ {}: … }}` to the step, its macro, or the pack — or give \
612 the fragment its own `[Options]` `variable: {}=…`, which also keeps the \
613 file runnable under stock `hurl`",
614 missing[0], missing[0]
615 )),
616 );
617 return;
618 }
619
620 let mut literals: BTreeMap<String, String> = BTreeMap::new();
626 for (name, bound) in bindings {
627 match bound {
628 Bound::Secret(secret) => {
629 refs.secrets.insert(name, secret);
630 }
631 Bound::Value(value) => {
632 literals.insert(name, value);
633 }
634 }
635 }
636 let text = bake_entry_options(
637 &fragment.text,
638 macro_step.retry,
639 macro_step.delay_ms,
640 &literals,
641 );
642 finish_step(
643 macro_step,
644 step_ref,
645 StepKindId::from(fragment.kind.as_str()),
646 StepPayload::HurlEntries(text),
647 Some(fragment.qualified()),
651 label_fakes_start,
652 out,
653 refs,
654 sinks,
655 resolve_in,
656 );
657}
658
659#[allow(clippy::too_many_arguments)]
662fn expand_payload_step(
663 macro_step: &MacroStep,
664 kind: &str,
665 payload: &PayloadForm,
666 step_ref: &StepRef,
667 out: &mut Vec<LoweredStep>,
668 refs: &mut Refs,
669 sinks: &mut Sinks,
670 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
671) {
672 let label_fakes_start = refs.fakes;
679 let payload = match payload {
680 PayloadForm::Raw(text) => {
681 let Some(resolved) = resolve_in(text, refs, sinks) else {
682 return;
683 };
684 let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
688 bake_entry_options(
689 &resolved,
690 macro_step.retry,
691 macro_step.delay_ms,
692 &BTreeMap::new(),
693 )
694 } else {
695 resolved
696 };
697 StepPayload::HurlEntries(resolved)
698 }
699 PayloadForm::Structured(value) => {
700 let mut resolve = |text: &str| {
704 if !text.contains('$') {
707 return Some(text.to_owned());
708 }
709 resolve_in(text, refs, sinks)
710 };
711 match resolve_structured(value, &mut resolve) {
712 Some(resolved) => StepPayload::Structured(resolved),
713 None => return,
714 }
715 }
716 };
717 finish_step(
718 macro_step,
719 step_ref,
720 StepKindId::from(kind),
721 payload,
722 None, label_fakes_start,
724 out,
725 refs,
726 sinks,
727 resolve_in,
728 );
729}
730
731#[allow(clippy::too_many_arguments)]
740fn finish_step(
741 macro_step: &MacroStep,
742 step_ref: &StepRef,
743 kind: StepKindId,
744 payload: StepPayload,
745 fragment: Option<String>,
748 label_fakes_start: usize,
749 out: &mut Vec<LoweredStep>,
750 refs: &mut Refs,
751 sinks: &mut Sinks,
752 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
753) {
754 let when = match ¯o_step.when {
755 Some(guard) => match resolve_in(guard, refs, sinks) {
756 Some(resolved) => Some(Guard(resolved)),
757 None => return,
758 },
759 None => None,
760 };
761 let functional_fakes_end = refs.fakes;
783 let label = match ¯o_step.name {
784 Some(name) => {
785 refs.fakes = label_fakes_start;
786 let resolved = resolve_in(name, refs, sinks);
787 refs.fakes = functional_fakes_end.max(refs.fakes);
788 match resolved {
789 Some(resolved) => Some(resolved),
790 None => return,
791 }
792 }
793 None => None,
794 };
795 out.push(LoweredStep {
796 step: step_ref.clone(),
797 kind,
798 payload,
799 optional: macro_step.optional,
800 when,
801 label,
802 fragment,
803 save_as: macro_step.save_as.clone(),
804 });
805}
806
807fn resolve_structured(
810 value: &serde_json::Value,
811 resolve: &mut dyn FnMut(&str) -> Option<String>,
812) -> Option<serde_json::Value> {
813 use serde_json::Value as J;
814 Some(match value {
815 J::String(text) => J::String(resolve(text)?),
816 J::Array(items) => J::Array(
817 items
818 .iter()
819 .map(|item| resolve_structured(item, resolve))
820 .collect::<Option<_>>()?,
821 ),
822 J::Object(map) => {
823 let mut out = serde_json::Map::new();
824 for (key, item) in map {
825 out.insert(key.clone(), resolve_structured(item, resolve)?);
826 }
827 J::Object(out)
828 }
829 other => other.clone(),
830 })
831}
832
833fn bake_entry_options(
840 text: &str,
841 retry: Option<crate::step::Retry>,
842 delay_ms: Option<u64>,
843 bindings: &BTreeMap<String, String>,
844) -> String {
845 let mut option_lines: Vec<String> = Vec::new();
846 if let Some(retry) = retry {
847 option_lines.push(format!("retry: {}", retry.count));
848 option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
849 }
850 if let Some(delay_ms) = delay_ms {
851 option_lines.push(format!("delay: {delay_ms}ms"));
852 }
853 for (name, value) in bindings {
859 option_lines.push(format!("variable: {name}=\"{}\"", quote_option(value)));
860 }
861 if option_lines.is_empty() {
868 return text.to_owned();
869 }
870 let retry_lines = option_lines.join("\n");
871 let mut author_options = vec![false];
876 let mut in_fence = false;
877 for line in text.lines() {
878 let trimmed = line.trim();
879 if trimmed.starts_with("```") {
880 in_fence = !in_fence;
881 continue;
882 }
883 if in_fence {
884 continue;
885 }
886 if is_method_line(trimmed) {
887 author_options.push(false);
888 } else if trimmed == "[Options]"
889 && let Some(last) = author_options.last_mut()
890 {
891 *last = true;
892 }
893 }
894 let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
895 let mut out: Vec<String> = Vec::new();
896 let mut in_entry_head = false; let mut injected_current = false;
898 let mut in_fence = false; let mut entry = 0usize;
900 for line in text.lines() {
901 let trimmed = line.trim();
902 if trimmed.starts_with("```") {
903 if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
906 out.push("[Options]".to_owned());
907 out.push(retry_lines.clone());
908 injected_current = true;
909 }
910 in_fence = !in_fence;
911 in_entry_head = false;
912 out.push(line.to_owned());
913 continue;
914 }
915 if in_fence {
916 out.push(line.to_owned());
917 continue;
918 }
919 if is_method_line(trimmed) {
920 in_entry_head = true;
921 injected_current = false;
922 entry += 1;
923 out.push(line.to_owned());
924 continue;
925 }
926 if trimmed == "[Options]" {
927 out.push(line.to_owned());
930 if !injected_current {
931 out.push(retry_lines.clone());
932 injected_current = true;
933 }
934 in_entry_head = false;
935 continue;
936 }
937 let is_header = in_entry_head && is_header_line(trimmed);
938 if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
939 out.push("[Options]".to_owned());
940 out.push(retry_lines.clone());
941 injected_current = true;
942 in_entry_head = false;
943 }
944 out.push(line.to_owned());
945 }
946 if in_entry_head && !injected_current {
947 out.push("[Options]".to_owned());
948 out.push(retry_lines.clone());
949 }
950 let mut result = out.join("\n");
951 if text.ends_with('\n') {
952 result.push('\n');
953 }
954 result
955}
956
957fn merge_expect(
964 status: Option<&str>,
965 fragment: Option<&str>,
966 out: &mut [LoweredStep],
967 sinks: &mut Sinks,
968 at: &impl Fn(Diag) -> Diag,
969) -> Option<(StepKindId, bool, usize)> {
970 let Some(previous) = out
971 .iter_mut()
972 .rev()
973 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
974 else {
975 sinks.errors.push(
976 at(Diag::error(
977 "proef::lower::then_before_when",
978 "this assert-only step has no previous request entry to attach to",
979 ))
980 .with_help("a Then step asserts on the request made by an earlier When step"),
981 );
982 return None;
983 };
984
985 if let Some(status) = status
986 && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
987 {
988 sinks.errors.push(at(Diag::error(
989 "proef::lower::bad_status",
990 format!("expected an HTTP status number, got `{status}`"),
991 )));
992 return None;
993 }
994
995 let host_kind = previous.kind.clone();
996 let host_optional = previous.optional;
997 let StepPayload::HurlEntries(text) = &mut previous.payload else {
998 return None;
999 };
1000 let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
1006 if !tail_has_http {
1007 push_line(text, "HTTP *");
1008 }
1009 if !tail_has_asserts {
1010 push_line(text, "[Asserts]");
1011 }
1012 let mut appended = 0usize;
1013 if let Some(status) = status {
1014 push_line(text, &format!("status == {status}"));
1015 appended += 1;
1016 }
1017 if let Some(fragment) = fragment {
1018 for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
1019 push_line(text, line.trim_end());
1020 appended += 1;
1021 }
1022 }
1023 Some((host_kind, host_optional, appended))
1024}
1025
1026fn is_header_line(trimmed: &str) -> bool {
1031 let Some((name, _)) = trimmed.split_once(':') else {
1032 return false;
1033 };
1034 !name.is_empty()
1035 && name != "HTTP"
1036 && name
1037 .chars()
1038 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
1039}
1040
1041pub(crate) fn is_method_line(trimmed: &str) -> bool {
1048 trimmed.split_whitespace().next().is_some_and(|word| {
1049 word.len() >= 3
1050 && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
1051 && word != "HTTP"
1052 }) && trimmed.split_whitespace().count() >= 2
1053}
1054
1055fn last_entry_scan(text: &str) -> (bool, bool) {
1060 let mut in_fence = false;
1061 let (mut has_http, mut has_asserts) = (false, false);
1062 for line in text.lines() {
1063 let trimmed = line.trim();
1064 if trimmed.starts_with("```") {
1065 in_fence = !in_fence;
1066 continue;
1067 }
1068 if in_fence {
1069 continue;
1070 }
1071 if is_method_line(trimmed) {
1072 (has_http, has_asserts) = (false, false);
1073 continue;
1074 }
1075 has_http = has_http || trimmed.starts_with("HTTP");
1076 has_asserts = has_asserts || trimmed == "[Asserts]";
1077 }
1078 (has_http, has_asserts)
1079}
1080
1081fn push_line(text: &mut String, line: &str) {
1082 if !text.is_empty() && !text.ends_with('\n') {
1083 text.push('\n');
1084 }
1085 text.push_str(line);
1086 text.push('\n');
1087}
1088
1089fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
1093 let mut batches: Vec<StepBatch> = Vec::new();
1094 for step in steps {
1095 let engine = kind_to_engine
1099 .get(step.kind.as_str())
1100 .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
1101 let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
1104 let start_new = match batches.last() {
1105 None => true,
1106 Some(last) => {
1107 !glued
1108 && (last.engine.as_str() != engine
1109 || step.optional
1110 || last.steps.last().is_some_and(|s| s.optional))
1111 }
1112 };
1113 if start_new {
1114 batches.push(StepBatch {
1115 index: batches.len(),
1116 engine: crate::engine::EngineId::from(engine.as_str()),
1117 steps: vec![step],
1118 });
1119 } else if let Some(last) = batches.last_mut() {
1120 last.steps.push(step);
1121 }
1122 }
1123 batches
1124}
1125
1126fn push_warnings(sinks: &mut Sinks, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
1127 for text in texts {
1128 sinks.warnings.push(
1129 Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
1130 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
1131 );
1132 }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 #![allow(clippy::unwrap_used, clippy::expect_used)]
1138
1139 use super::*;
1140 use crate::engine::StepKindSpec;
1141 use crate::pack::{self, PackSource};
1142 use crate::step::StepPayload;
1143
1144 const KINDS: &[StepKindSpec] = &[StepKindSpec {
1145 prefix: "hurl",
1146 schema: "true",
1147 validate: None,
1148 fragments: None,
1149 options: None,
1150 }];
1151
1152 const PACK: &str = r#"macros:
1153 auth:
1154 params: [token]
1155 steps:
1156 - name: authenticate
1157 hurl: |
1158 POST ${url:base}/auth
1159 Authorization: Bearer ${token}
1160 HTTP 200
1161 search:
1162 params: [term]
1163 match: "I search for {term}"
1164 steps:
1165 - use: auth
1166 with: { token: "${secret:apiToken}" }
1167 - name: run the search
1168 hurl: |
1169 GET ${url:base}/search?q=${term}
1170 HTTP 200
1171 [Captures]
1172 recordId: jsonpath "$[0].id"
1173 checkHealth:
1174 match: the service is healthy
1175 steps:
1176 - optional: true
1177 hurl: |
1178 GET ${url:base}/health
1179 expectStatus:
1180 params: [status]
1181 match: "the response status is {status}"
1182 expect:
1183 - status: "${status}"
1184"#;
1185
1186 fn fixture() -> (
1187 crate::feature::FeatureFile,
1188 crate::bind::BoundScenario,
1189 PackSet,
1190 ) {
1191 let packs = pack::load(
1192 &[PackSource {
1193 name: "test.yaml".into(),
1194 text: Arc::from(PACK),
1195 }],
1196 &crate::pack::FragmentCorpus::empty(),
1197 KINDS,
1198 )
1199 .unwrap();
1200 let feature = crate::feature::parse(
1201 "t.feature",
1202 "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",
1203 )
1204 .unwrap();
1205 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1206 (feature, scenario, packs)
1207 }
1208
1209 fn ctx<'a>(
1210 feature: &'a crate::feature::FeatureFile,
1211 packs: &'a PackSet,
1212 kind_to_engine: &'a BTreeMap<String, String>,
1213 env: &'a BTreeMap<String, String>,
1214 config_vars: &'a BTreeMap<String, String>,
1215 world: &'a World,
1216 ) -> LowerCtx<'a> {
1217 LowerCtx {
1218 feature,
1219 packs,
1220 kind_to_engine,
1221 env,
1222 config_vars,
1223 run_id: "run-0001",
1224 world,
1225 mode: ResolveMode::DryRun,
1226 }
1227 }
1228
1229 #[test]
1230 fn expansion_resolution_merge_and_segmentation_work_together() {
1231 let (feature, scenario, packs) = fixture();
1232 let kind_to_engine: BTreeMap<String, String> =
1233 [("hurl".to_owned(), "hurl".to_owned())].into();
1234 let env = BTreeMap::new();
1235 let config_vars =
1236 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1237 let world = World::default();
1238 let lowered = lower(
1239 &scenario,
1240 &ctx(
1241 &feature,
1242 &packs,
1243 &kind_to_engine,
1244 &env,
1245 &config_vars,
1246 &world,
1247 ),
1248 )
1249 .unwrap();
1250
1251 assert_eq!(lowered.batches.len(), 2);
1255 assert_eq!(lowered.batches[0].steps.len(), 1);
1256 assert!(lowered.batches[0].steps[0].optional);
1257 assert_eq!(lowered.batches[1].steps.len(), 3);
1258 let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
1259 panic!("expected a merged-asserts step for the Then line");
1260 };
1261 assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
1262
1263 let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
1265 panic!("expected hurl entries");
1266 };
1267 assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
1268 assert!(
1269 auth.contains("Bearer {{apiToken}}"),
1270 "secret placeholder: {auth}"
1271 );
1272 assert!(lowered.secrets.contains_key("apiToken"));
1273
1274 let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
1276 panic!("expected hurl entries");
1277 };
1278 assert!(
1279 search.contains("GET http://fixture.local/search?q=Jansen"),
1280 "{search}"
1281 );
1282 assert!(search.contains("[Asserts]"), "{search}");
1283 assert!(search.trim_end().ends_with("status == 200"), "{search}");
1284
1285 assert_eq!(lowered.batches[1].steps[1].step.line, 4);
1287 assert_eq!(
1288 lowered.batches[1].steps[0].label.as_deref(),
1289 Some("authenticate")
1290 );
1291 }
1292
1293 #[test]
1294 fn then_before_when_is_an_error() {
1295 let (_, _, packs) = fixture();
1296 let feature = crate::feature::parse(
1297 "t.feature",
1298 "Feature: F\n Scenario: S\n Then the response status is 200\n",
1299 )
1300 .unwrap();
1301 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1302 let kind_to_engine = BTreeMap::new();
1303 let env = BTreeMap::new();
1304 let config_vars = BTreeMap::new();
1305 let world = World::default();
1306 let errs = lower(
1307 &scenario,
1308 &ctx(
1309 &feature,
1310 &packs,
1311 &kind_to_engine,
1312 &env,
1313 &config_vars,
1314 &world,
1315 ),
1316 )
1317 .unwrap_err();
1318 assert_eq!(errs[0].code, "proef::lower::then_before_when");
1319 }
1320
1321 #[test]
1331 fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
1332 const PACK: &str = r#"macros:
1333 ping:
1334 match: the service is pinged
1335 steps:
1336 - hurl: |
1337 GET ${url:base}/ping
1338 HTTP 200
1339 expectBlank:
1340 match: nothing extra is asserted
1341 expect:
1342 - hurl: "${vars:blank}"
1343"#;
1344 let packs = pack::load(
1345 &[PackSource {
1346 name: "test.yaml".into(),
1347 text: Arc::from(PACK),
1348 }],
1349 &crate::pack::FragmentCorpus::empty(),
1350 KINDS,
1351 )
1352 .unwrap();
1353 let feature = crate::feature::parse(
1354 "t.feature",
1355 "Feature: F\n Scenario: S\n Given the service is pinged\n Then nothing extra is asserted\n",
1356 )
1357 .unwrap();
1358 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1359 let kind_to_engine: BTreeMap<String, String> =
1360 [("hurl".to_owned(), "hurl".to_owned())].into();
1361 let env = BTreeMap::new();
1362 let config_vars = BTreeMap::from([
1363 ("url:base".to_owned(), "http://fixture.local".to_owned()),
1364 ("vars:blank".to_owned(), String::new()),
1365 ]);
1366 let world = World::default();
1367 let lowered = lower(
1368 &scenario,
1369 &ctx(
1370 &feature,
1371 &packs,
1372 &kind_to_engine,
1373 &env,
1374 &config_vars,
1375 &world,
1376 ),
1377 )
1378 .unwrap();
1379
1380 assert_eq!(lowered.batches[0].steps.len(), 2);
1381 let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
1382 panic!("expected a merged-asserts step for the Then line");
1383 };
1384 assert_eq!(lines, 0, "the fragment resolved to nothing");
1385
1386 let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
1387 for entry in &artifact.map.entries {
1388 let [start, end] = entry.hurl_lines;
1389 assert!(
1390 start <= end,
1391 "inverted span for a zero-line merge: {start}..{end}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1399 fn structured_payloads_resolve_placeholders_recursively() {
1400 const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
1401 prefix: "alt",
1402 schema: "true",
1403 validate: None,
1404 fragments: None,
1405 options: None,
1406 }];
1407 let packs = pack::load(
1408 &[PackSource {
1409 name: "alt.yaml".into(),
1410 text: Arc::from(
1411 "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",
1412 ),
1413 }],
1414 &crate::pack::FragmentCorpus::empty(),
1415 ALT_KINDS,
1416 )
1417 .unwrap();
1418 let feature = crate::feature::parse(
1419 "t.feature",
1420 "Feature: F\n Scenario: S\n When the alternate step runs\n",
1421 )
1422 .unwrap();
1423 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1424 let kind_to_engine: BTreeMap<String, String> =
1425 [("alt".to_owned(), "alt".to_owned())].into();
1426 let env = BTreeMap::new();
1427 let config_vars =
1428 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1429 let world = World::default();
1430 let lowered = lower(
1431 &scenario,
1432 &ctx(
1433 &feature,
1434 &packs,
1435 &kind_to_engine,
1436 &env,
1437 &config_vars,
1438 &world,
1439 ),
1440 )
1441 .unwrap();
1442 let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
1443 panic!("structured payload expected");
1444 };
1445 assert_eq!(value["target"], "http://fixture.local/item");
1446 assert_eq!(value["checks"][0], "http://fixture.local");
1447 assert_eq!(value["checks"][1], 7);
1448 }
1449
1450 #[test]
1454 fn expect_merge_scopes_to_the_last_entry() {
1455 let packs = pack::load(
1456 &[PackSource {
1457 name: "multi.yaml".into(),
1458 text: Arc::from(
1459 "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",
1460 ),
1461 }],
1462 &crate::pack::FragmentCorpus::empty(),
1463 KINDS,
1464 )
1465 .unwrap();
1466 let feature = crate::feature::parse(
1467 "t.feature",
1468 "Feature: F\n Scenario: S\n When both calls run\n Then the response status is 201\n",
1469 )
1470 .unwrap();
1471 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1472 let kind_to_engine: BTreeMap<String, String> =
1473 [("hurl".to_owned(), "hurl".to_owned())].into();
1474 let env = BTreeMap::new();
1475 let config_vars = BTreeMap::new();
1476 let world = World::default();
1477 let lowered = lower(
1478 &scenario,
1479 &ctx(
1480 &feature,
1481 &packs,
1482 &kind_to_engine,
1483 &env,
1484 &config_vars,
1485 &world,
1486 ),
1487 )
1488 .unwrap();
1489 let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
1490 panic!("expected hurl entries");
1491 };
1492 let tail = text.split("GET http://x/b").nth(1).unwrap();
1496 assert!(tail.contains("HTTP *"), "{text}");
1497 assert!(tail.contains("[Asserts]"), "{text}");
1498 assert!(tail.contains("status == 201"), "{text}");
1499 }
1500
1501 #[test]
1505 fn baked_options_extend_a_late_author_options_section() {
1506 let retry = Some(crate::step::Retry {
1507 count: 2,
1508 interval_ms: 100,
1509 });
1510 let body =
1511 "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
1512 let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1513 assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
1514 assert!(
1515 baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
1516 "{baked}"
1517 );
1518 }
1519
1520 #[test]
1523 fn baked_options_never_enter_bodies() {
1524 let retry = Some(crate::step::Retry {
1525 count: 2,
1526 interval_ms: 100,
1527 });
1528 for body in [
1529 "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
1530 "POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
1531 "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
1532 ] {
1533 let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1534 assert_eq!(
1535 baked.matches("[Options]").count(),
1536 1,
1537 "exactly one options block in:\n{baked}"
1538 );
1539 let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
1540 let body_at = baked
1541 .find("```")
1542 .or_else(|| baked.find('<'))
1543 .or_else(|| baked.find('{'))
1544 .unwrap_or(0);
1545 assert!(options_at < body_at, "options precede the body:\n{baked}");
1546 }
1547 }
1548
1549 #[test]
1550 fn engine_change_splits_batches() {
1551 let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1552 .iter()
1553 .map(|kind| LoweredStep {
1554 step: StepRef {
1555 file: Arc::from("f"),
1556 line: 1,
1557 text: Arc::from("t"),
1558 },
1559 kind: StepKindId::from(*kind),
1560 payload: StepPayload::HurlEntries(String::new()),
1561 optional: false,
1562 when: None,
1563 label: None,
1564 fragment: None,
1565 save_as: BTreeMap::new(),
1566 })
1567 .collect();
1568 let mapping: BTreeMap<String, String> = [
1569 ("hurl".to_owned(), "hurl".to_owned()),
1570 ("alt".to_owned(), "alt".to_owned()),
1571 ]
1572 .into();
1573 let batches = segment(steps, &mapping);
1574 let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1575 assert_eq!(sizes, vec![2, 1, 1]);
1576 assert_eq!(batches[1].engine.as_str(), "alt");
1577 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1579 assert_eq!(indexes, vec![0, 1, 2]);
1580 }
1581
1582 #[test]
1589 fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
1590 const FAKE_PACK: &str = r#"macros:
1591 searchFor:
1592 params: [term]
1593 match: "the operator searches for {term}"
1594 steps:
1595 - name: "search for ${term}"
1596 hurl: |
1597 GET ${url:base}/search
1598 [Query]
1599 q: ${term}
1600 HTTP 200
1601 pingFake:
1602 match: a fresh fake is requested
1603 steps:
1604 - hurl: |
1605 GET ${url:base}/ping
1606 [Query]
1607 v: ${fake:lastName}
1608 HTTP 200
1609"#;
1610 let packs = pack::load(
1611 &[PackSource {
1612 name: "fakes.yaml".into(),
1613 text: Arc::from(FAKE_PACK),
1614 }],
1615 &crate::pack::FragmentCorpus::empty(),
1616 KINDS,
1617 )
1618 .unwrap();
1619 let feature = crate::feature::parse(
1620 "t.feature",
1621 "Feature: F\n Scenario: S\n When the operator searches for ${fake:lastName}\n Then a fresh fake is requested\n",
1622 )
1623 .unwrap();
1624 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1625 let kind_to_engine: BTreeMap<String, String> =
1626 [("hurl".to_owned(), "hurl".to_owned())].into();
1627 let env = BTreeMap::new();
1628 let config_vars =
1629 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1630 let world = World::default();
1631 let lowered = lower(
1632 &scenario,
1633 &ctx(
1634 &feature,
1635 &packs,
1636 &kind_to_engine,
1637 &env,
1638 &config_vars,
1639 &world,
1640 ),
1641 )
1642 .unwrap();
1643
1644 assert_eq!(lowered.batches[0].steps.len(), 2);
1646 let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
1647 panic!("expected hurl entries");
1648 };
1649 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1650
1651 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1654 assert!(
1655 search.contains(&format!("q: {occurrence_0}")),
1656 "payload: {search}"
1657 );
1658 assert!(label.contains(&occurrence_0), "label: {label}");
1659
1660 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1663 panic!("expected hurl entries");
1664 };
1665 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1666 assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
1667 }
1668
1669 #[test]
1676 fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
1677 const FAKE_PACK: &str = r#"macros:
1678 unmirroredLabel:
1679 match: a label mentions more fakes than its payload
1680 steps:
1681 - name: "${fake:lastName} vs ${fake:lastName}"
1682 hurl: |
1683 GET ${url:base}/probe
1684 [Query]
1685 q: ${fake:lastName}
1686 HTTP 200
1687 pingFake:
1688 match: a fresh fake is requested
1689 steps:
1690 - hurl: |
1691 GET ${url:base}/ping
1692 [Query]
1693 v: ${fake:lastName}
1694 HTTP 200
1695"#;
1696 let packs = pack::load(
1697 &[PackSource {
1698 name: "unmirrored.yaml".into(),
1699 text: Arc::from(FAKE_PACK),
1700 }],
1701 &crate::pack::FragmentCorpus::empty(),
1702 KINDS,
1703 )
1704 .unwrap();
1705 let feature = crate::feature::parse(
1706 "t.feature",
1707 "Feature: F\n Scenario: S\n When a label mentions more fakes than its payload\n Then a fresh fake is requested\n",
1708 )
1709 .unwrap();
1710 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1711 let kind_to_engine: BTreeMap<String, String> =
1712 [("hurl".to_owned(), "hurl".to_owned())].into();
1713 let env = BTreeMap::new();
1714 let config_vars =
1715 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1716 let world = World::default();
1717 let lowered = lower(
1718 &scenario,
1719 &ctx(
1720 &feature,
1721 &packs,
1722 &kind_to_engine,
1723 &env,
1724 &config_vars,
1725 &world,
1726 ),
1727 )
1728 .unwrap();
1729
1730 assert_eq!(lowered.batches[0].steps.len(), 2);
1732 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1733 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1734 panic!("expected hurl entries");
1735 };
1736
1737 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1741 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1742 assert!(label.contains(&occurrence_0), "label: {label}");
1743 assert!(label.contains(&occurrence_1), "label: {label}");
1744
1745 let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
1749 assert!(
1750 !ping.contains(&format!("v: {occurrence_1}")),
1751 "the next step's fake reused an occurrence the label already \
1752 displayed: {ping}"
1753 );
1754 assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
1755 }
1756
1757 #[allow(clippy::unnecessary_wraps)]
1766 fn frag_scan(
1767 text: &str,
1768 ) -> Result<crate::engine::ScannedFile, crate::engine::FragmentScanError> {
1769 let mut out: Vec<crate::engine::ScannedFragment> = Vec::new();
1770 for (index, line) in text.lines().enumerate() {
1771 let line = line.trim();
1772 if let Some(name) = line.strip_prefix('@') {
1773 out.push(crate::engine::ScannedFragment {
1774 name: name.to_owned(),
1775 text: format!("GET http://x/{name}\nHTTP 200\n"),
1776 line: index + 1,
1777 placeholders: Vec::new(),
1778 declared_options: Vec::new(),
1779 supplied_variables: Vec::new(),
1780 });
1781 } else if let Some(last) = out.last_mut() {
1782 if let Some(read) = line.strip_prefix('?') {
1783 last.placeholders.push(read.to_owned());
1784 } else if let Some(supplied) = line.strip_prefix('=') {
1785 let head = last.text.find("HTTP ").unwrap_or(last.text.len());
1791 let section = if last.text[..head].contains("[Options]") {
1792 format!("variable: {supplied}=from-fragment\n")
1793 } else {
1794 format!("[Options]\nvariable: {supplied}=from-fragment\n")
1795 };
1796 last.text.insert_str(head, §ion);
1797 last.supplied_variables.push(supplied.to_owned());
1798 } else if let Some(write) = line.strip_prefix('!') {
1799 if !last.text.contains("[Captures]") {
1803 last.text.push_str("[Captures]\n");
1804 }
1805 last.text.push_str(write);
1806 last.text.push_str(": jsonpath \"$.id\"\n");
1807 }
1808 }
1809 }
1810 Ok(crate::engine::ScannedFile {
1811 fragments: out,
1812 unannotated: Vec::new(),
1813 })
1814 }
1815
1816 const FRAG_KINDS: &[StepKindSpec] = &[StepKindSpec {
1817 prefix: "hurl",
1818 schema: "true",
1819 validate: None,
1820 fragments: Some(crate::engine::FragmentSupport {
1821 ext: "frag",
1822 scan: frag_scan,
1823 }),
1824 options: None,
1825 }];
1826
1827 fn lower_fragments(pack: &str, fragments: &str) -> Result<LoweredScenario, Vec<Diag>> {
1829 let packs = pack::load(
1830 &[PackSource {
1831 name: "p.yaml".into(),
1832 text: Arc::from(pack),
1833 }],
1834 &pack::FragmentCorpus::new(
1835 vec![PackSource {
1836 name: "api.frag".into(),
1837 text: Arc::from(fragments),
1838 }],
1839 FRAG_KINDS,
1840 ),
1841 FRAG_KINDS,
1842 )
1843 .unwrap_or_else(|err| panic!("pack should load: {err:?}"));
1844 let feature =
1845 crate::feature::parse("t.feature", "Feature: F\n Scenario: S\n When it runs\n")
1846 .unwrap();
1847 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1848 let kind_to_engine = BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]);
1849 let env = BTreeMap::new();
1850 let config_vars = BTreeMap::from([("url:base".to_owned(), "http://api".to_owned())]);
1851 let world = World::new(crate::world::GlobalStore::default());
1852 let ctx = ctx(
1853 &feature,
1854 &packs,
1855 &kind_to_engine,
1856 &env,
1857 &config_vars,
1858 &world,
1859 );
1860 lower(&scenario, &ctx)
1861 }
1862
1863 fn only_entry(lowered: &LoweredScenario) -> &str {
1864 let step = lowered
1865 .batches
1866 .iter()
1867 .flat_map(|b| b.steps.iter())
1868 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
1869 .expect("one hurl entry");
1870 let StepPayload::HurlEntries(text) = &step.payload else {
1871 unreachable!()
1872 };
1873 text
1874 }
1875
1876 #[test]
1880 fn bindings_cascade_and_are_injected_as_entry_options() {
1881 let lowered = lower_fragments(
1882 "bind:\n base: ${url:base}\n who: pack\nmacros:\n m:\n match: it runs\n bind:\n who: macro\n extra: yes\n steps:\n - ref: f\n bind:\n who: step\n",
1883 "@f\n?base\n?who\n?extra\n",
1884 )
1885 .expect("lowers");
1886 let text = only_entry(&lowered);
1887 assert!(text.contains("[Options]"), "{text}");
1888 assert!(text.contains(r#"variable: base="http://api""#), "{text}");
1889 assert!(
1890 text.contains(r#"variable: who="step""#),
1891 "step scope wins: {text}"
1892 );
1893 assert!(!text.contains(r#"who="macro""#) && !text.contains(r#"who="pack""#));
1894 assert!(text.contains(r#"variable: extra="yes""#), "{text}");
1895 }
1896
1897 #[test]
1901 fn a_secret_binding_is_renamed_not_written() {
1902 let lowered = lower_fragments(
1903 "macros:\n m:\n match: it runs\n bind:\n auth_token: ${secret:apiToken}\n steps:\n - ref: f\n",
1904 "@f\n?auth_token\n",
1905 )
1906 .expect("lowers");
1907 let text = only_entry(&lowered);
1908 assert!(
1909 !text.contains("auth_token=") && !text.contains("apiToken"),
1910 "no secret may reach the artifact: {text}"
1911 );
1912 assert_eq!(
1913 lowered.secrets.get("auth_token").map(String::as_str),
1914 Some("apiToken")
1915 );
1916 }
1917
1918 #[test]
1919 fn a_secret_mixed_into_a_larger_value_is_refused() {
1920 let diags = lower_fragments(
1921 "macros:\n m:\n match: it runs\n bind:\n auth: \"Bearer ${secret:apiToken}\"\n steps:\n - ref: f\n",
1922 "@f\n?auth\n",
1923 )
1924 .expect_err("should refuse");
1925 assert!(
1926 diags
1927 .iter()
1928 .any(|d| d.code == "proef::lower::secret_in_composite_bind"),
1929 "{diags:?}"
1930 );
1931 }
1932
1933 #[test]
1938 fn an_escaped_secret_reference_is_a_literal_not_a_secret() {
1939 let lowered = lower_fragments(
1940 "macros:\n m:\n match: it runs\n bind:\n hint: $${secret:apiToken}\n steps:\n - ref: f\n",
1941 "@f\n?hint\n",
1942 )
1943 .expect("an escaped reference is ordinary text");
1944 assert!(
1945 lowered.secrets.is_empty(),
1946 "nothing was bound to a secret: {:?}",
1947 lowered.secrets
1948 );
1949 assert!(
1950 only_entry(&lowered).contains(r#"variable: hint="${secret:apiToken}""#),
1951 "the literal is injected verbatim: {}",
1952 only_entry(&lowered)
1953 );
1954 }
1955
1956 #[test]
1961 fn a_variable_the_fragment_supplies_itself_needs_no_binding() {
1962 let lowered = lower_fragments(
1963 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n",
1964 "@first\n=token\n?token\n",
1965 )
1966 .expect("a fragment that supplies its own variable lowers");
1967 let text = lowered
1968 .batches
1969 .iter()
1970 .flat_map(|b| b.steps.iter())
1971 .find_map(|s| match &s.payload {
1972 StepPayload::HurlEntries(text) => Some(text.clone()),
1973 _ => None,
1974 })
1975 .expect("hurl entries");
1976 assert_eq!(
1979 text.matches("variable: token=").count(),
1980 1,
1981 "exactly one supplier reaches the entry: {text}"
1982 );
1983 }
1984
1985 #[test]
1990 fn a_multiline_binding_is_refused_by_name() {
1991 let err = lower_fragments(
1992 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n bind:\n body: |\n one\n two\n",
1993 "@first\n?body\n",
1994 )
1995 .expect_err("a multi-line binding cannot be carried");
1996 let diag = err
1997 .iter()
1998 .find(|d| d.code == "proef::lower::multiline_bind")
1999 .unwrap_or_else(|| panic!("expected multiline_bind in {err:?}"));
2000 assert!(diag.message.contains("body"), "{}", diag.message);
2001 }
2002
2003 #[test]
2007 fn a_placeholder_nothing_supplies_is_refused() {
2008 let diags = lower_fragments(
2009 "macros:\n m:\n match: it runs\n steps:\n - ref: f\n",
2010 "@f\n?missingOne\n",
2011 )
2012 .expect_err("should refuse");
2013 let diag = diags
2014 .iter()
2015 .find(|d| d.code == "proef::lower::unbound_placeholder")
2016 .unwrap_or_else(|| panic!("expected unbound_placeholder in {diags:?}"));
2017 assert!(diag.message.contains("missingOne"), "{}", diag.message);
2018 assert!(diag.help.is_some());
2019 }
2020
2021 #[test]
2028 fn one_binding_is_one_value_across_a_macros_steps() {
2029 let lowered = lower_fragments(
2030 "macros:\n m:\n match: it runs\n bind:\n shared: ${fake:email}\n steps:\n - ref: first\n bind:\n own: ${fake:email}\n - ref: second\n bind:\n own: ${fake:email}\n",
2031 "@first\n?shared\n?own\n@second\n?shared\n?own\n",
2032 )
2033 .expect("lowers");
2034 let entries: Vec<&str> = lowered
2035 .batches
2036 .iter()
2037 .flat_map(|b| b.steps.iter())
2038 .filter_map(|s| match &s.payload {
2039 StepPayload::HurlEntries(text) => Some(text.as_str()),
2040 _ => None,
2041 })
2042 .collect();
2043 assert_eq!(entries.len(), 2);
2044 let shared = |text: &str| {
2045 text.lines()
2046 .find(|l| l.starts_with("variable: shared="))
2047 .expect("shared binding")
2048 .to_owned()
2049 };
2050 let own = |text: &str| {
2051 text.lines()
2052 .find(|l| l.starts_with("variable: own="))
2053 .expect("own binding")
2054 .to_owned()
2055 };
2056 assert_eq!(
2057 shared(entries[0]),
2058 shared(entries[1]),
2059 "one macro-scope binding is one value for the whole macro"
2060 );
2061 assert_ne!(
2062 own(entries[0]),
2063 own(entries[1]),
2064 "two step-scope bindings are two values"
2065 );
2066 }
2067
2068 #[test]
2074 fn a_ref_steps_label_replays_its_binding_instead_of_minting_a_fresh_value() {
2075 let labelled = lower_fragments(
2076 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n name: signup ${fake:email}\n bind:\n who: ${fake:email}\n - ref: second\n bind:\n who: ${fake:email}\n",
2077 "@first\n?who\n@second\n?who\n",
2078 )
2079 .expect("lowers");
2080 let steps: Vec<&LoweredStep> = labelled
2081 .batches
2082 .iter()
2083 .flat_map(|b| b.steps.iter())
2084 .collect();
2085 assert_eq!(steps.len(), 2);
2086 let who = |step: &LoweredStep| {
2087 let StepPayload::HurlEntries(text) = &step.payload else {
2088 unreachable!()
2089 };
2090 text.lines()
2091 .find_map(|l| l.strip_prefix("variable: who="))
2092 .expect("who binding")
2093 .trim_matches('"')
2094 .to_owned()
2095 };
2096 assert_eq!(
2097 steps[0].label.as_deref(),
2098 Some(format!("signup {}", who(steps[0])).as_str()),
2099 "the label must report the value its own binding sent"
2100 );
2101
2102 let control = lower_fragments(
2105 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n bind:\n who: ${fake:email}\n - ref: second\n bind:\n who: ${fake:email}\n",
2106 "@first\n?who\n@second\n?who\n",
2107 )
2108 .expect("lowers");
2109 let control_steps: Vec<&LoweredStep> = control
2110 .batches
2111 .iter()
2112 .flat_map(|b| b.steps.iter())
2113 .collect();
2114 assert_eq!(
2115 who(steps[1]),
2116 who(control_steps[1]),
2117 "a label must not shift a later step's fake values"
2118 );
2119 }
2120
2121 mod properties {
2122 #![allow(clippy::ignored_unit_patterns)]
2123
2124 use super::*;
2125 use proptest::prelude::*;
2126
2127 proptest! {
2128 #[test]
2138 fn a_renamed_secret_binds_by_name_and_never_by_value(
2139 variable in "[a-z][a-z_]{2,12}",
2140 secret in "[a-zA-Z][a-zA-Z0-9]{3,12}",
2141 literal in "[a-z][a-z0-9]{2,10}",
2142 ) {
2143 prop_assume!(variable != "plain");
2146 let pack = format!(
2147 "macros:\n m:\n match: it runs\n bind:\n {variable}: ${{secret:{secret}}}\n plain: {literal}\n steps:\n - ref: f\n"
2148 );
2149 let fragments = format!("@f\n?{variable}\n?plain\n");
2150 let lowered = lower_fragments(&pack, &fragments)
2151 .unwrap_or_else(|d| panic!("should lower: {d:?}"));
2152 let text = only_entry(&lowered);
2153
2154 let variable_line = format!("variable: {variable}=");
2157 prop_assert!(!text.contains(&variable_line));
2158 prop_assert!(!text.contains(&secret));
2159 let plain_line = format!("variable: plain=\"{literal}\"");
2162 prop_assert!(text.contains(&plain_line));
2163 prop_assert_eq!(
2165 lowered.secrets.get(&variable).map(String::as_str),
2166 Some(secret.as_str())
2167 );
2168 }
2169 }
2170 }
2171
2172 #[test]
2175 fn a_capture_from_an_earlier_step_supplies_a_later_fragment() {
2176 lower_fragments(
2177 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n - ref: second\n",
2178 "@first\n!recordId\n@second\n?recordId\n",
2179 )
2180 .expect("a preceding capture supplies it");
2181 }
2182}