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 secret in &scenario.secrets {
124 let _ = write!(replay, " --secret {secret}=<value>");
126 }
127 push_line(&mut text, &mut line, &replay);
128
129 let mut entries = Vec::new();
130 let mut index = 0usize;
131 while index < steps.len() {
132 let (batch_index, step_index, step) = steps[index];
133 let StepPayload::HurlEntries(payload) = &step.payload else {
134 index += 1;
137 continue;
138 };
139 push_line(&mut text, &mut line, "");
140 push_line(
141 &mut text,
142 &mut line,
143 &entry_comment(&step.step, step.label.as_deref()),
144 );
145 if step.optional {
146 push_line(&mut text, &mut line, "# optional");
147 }
148 let body: Vec<&str> = trimmed_lines(payload);
149 let start = line + 1;
150 for body_line in &body {
151 push_line(&mut text, &mut line, body_line);
152 }
153 entries.push(MapEntry {
154 hurl_lines: [start, line],
155 feature: FeatureAnchor {
156 file: step.step.file.to_string(),
157 line: step.step.line,
158 text: step.step.text.to_string(),
159 },
160 optional: step.optional,
161 captures: capture_names(&body),
162 batch: batch_index,
163 step: step_index,
164 });
165
166 index += 1;
169 let first_merged = index;
170 while index < steps.len()
171 && matches!(steps[index].2.payload, StepPayload::MergedAsserts { .. })
172 {
173 index += 1;
174 }
175 entries.extend(merged_map_entries(&steps[first_merged..index], line));
176 }
177
178 Some(Artifact {
179 hurl_text: text,
180 map: SidecarMap {
181 schema: MAP_SCHEMA_VERSION,
182 entries,
183 },
184 vars: has_vars.then(|| vars_content(scenario, &slug, world)),
185 slug,
186 })
187}
188
189fn merged_map_entries(
194 followers: &[(usize, usize, &crate::step::LoweredStep)],
195 entry_end: usize,
196) -> Vec<MapEntry> {
197 let total: usize = followers
198 .iter()
199 .map(|&(_, _, merged)| match merged.payload {
200 StepPayload::MergedAsserts { lines } => lines,
201 _ => unreachable!("followers are delimited by the MergedAsserts match"),
202 })
203 .sum();
204 let mut start = entry_end.saturating_sub(total) + 1;
205 followers
206 .iter()
207 .filter_map(|&(batch, step, merged)| {
208 let StepPayload::MergedAsserts { lines } = merged.payload else {
209 unreachable!("followers are delimited by the MergedAsserts match");
210 };
211 if lines == 0 {
222 return None;
223 }
224 let span = [start, start + lines - 1];
225 start += lines;
226 Some(MapEntry {
227 hurl_lines: span,
228 feature: FeatureAnchor {
229 file: merged.step.file.to_string(),
230 line: merged.step.line,
231 text: merged.step.text.to_string(),
232 },
233 optional: merged.optional,
234 captures: Vec::new(),
235 batch,
236 step,
237 })
238 })
239 .collect()
240}
241
242fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
244 match label {
245 Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
246 None => format!("# {}:{} — {}", step.file, step.line, step.text),
247 }
248}
249
250fn trimmed_lines(payload: &str) -> Vec<&str> {
253 let mut lines: Vec<&str> = payload.lines().collect();
254 while lines.last().is_some_and(|l| l.trim().is_empty()) {
255 lines.pop();
256 }
257 lines
258}
259
260fn capture_names(body: &[&str]) -> Vec<String> {
269 let mut names = Vec::new();
270 let mut in_captures = false;
271 let mut in_fence = false;
272 for line in body {
273 let trimmed = line.trim();
274 if trimmed.starts_with("```") {
275 in_fence = !in_fence;
276 in_captures = false;
277 continue;
278 }
279 if in_fence {
280 continue;
281 }
282 if trimmed == "[Captures]" {
283 in_captures = true;
284 continue;
285 }
286 if trimmed.starts_with('[') {
287 in_captures = false;
288 continue;
289 }
290 if in_captures && let Some(name) = capture_name(trimmed) {
296 names.push(name.to_owned());
297 continue;
298 }
299 if starts_entry_line(trimmed) {
302 in_captures = false;
303 continue;
304 }
305 if trimmed.starts_with('{') || trimmed.starts_with('<') {
307 in_captures = false;
308 }
309 }
310 names
311}
312
313fn capture_name(trimmed: &str) -> Option<&str> {
319 let (name, _) = trimmed.split_once(':')?;
320 let name = name.trim();
321 (!name.is_empty()
322 && name
323 .chars()
324 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
325 .then_some(name)
326}
327
328fn starts_entry_line(trimmed: &str) -> bool {
344 trimmed.starts_with("HTTP ") || trimmed.starts_with("HTTP/") || is_method_line(trimmed)
345}
346
347pub fn file_references(hurl_text: &str) -> Vec<String> {
352 let mut names: Vec<String> = Vec::new();
353 for line in hurl_text.lines() {
354 let mut rest = line;
355 while let Some(position) = rest.find("file,") {
356 let tail = &rest[position + "file,".len()..];
357 let Some(end) = tail.find(';') else { break };
358 let name = tail[..end].trim();
359 if !name.is_empty() && !names.iter().any(|n| n == name) {
360 names.push(name.to_owned());
361 }
362 rest = &tail[end + 1..];
363 }
364 }
365 names
366}
367
368fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
371 use std::fmt::Write as _;
372
373 let mut out = String::new();
374 let _ = writeln!(out, "# proef variables for {slug}.hurl");
375 for name in &scenario.globals {
376 match world.get(name) {
377 Some(value) => {
378 let rendered = value.to_string();
379 if rendered.contains(['\n', '\r']) {
380 let _ = writeln!(
384 out,
385 "# global `{name}` is not line-representable (value contains a newline)\n{name}="
386 );
387 } else {
388 let _ = writeln!(out, "{name}={rendered}");
389 }
390 }
391 None => {
392 let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
393 }
394 }
395 }
396 for name in &scenario.secrets {
397 let _ = writeln!(
398 out,
399 "# secret `{name}` — supply at replay: --secret {name}=<value>"
400 );
401 }
402 out
403}
404
405pub fn slugify(text: &str) -> String {
407 let mut slug = String::with_capacity(text.len());
408 let mut dash_pending = false;
409 for c in text.chars() {
410 if c.is_alphanumeric() {
411 if dash_pending && !slug.is_empty() {
412 slug.push('-');
413 }
414 dash_pending = false;
415 slug.extend(c.to_lowercase());
416 } else {
417 dash_pending = true;
418 }
419 }
420 slug
421}
422
423#[cfg(test)]
424mod tests {
425 #![allow(clippy::unwrap_used)]
426
427 use std::collections::{BTreeMap, BTreeSet};
428 use std::sync::Arc;
429
430 use super::*;
431 use crate::engine::EngineId;
432 use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
433 use crate::world::{GlobalStore, Value};
434
435 fn step(
436 line: usize,
437 text: &str,
438 payload: &str,
439 optional: bool,
440 label: Option<&str>,
441 ) -> LoweredStep {
442 LoweredStep {
443 step: StepRef {
444 file: Arc::from("tests/features/demo.feature"),
445 line,
446 text: Arc::from(text),
447 },
448 kind: StepKindId::from("hurl"),
449 payload: StepPayload::HurlEntries(payload.to_owned()),
450 optional,
451 when: None,
452 label: label.map(ToOwned::to_owned),
453 save_as: BTreeMap::new(),
454 }
455 }
456
457 fn scenario() -> LoweredScenario {
458 LoweredScenario {
459 name: "Search finds a record".to_owned(),
460 tags: vec!["api".to_owned()],
461 line: 4,
462 batches: vec![
463 StepBatch {
464 index: 0,
465 engine: EngineId::from("hurl"),
466 steps: vec![step(
467 5,
468 "the service is healthy",
469 "GET http://x/health\nHTTP 200\n\n",
470 true,
471 None,
472 )],
473 },
474 StepBatch {
475 index: 1,
476 engine: EngineId::from("hurl"),
477 steps: vec![step(
478 6,
479 "I search for \"Jansen\"",
480 "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nrecordId: jsonpath \"$[0].id\"",
481 false,
482 Some("run the search"),
483 )],
484 },
485 ],
486 secrets: BTreeSet::from(["apiToken".to_owned()]),
487 globals: BTreeSet::from(["envName".to_owned()]),
488 warnings: Vec::new(),
489 }
490 }
491
492 #[test]
493 fn capture_scan_ends_at_the_next_entry() {
494 let body = [
495 "GET http://x/a",
496 "HTTP 200",
497 "[Captures]",
498 "id: jsonpath \"$.id\"",
499 "",
500 "# — next request",
501 "GET http://x/b",
502 "HTTP 200",
503 ];
504 assert_eq!(capture_names(&body), vec!["id"]);
505 }
506
507 #[test]
508 fn capture_scan_ignores_fenced_lines_and_ends_at_custom_methods() {
509 let body = [
513 "GET http://x/a",
514 "HTTP 200",
515 "[Captures]",
516 "real: jsonpath \"$.id\"",
517 "",
518 "PROPFIND http://x/b",
519 "```",
520 "[Captures]",
521 "phantom: jsonpath \"$.nope\"",
522 "```",
523 "HTTP 207",
524 ];
525 let names = capture_names(&body);
526 assert!(names.contains(&"real".to_owned()), "{names:?}");
527 assert!(
528 !names.contains(&"phantom".to_owned()),
529 "fenced capture leaked into the sidecar: {names:?}"
530 );
531 }
532
533 #[test]
534 fn capture_names_keeps_a_capture_whose_name_starts_with_http() {
535 let body = [
544 "GET http://x/a",
545 "HTTP 200",
546 "[Captures]",
547 "HTTPStatus: jsonpath \"$.status\"",
548 "plain: jsonpath \"$.id\"",
549 ];
550 assert_eq!(
551 capture_names(&body),
552 vec!["HTTPStatus".to_owned(), "plain".to_owned()]
553 );
554 }
555
556 #[test]
557 fn starts_entry_line_requires_a_delimiter_after_http() {
558 assert!(!starts_entry_line("HTTPStatus: jsonpath \"$.status\""));
563 assert!(starts_entry_line("HTTP 200"));
564 assert!(starts_entry_line("HTTP/1.1 200"));
565 assert!(starts_entry_line("PROPFIND http://x/b"));
569 }
570
571 #[test]
572 fn capture_scan_ends_the_previous_entry_at_a_custom_method_line() {
573 let body = [
584 "GET http://x/a",
585 "HTTP 200",
586 "[Captures]",
587 "real: jsonpath \"$.id\"",
588 "PROPFIND http://x/b",
589 "Depth: 1",
590 "HTTP 207",
591 ];
592 let names = capture_names(&body);
593 assert_eq!(
594 names,
595 vec!["real".to_owned()],
596 "a custom-method entry line must end the previous entry's capture scan: {names:?}"
597 );
598 }
599
600 #[test]
601 fn a_comment_inside_a_captures_run_does_not_drop_the_captures_after_it() {
602 let body = [
609 "GET http://x/a",
610 "HTTP 200",
611 "[Captures]",
612 "# the id we reuse later",
613 "id: jsonpath \"$.id\"",
614 "other: jsonpath \"$.other\"",
615 ];
616 let names = capture_names(&body);
617 assert_eq!(
618 names,
619 vec!["id".to_owned(), "other".to_owned()],
620 "a comment inside the run dropped the captures following it: {names:?}"
621 );
622 }
623
624 #[test]
625 fn capture_names_with_a_space_before_the_colon_are_not_mistaken_for_a_method_line() {
626 let body = [
634 "GET http://x/a",
635 "HTTP 200",
636 "[Captures]",
637 "STATUS : jsonpath \"$.s\"",
638 "plain: jsonpath \"$.id\"",
639 ];
640 assert_eq!(
641 capture_names(&body),
642 vec!["STATUS".to_owned(), "plain".to_owned()]
643 );
644 }
645
646 #[test]
647 fn file_references_finds_file_bodies_and_multipart_parts() {
648 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";
649 assert_eq!(
650 file_references(text),
651 vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
652 );
653 }
654
655 #[test]
656 fn canonical_layout_map_and_vars() {
657 let mut store = GlobalStore::new();
658 store.insert("envName", Value::String("staging".into()));
659 let world = World::new(store);
660
661 let artifact = emit(&scenario(), "500_demo", &world).unwrap();
662 assert_eq!(artifact.slug, "500-demo--search-finds-a-record");
663
664 let lines: Vec<&str> = artifact.hurl_text.lines().collect();
665 assert_eq!(lines[0], "# proef artifact — Search finds a record");
666 assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
667 assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
668 assert_eq!(
669 lines[4],
670 "# tests/features/demo.feature:5 — the service is healthy"
671 );
672 assert_eq!(lines[5], "# optional");
673 assert_eq!(lines[6], "GET http://x/health");
674
675 let map = &artifact.map;
677 assert_eq!(map.schema, 1);
678 assert_eq!(map.entries.len(), 2);
679 assert_eq!(map.entries[0].hurl_lines, [7, 8]);
680 assert!(map.entries[0].optional);
681 assert_eq!(map.entries[0].batch, 0);
682 assert_eq!(map.entries[1].captures, vec!["recordId"]);
683 assert_eq!(map.entries[1].batch, 1);
684 let [start, end] = map.entries[1].hurl_lines;
685 assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
686 assert_eq!(end - start, 3);
687
688 let vars = artifact.vars.unwrap();
690 assert!(vars.contains("envName=staging"), "{vars}");
691 assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
692 assert!(!vars.contains("apiToken=\n"), "secret values never appear");
693 }
694
695 #[test]
696 fn no_hurl_entries_means_no_artifact() {
697 let empty = LoweredScenario {
698 name: "n".to_owned(),
699 tags: Vec::new(),
700 line: 1,
701 batches: Vec::new(),
702 secrets: BTreeSet::new(),
703 globals: BTreeSet::new(),
704 warnings: Vec::new(),
705 };
706 assert!(emit(&empty, "f", &World::default()).is_none());
707 }
708
709 #[test]
710 fn slugs_are_file_safe_and_stable() {
711 assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
712 assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
713 assert_eq!(slugify(" -- "), "");
714 }
715
716 #[test]
717 fn emission_is_deterministic() {
718 let world = World::default();
719 let a = emit(&scenario(), "500_demo", &world).unwrap();
720 let b = emit(&scenario(), "500_demo", &world).unwrap();
721 assert_eq!(a.hurl_text, b.hurl_text);
722 assert_eq!(
723 serde_json::to_string(&a.map).unwrap(),
724 serde_json::to_string(&b.map).unwrap()
725 );
726 }
727}