1use std::fmt::Write as _;
16
17use serde::Serialize;
18
19use crate::lower::LoweredScenario;
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(
193 followers: &[(usize, usize, &crate::step::LoweredStep)],
194 entry_end: usize,
195) -> Vec<MapEntry> {
196 let mut end = entry_end;
197 let mut spans: Vec<[usize; 2]> = Vec::new();
198 for &(_, _, merged) in followers.iter().rev() {
199 let StepPayload::MergedAsserts { lines } = merged.payload else {
200 continue;
201 };
202 spans.push([end.saturating_sub(lines) + 1, end]);
203 end = end.saturating_sub(lines);
204 }
205 spans.reverse();
206 followers
207 .iter()
208 .zip(spans)
209 .map(|(&(batch, step, merged), span)| MapEntry {
210 hurl_lines: span,
211 feature: FeatureAnchor {
212 file: merged.step.file.to_string(),
213 line: merged.step.line,
214 text: merged.step.text.to_string(),
215 },
216 optional: merged.optional,
217 captures: Vec::new(),
218 batch,
219 step,
220 })
221 .collect()
222}
223
224fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
226 match label {
227 Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
228 None => format!("# {}:{} — {}", step.file, step.line, step.text),
229 }
230}
231
232fn trimmed_lines(payload: &str) -> Vec<&str> {
235 let mut lines: Vec<&str> = payload.lines().collect();
236 while lines.last().is_some_and(|l| l.trim().is_empty()) {
237 lines.pop();
238 }
239 lines
240}
241
242fn capture_names(body: &[&str]) -> Vec<String> {
245 let mut names = Vec::new();
246 let mut in_captures = false;
247 for line in body {
248 let trimmed = line.trim();
249 if trimmed == "[Captures]" {
250 in_captures = true;
251 continue;
252 }
253 if trimmed.starts_with('[') {
254 in_captures = false;
255 continue;
256 }
257 if starts_entry_line(trimmed) {
260 in_captures = false;
261 continue;
262 }
263 if in_captures
264 && let Some((name, _)) = trimmed.split_once(':')
265 && !name.trim().is_empty()
266 && !name.trim().contains(char::is_whitespace)
267 {
268 names.push(name.trim().to_owned());
269 }
270 }
271 names
272}
273
274fn starts_entry_line(trimmed: &str) -> bool {
277 const STARTERS: &[&str] = &[
278 "GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "HTTP ", "HTTP/",
279 ];
280 trimmed.starts_with('#') || STARTERS.iter().any(|s| trimmed.starts_with(s))
281}
282
283pub fn file_references(hurl_text: &str) -> Vec<String> {
288 let mut names: Vec<String> = Vec::new();
289 for line in hurl_text.lines() {
290 let mut rest = line;
291 while let Some(position) = rest.find("file,") {
292 let tail = &rest[position + "file,".len()..];
293 let Some(end) = tail.find(';') else { break };
294 let name = tail[..end].trim();
295 if !name.is_empty() && !names.iter().any(|n| n == name) {
296 names.push(name.to_owned());
297 }
298 rest = &tail[end + 1..];
299 }
300 }
301 names
302}
303
304fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
307 use std::fmt::Write as _;
308
309 let mut out = String::new();
310 let _ = writeln!(out, "# proef variables for {slug}.hurl");
311 for name in &scenario.globals {
312 match world.get(name) {
313 Some(value) => {
314 let _ = writeln!(out, "{name}={value}");
315 }
316 None => {
317 let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
318 }
319 }
320 }
321 for name in &scenario.secrets {
322 let _ = writeln!(
323 out,
324 "# secret `{name}` — supply at replay: --secret {name}=<value>"
325 );
326 }
327 out
328}
329
330pub fn slugify(text: &str) -> String {
332 let mut slug = String::with_capacity(text.len());
333 let mut dash_pending = false;
334 for c in text.chars() {
335 if c.is_alphanumeric() {
336 if dash_pending && !slug.is_empty() {
337 slug.push('-');
338 }
339 dash_pending = false;
340 slug.extend(c.to_lowercase());
341 } else {
342 dash_pending = true;
343 }
344 }
345 slug
346}
347
348#[cfg(test)]
349mod tests {
350 #![allow(clippy::unwrap_used)]
351
352 use std::collections::{BTreeMap, BTreeSet};
353 use std::sync::Arc;
354
355 use super::*;
356 use crate::engine::EngineId;
357 use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
358 use crate::world::{GlobalStore, Value};
359
360 fn step(
361 line: usize,
362 text: &str,
363 payload: &str,
364 optional: bool,
365 label: Option<&str>,
366 ) -> LoweredStep {
367 LoweredStep {
368 step: StepRef {
369 file: Arc::from("tests/features/demo.feature"),
370 line,
371 text: Arc::from(text),
372 },
373 kind: StepKindId::from("hurl"),
374 payload: StepPayload::HurlEntries(payload.to_owned()),
375 optional,
376 when: None,
377 label: label.map(ToOwned::to_owned),
378 save_as: BTreeMap::new(),
379 }
380 }
381
382 fn scenario() -> LoweredScenario {
383 LoweredScenario {
384 name: "Search finds a client".to_owned(),
385 tags: vec!["api".to_owned()],
386 line: 4,
387 batches: vec![
388 StepBatch {
389 index: 0,
390 engine: EngineId::from("hurl"),
391 steps: vec![step(
392 5,
393 "the service is healthy",
394 "GET http://x/health\nHTTP 200\n\n",
395 true,
396 None,
397 )],
398 },
399 StepBatch {
400 index: 1,
401 engine: EngineId::from("hurl"),
402 steps: vec![step(
403 6,
404 "I search for \"Jansen\"",
405 "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nclientId: jsonpath \"$[0].id\"",
406 false,
407 Some("run the search"),
408 )],
409 },
410 ],
411 secrets: BTreeSet::from(["apiToken".to_owned()]),
412 globals: BTreeSet::from(["envName".to_owned()]),
413 warnings: Vec::new(),
414 }
415 }
416
417 #[test]
418 fn capture_scan_ends_at_the_next_entry() {
419 let body = [
420 "GET http://x/a",
421 "HTTP 200",
422 "[Captures]",
423 "id: jsonpath \"$.id\"",
424 "",
425 "# — next request",
426 "GET http://x/b",
427 "HTTP 200",
428 ];
429 assert_eq!(capture_names(&body), vec!["id"]);
430 }
431
432 #[test]
433 fn file_references_finds_file_bodies_and_multipart_parts() {
434 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";
435 assert_eq!(
436 file_references(text),
437 vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
438 );
439 }
440
441 #[test]
442 fn canonical_layout_map_and_vars() {
443 let mut store = GlobalStore::new();
444 store.insert("envName", Value::String("staging".into()));
445 let world = World::new(store);
446
447 let artifact = emit(&scenario(), "500_demo", &world).unwrap();
448 assert_eq!(artifact.slug, "500-demo--search-finds-a-client");
449
450 let lines: Vec<&str> = artifact.hurl_text.lines().collect();
451 assert_eq!(lines[0], "# proef artifact — Search finds a client");
452 assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
453 assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
454 assert_eq!(
455 lines[4],
456 "# tests/features/demo.feature:5 — the service is healthy"
457 );
458 assert_eq!(lines[5], "# optional");
459 assert_eq!(lines[6], "GET http://x/health");
460
461 let map = &artifact.map;
463 assert_eq!(map.schema, 1);
464 assert_eq!(map.entries.len(), 2);
465 assert_eq!(map.entries[0].hurl_lines, [7, 8]);
466 assert!(map.entries[0].optional);
467 assert_eq!(map.entries[0].batch, 0);
468 assert_eq!(map.entries[1].captures, vec!["clientId"]);
469 assert_eq!(map.entries[1].batch, 1);
470 let [start, end] = map.entries[1].hurl_lines;
471 assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
472 assert_eq!(end - start, 3);
473
474 let vars = artifact.vars.unwrap();
476 assert!(vars.contains("envName=staging"), "{vars}");
477 assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
478 assert!(!vars.contains("apiToken=\n"), "secret values never appear");
479 }
480
481 #[test]
482 fn no_hurl_entries_means_no_artifact() {
483 let empty = LoweredScenario {
484 name: "n".to_owned(),
485 tags: Vec::new(),
486 line: 1,
487 batches: Vec::new(),
488 secrets: BTreeSet::new(),
489 globals: BTreeSet::new(),
490 warnings: Vec::new(),
491 };
492 assert!(emit(&empty, "f", &World::default()).is_none());
493 }
494
495 #[test]
496 fn slugs_are_file_safe_and_stable() {
497 assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
498 assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
499 assert_eq!(slugify(" -- "), "");
500 }
501
502 #[test]
503 fn emission_is_deterministic() {
504 let world = World::default();
505 let a = emit(&scenario(), "500_demo", &world).unwrap();
506 let b = emit(&scenario(), "500_demo", &world).unwrap();
507 assert_eq!(a.hurl_text, b.hurl_text);
508 assert_eq!(
509 serde_json::to_string(&a.map).unwrap(),
510 serde_json::to_string(&b.map).unwrap()
511 );
512 }
513}