1use std::fmt::Write as _;
16
17use serde::Serialize;
18
19use crate::lower::{LoweredScenario, is_method_line};
20use crate::step::{StepPayload, StepRef};
21use crate::world::World;
22
23pub const MAP_SCHEMA_VERSION: u32 = 1;
25
26#[derive(Debug, Clone)]
28pub struct Artifact {
29 pub slug: String,
31 pub hurl_text: String,
33 pub map: SidecarMap,
35 pub vars: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct SidecarMap {
42 pub schema: u32,
44 pub entries: Vec<MapEntry>,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct MapEntry {
51 pub hurl_lines: [usize; 2],
53 pub feature: FeatureAnchor,
55 pub optional: bool,
57 pub captures: Vec<String>,
59 pub batch: usize,
61 pub step: usize,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct FeatureAnchor {
70 pub file: String,
72 pub line: usize,
74 pub text: String,
76}
77
78pub fn emit(scenario: &LoweredScenario, feature_stem: &str, world: &World) -> Option<Artifact> {
81 let slug = format!("{}--{}", slugify(feature_stem), slugify(&scenario.name));
82 let has_vars = !scenario.globals.is_empty() || !scenario.secrets.is_empty();
83
84 let mut steps: Vec<(usize, usize, &crate::step::LoweredStep)> = Vec::new();
85 for (batch_index, batch) in scenario.batches.iter().enumerate() {
86 for (step_index, step) in batch.steps.iter().enumerate() {
87 if matches!(
88 step.payload,
89 StepPayload::HurlEntries(_) | StepPayload::MergedAsserts { .. }
90 ) {
91 steps.push((batch_index, step_index, step));
92 }
93 }
94 }
95 let (_, _, first_step) = *steps
98 .iter()
99 .find(|(_, _, s)| matches!(s.payload, StepPayload::HurlEntries(_)))?;
100
101 let mut text = String::new();
102 let mut line = 0usize;
103 let push_line = |text: &mut String, line: &mut usize, content: &str| {
104 text.push_str(content);
105 text.push('\n');
106 *line += 1;
107 };
108
109 push_line(
110 &mut text,
111 &mut line,
112 &format!("# proef artifact — {}", scenario.name),
113 );
114 push_line(
115 &mut text,
116 &mut line,
117 &format!("# source: {}:{}", first_step.step.file, scenario.line),
118 );
119 let mut replay = format!("# replay: hurl --test {slug}.hurl");
120 if has_vars {
121 let _ = write!(replay, " --variables-file {slug}.vars");
122 }
123 for variable in scenario.secrets.keys() {
124 let _ = write!(replay, " --secret {variable}=<value>");
128 }
129 push_line(&mut text, &mut line, &replay);
130
131 let mut entries = Vec::new();
132 let mut index = 0usize;
133 while index < steps.len() {
134 let (batch_index, step_index, step) = steps[index];
135 let StepPayload::HurlEntries(payload) = &step.payload else {
136 index += 1;
139 continue;
140 };
141 push_line(&mut text, &mut line, "");
142 push_line(
143 &mut text,
144 &mut line,
145 &entry_comment(&step.step, step.label.as_deref()),
146 );
147 if step.optional {
148 push_line(&mut text, &mut line, "# optional");
149 }
150 let body: Vec<&str> = trimmed_lines(payload);
151 let start = line + 1;
152 for body_line in &body {
153 push_line(&mut text, &mut line, body_line);
154 }
155 entries.push(MapEntry {
156 hurl_lines: [start, line],
157 feature: FeatureAnchor {
158 file: step.step.file.to_string(),
159 line: step.step.line,
160 text: step.step.text.to_string(),
161 },
162 optional: step.optional,
163 captures: capture_names(&body),
164 batch: batch_index,
165 step: step_index,
166 });
167
168 index += 1;
171 let first_merged = index;
172 while index < steps.len()
173 && matches!(steps[index].2.payload, StepPayload::MergedAsserts { .. })
174 {
175 index += 1;
176 }
177 entries.extend(merged_map_entries(&steps[first_merged..index], line));
178 }
179
180 Some(Artifact {
181 hurl_text: text,
182 map: SidecarMap {
183 schema: MAP_SCHEMA_VERSION,
184 entries,
185 },
186 vars: has_vars.then(|| vars_content(scenario, &slug, world)),
187 slug,
188 })
189}
190
191fn merged_map_entries(
196 followers: &[(usize, usize, &crate::step::LoweredStep)],
197 entry_end: usize,
198) -> Vec<MapEntry> {
199 let total: usize = followers
200 .iter()
201 .map(|&(_, _, merged)| match merged.payload {
202 StepPayload::MergedAsserts { lines } => lines,
203 _ => unreachable!("followers are delimited by the MergedAsserts match"),
204 })
205 .sum();
206 let mut start = entry_end.saturating_sub(total) + 1;
207 followers
208 .iter()
209 .filter_map(|&(batch, step, merged)| {
210 let StepPayload::MergedAsserts { lines } = merged.payload else {
211 unreachable!("followers are delimited by the MergedAsserts match");
212 };
213 if lines == 0 {
224 return None;
225 }
226 let span = [start, start + lines - 1];
227 start += lines;
228 Some(MapEntry {
229 hurl_lines: span,
230 feature: FeatureAnchor {
231 file: merged.step.file.to_string(),
232 line: merged.step.line,
233 text: merged.step.text.to_string(),
234 },
235 optional: merged.optional,
236 captures: Vec::new(),
237 batch,
238 step,
239 })
240 })
241 .collect()
242}
243
244fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
246 match label {
247 Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
248 None => format!("# {}:{} — {}", step.file, step.line, step.text),
249 }
250}
251
252fn trimmed_lines(payload: &str) -> Vec<&str> {
255 let mut lines: Vec<&str> = payload.lines().collect();
256 while lines.last().is_some_and(|l| l.trim().is_empty()) {
257 lines.pop();
258 }
259 lines
260}
261
262pub(crate) fn capture_names(body: &[&str]) -> Vec<String> {
271 let mut names = Vec::new();
272 let mut in_captures = false;
273 let mut in_fence = false;
274 for line in body {
275 let trimmed = line.trim();
276 if trimmed.starts_with("```") {
277 in_fence = !in_fence;
278 in_captures = false;
279 continue;
280 }
281 if in_fence {
282 continue;
283 }
284 if trimmed == "[Captures]" {
285 in_captures = true;
286 continue;
287 }
288 if trimmed.starts_with('[') {
289 in_captures = false;
290 continue;
291 }
292 if in_captures && let Some(name) = capture_name(trimmed) {
298 names.push(name.to_owned());
299 continue;
300 }
301 if starts_entry_line(trimmed) {
304 in_captures = false;
305 continue;
306 }
307 if trimmed.starts_with('{') || trimmed.starts_with('<') {
309 in_captures = false;
310 }
311 }
312 names
313}
314
315fn capture_name(trimmed: &str) -> Option<&str> {
321 let (name, _) = trimmed.split_once(':')?;
322 let name = name.trim();
323 (!name.is_empty()
324 && name
325 .chars()
326 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
327 .then_some(name)
328}
329
330fn starts_entry_line(trimmed: &str) -> bool {
346 trimmed.starts_with("HTTP ") || trimmed.starts_with("HTTP/") || is_method_line(trimmed)
347}
348
349pub fn file_references(hurl_text: &str) -> Vec<String> {
354 let mut names: Vec<String> = Vec::new();
355 for line in hurl_text.lines() {
356 let mut rest = line;
357 while let Some(position) = rest.find("file,") {
358 let tail = &rest[position + "file,".len()..];
359 let Some(end) = tail.find(';') else { break };
360 let name = tail[..end].trim();
361 if !name.is_empty() && !names.iter().any(|n| n == name) {
362 names.push(name.to_owned());
363 }
364 rest = &tail[end + 1..];
365 }
366 }
367 names
368}
369
370fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
373 use std::fmt::Write as _;
374
375 let mut out = String::new();
376 let _ = writeln!(out, "# proef variables for {slug}.hurl");
377 for name in &scenario.globals {
378 match world.get(name) {
379 Some(value) => {
380 let rendered = value.to_string();
381 if rendered.contains(['\n', '\r']) {
382 let _ = writeln!(
386 out,
387 "# global `{name}` is not line-representable (value contains a newline)\n{name}="
388 );
389 } else {
390 let _ = writeln!(out, "{name}={rendered}");
391 }
392 }
393 None => {
394 let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
395 }
396 }
397 }
398 for (variable, secret) in &scenario.secrets {
399 let source = if variable == secret {
402 String::new()
403 } else {
404 format!(" (from secret `{secret}`)")
405 };
406 let _ = writeln!(
407 out,
408 "# secret `{variable}`{source} — supply at replay: --secret {variable}=<value>"
409 );
410 }
411 out
412}
413
414pub fn slugify(text: &str) -> String {
416 let mut slug = String::with_capacity(text.len());
417 let mut dash_pending = false;
418 for c in text.chars() {
419 if c.is_alphanumeric() {
420 if dash_pending && !slug.is_empty() {
421 slug.push('-');
422 }
423 dash_pending = false;
424 slug.extend(c.to_lowercase());
425 } else {
426 dash_pending = true;
427 }
428 }
429 slug
430}
431
432#[cfg(test)]
433mod tests {
434 #![allow(clippy::unwrap_used)]
435
436 use std::collections::{BTreeMap, BTreeSet};
437 use std::sync::Arc;
438
439 use super::*;
440 use crate::engine::EngineId;
441 use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
442 use crate::world::{GlobalStore, Value};
443
444 fn step(
445 line: usize,
446 text: &str,
447 payload: &str,
448 optional: bool,
449 label: Option<&str>,
450 ) -> LoweredStep {
451 LoweredStep {
452 step: StepRef {
453 file: Arc::from("tests/features/demo.feature"),
454 line,
455 text: Arc::from(text),
456 },
457 kind: StepKindId::from("hurl"),
458 payload: StepPayload::HurlEntries(payload.to_owned()),
459 optional,
460 when: None,
461 label: label.map(ToOwned::to_owned),
462 fragment: None,
463 save_as: BTreeMap::new(),
464 }
465 }
466
467 fn scenario() -> LoweredScenario {
468 LoweredScenario {
469 name: "Search finds a record".to_owned(),
470 tags: vec!["api".to_owned()],
471 line: 4,
472 batches: vec![
473 StepBatch {
474 index: 0,
475 engine: EngineId::from("hurl"),
476 steps: vec![step(
477 5,
478 "the service is healthy",
479 "GET http://x/health\nHTTP 200\n\n",
480 true,
481 None,
482 )],
483 },
484 StepBatch {
485 index: 1,
486 engine: EngineId::from("hurl"),
487 steps: vec![step(
488 6,
489 "I search for \"Jansen\"",
490 "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nrecordId: jsonpath \"$[0].id\"",
491 false,
492 Some("run the search"),
493 )],
494 },
495 ],
496 secrets: BTreeMap::from([("apiToken".to_owned(), "apiToken".to_owned())]),
497 globals: BTreeSet::from(["envName".to_owned()]),
498 warnings: Vec::new(),
499 }
500 }
501
502 #[test]
503 fn capture_scan_ends_at_the_next_entry() {
504 let body = [
505 "GET http://x/a",
506 "HTTP 200",
507 "[Captures]",
508 "id: jsonpath \"$.id\"",
509 "",
510 "# — next request",
511 "GET http://x/b",
512 "HTTP 200",
513 ];
514 assert_eq!(capture_names(&body), vec!["id"]);
515 }
516
517 #[test]
518 fn capture_scan_ignores_fenced_lines_and_ends_at_custom_methods() {
519 let body = [
523 "GET http://x/a",
524 "HTTP 200",
525 "[Captures]",
526 "real: jsonpath \"$.id\"",
527 "",
528 "PROPFIND http://x/b",
529 "```",
530 "[Captures]",
531 "phantom: jsonpath \"$.nope\"",
532 "```",
533 "HTTP 207",
534 ];
535 let names = capture_names(&body);
536 assert!(names.contains(&"real".to_owned()), "{names:?}");
537 assert!(
538 !names.contains(&"phantom".to_owned()),
539 "fenced capture leaked into the sidecar: {names:?}"
540 );
541 }
542
543 #[test]
544 fn capture_names_keeps_a_capture_whose_name_starts_with_http() {
545 let body = [
554 "GET http://x/a",
555 "HTTP 200",
556 "[Captures]",
557 "HTTPStatus: jsonpath \"$.status\"",
558 "plain: jsonpath \"$.id\"",
559 ];
560 assert_eq!(
561 capture_names(&body),
562 vec!["HTTPStatus".to_owned(), "plain".to_owned()]
563 );
564 }
565
566 #[test]
567 fn starts_entry_line_requires_a_delimiter_after_http() {
568 assert!(!starts_entry_line("HTTPStatus: jsonpath \"$.status\""));
573 assert!(starts_entry_line("HTTP 200"));
574 assert!(starts_entry_line("HTTP/1.1 200"));
575 assert!(starts_entry_line("PROPFIND http://x/b"));
579 }
580
581 #[test]
582 fn capture_scan_ends_the_previous_entry_at_a_custom_method_line() {
583 let body = [
594 "GET http://x/a",
595 "HTTP 200",
596 "[Captures]",
597 "real: jsonpath \"$.id\"",
598 "PROPFIND http://x/b",
599 "Depth: 1",
600 "HTTP 207",
601 ];
602 let names = capture_names(&body);
603 assert_eq!(
604 names,
605 vec!["real".to_owned()],
606 "a custom-method entry line must end the previous entry's capture scan: {names:?}"
607 );
608 }
609
610 #[test]
611 fn a_comment_inside_a_captures_run_does_not_drop_the_captures_after_it() {
612 let body = [
619 "GET http://x/a",
620 "HTTP 200",
621 "[Captures]",
622 "# the id we reuse later",
623 "id: jsonpath \"$.id\"",
624 "other: jsonpath \"$.other\"",
625 ];
626 let names = capture_names(&body);
627 assert_eq!(
628 names,
629 vec!["id".to_owned(), "other".to_owned()],
630 "a comment inside the run dropped the captures following it: {names:?}"
631 );
632 }
633
634 #[test]
635 fn capture_names_with_a_space_before_the_colon_are_not_mistaken_for_a_method_line() {
636 let body = [
644 "GET http://x/a",
645 "HTTP 200",
646 "[Captures]",
647 "STATUS : jsonpath \"$.s\"",
648 "plain: jsonpath \"$.id\"",
649 ];
650 assert_eq!(
651 capture_names(&body),
652 vec!["STATUS".to_owned(), "plain".to_owned()]
653 );
654 }
655
656 #[test]
657 fn file_references_finds_file_bodies_and_multipart_parts() {
658 let text = "POST http://x/upload\n[Multipart]\nphoto: file,fixture.jpg;\nHTTP 201\n\nPOST http://x/raw\nfile,payload.bin;\nHTTP 200\n";
659 assert_eq!(
660 file_references(text),
661 vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
662 );
663 }
664
665 #[test]
666 fn canonical_layout_map_and_vars() {
667 let mut store = GlobalStore::new();
668 store.insert("envName", Value::String("staging".into()));
669 let world = World::new(store);
670
671 let artifact = emit(&scenario(), "500_demo", &world).unwrap();
672 assert_eq!(artifact.slug, "500-demo--search-finds-a-record");
673
674 let lines: Vec<&str> = artifact.hurl_text.lines().collect();
675 assert_eq!(lines[0], "# proef artifact — Search finds a record");
676 assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
677 assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
678 assert_eq!(
679 lines[4],
680 "# tests/features/demo.feature:5 — the service is healthy"
681 );
682 assert_eq!(lines[5], "# optional");
683 assert_eq!(lines[6], "GET http://x/health");
684
685 let map = &artifact.map;
687 assert_eq!(map.schema, 1);
688 assert_eq!(map.entries.len(), 2);
689 assert_eq!(map.entries[0].hurl_lines, [7, 8]);
690 assert!(map.entries[0].optional);
691 assert_eq!(map.entries[0].batch, 0);
692 assert_eq!(map.entries[1].captures, vec!["recordId"]);
693 assert_eq!(map.entries[1].batch, 1);
694 let [start, end] = map.entries[1].hurl_lines;
695 assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
696 assert_eq!(end - start, 3);
697
698 let vars = artifact.vars.unwrap();
700 assert!(vars.contains("envName=staging"), "{vars}");
701 assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
702 assert!(!vars.contains("apiToken=\n"), "secret values never appear");
703 }
704
705 #[test]
706 fn no_hurl_entries_means_no_artifact() {
707 let empty = LoweredScenario {
708 name: "n".to_owned(),
709 tags: Vec::new(),
710 line: 1,
711 batches: Vec::new(),
712 secrets: BTreeMap::new(),
713 globals: BTreeSet::new(),
714 warnings: Vec::new(),
715 };
716 assert!(emit(&empty, "f", &World::default()).is_none());
717 }
718
719 #[test]
720 fn slugs_are_file_safe_and_stable() {
721 assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
722 assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
723 assert_eq!(slugify(" -- "), "");
724 }
725
726 #[test]
727 fn emission_is_deterministic() {
728 let world = World::default();
729 let a = emit(&scenario(), "500_demo", &world).unwrap();
730 let b = emit(&scenario(), "500_demo", &world).unwrap();
731 assert_eq!(a.hurl_text, b.hurl_text);
732 assert_eq!(
733 serde_json::to_string(&a.map).unwrap(),
734 serde_json::to_string(&b.map).unwrap()
735 );
736 }
737}