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, &str)> = Vec::new();
85 for (batch_index, batch) in scenario.batches.iter().enumerate() {
86 for (step_index, step) in batch.steps.iter().enumerate() {
87 if let StepPayload::HurlEntries(text) = &step.payload {
88 steps.push((batch_index, step_index, step, text));
89 }
90 }
91 }
92 let (_, _, first_step, _) = *steps.first()?;
95
96 let mut text = String::new();
97 let mut line = 0usize;
98 let push_line = |text: &mut String, line: &mut usize, content: &str| {
99 text.push_str(content);
100 text.push('\n');
101 *line += 1;
102 };
103
104 push_line(
105 &mut text,
106 &mut line,
107 &format!("# proef artifact — {}", scenario.name),
108 );
109 push_line(
110 &mut text,
111 &mut line,
112 &format!("# source: {}:{}", first_step.step.file, scenario.line),
113 );
114 let mut replay = format!("# replay: hurl --test {slug}.hurl");
115 if has_vars {
116 let _ = write!(replay, " --variables-file {slug}.vars");
117 }
118 for secret in &scenario.secrets {
119 let _ = write!(replay, " --secret {secret}=<value>");
121 }
122 push_line(&mut text, &mut line, &replay);
123
124 let mut entries = Vec::new();
125 for (batch_index, step_index, step, payload) in steps {
126 push_line(&mut text, &mut line, "");
127 push_line(
128 &mut text,
129 &mut line,
130 &entry_comment(&step.step, step.label.as_deref()),
131 );
132 if step.optional {
133 push_line(&mut text, &mut line, "# optional");
134 }
135 let body: Vec<&str> = trimmed_lines(payload);
136 let start = line + 1;
137 for body_line in &body {
138 push_line(&mut text, &mut line, body_line);
139 }
140 entries.push(MapEntry {
141 hurl_lines: [start, line],
142 feature: FeatureAnchor {
143 file: step.step.file.to_string(),
144 line: step.step.line,
145 text: step.step.text.to_string(),
146 },
147 optional: step.optional,
148 captures: capture_names(&body),
149 batch: batch_index,
150 step: step_index,
151 });
152 }
153
154 Some(Artifact {
155 hurl_text: text,
156 map: SidecarMap {
157 schema: MAP_SCHEMA_VERSION,
158 entries,
159 },
160 vars: has_vars.then(|| vars_content(scenario, &slug, world)),
161 slug,
162 })
163}
164
165fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
167 match label {
168 Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
169 None => format!("# {}:{} — {}", step.file, step.line, step.text),
170 }
171}
172
173fn trimmed_lines(payload: &str) -> Vec<&str> {
176 let mut lines: Vec<&str> = payload.lines().collect();
177 while lines.last().is_some_and(|l| l.trim().is_empty()) {
178 lines.pop();
179 }
180 lines
181}
182
183fn capture_names(body: &[&str]) -> Vec<String> {
186 let mut names = Vec::new();
187 let mut in_captures = false;
188 for line in body {
189 let trimmed = line.trim();
190 if trimmed == "[Captures]" {
191 in_captures = true;
192 continue;
193 }
194 if trimmed.starts_with('[') {
195 in_captures = false;
196 continue;
197 }
198 if starts_entry_line(trimmed) {
201 in_captures = false;
202 continue;
203 }
204 if in_captures
205 && let Some((name, _)) = trimmed.split_once(':')
206 && !name.trim().is_empty()
207 && !name.trim().contains(char::is_whitespace)
208 {
209 names.push(name.trim().to_owned());
210 }
211 }
212 names
213}
214
215fn starts_entry_line(trimmed: &str) -> bool {
218 const STARTERS: &[&str] = &[
219 "GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "HTTP ", "HTTP/",
220 ];
221 trimmed.starts_with('#') || STARTERS.iter().any(|s| trimmed.starts_with(s))
222}
223
224pub fn file_references(hurl_text: &str) -> Vec<String> {
229 let mut names: Vec<String> = Vec::new();
230 for line in hurl_text.lines() {
231 let mut rest = line;
232 while let Some(position) = rest.find("file,") {
233 let tail = &rest[position + "file,".len()..];
234 let Some(end) = tail.find(';') else { break };
235 let name = tail[..end].trim();
236 if !name.is_empty() && !names.iter().any(|n| n == name) {
237 names.push(name.to_owned());
238 }
239 rest = &tail[end + 1..];
240 }
241 }
242 names
243}
244
245fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
248 use std::fmt::Write as _;
249
250 let mut out = String::new();
251 let _ = writeln!(out, "# proef variables for {slug}.hurl");
252 for name in &scenario.globals {
253 match world.get(name) {
254 Some(value) => {
255 let _ = writeln!(out, "{name}={value}");
256 }
257 None => {
258 let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
259 }
260 }
261 }
262 for name in &scenario.secrets {
263 let _ = writeln!(
264 out,
265 "# secret `{name}` — supply at replay: --secret {name}=<value>"
266 );
267 }
268 out
269}
270
271pub fn slugify(text: &str) -> String {
273 let mut slug = String::with_capacity(text.len());
274 let mut dash_pending = false;
275 for c in text.chars() {
276 if c.is_alphanumeric() {
277 if dash_pending && !slug.is_empty() {
278 slug.push('-');
279 }
280 dash_pending = false;
281 slug.extend(c.to_lowercase());
282 } else {
283 dash_pending = true;
284 }
285 }
286 slug
287}
288
289#[cfg(test)]
290mod tests {
291 #![allow(clippy::unwrap_used)]
292
293 use std::collections::{BTreeMap, BTreeSet};
294 use std::sync::Arc;
295
296 use super::*;
297 use crate::engine::EngineId;
298 use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
299 use crate::world::{GlobalStore, Value};
300
301 fn step(
302 line: usize,
303 text: &str,
304 payload: &str,
305 optional: bool,
306 label: Option<&str>,
307 ) -> LoweredStep {
308 LoweredStep {
309 step: StepRef {
310 file: Arc::from("tests/features/demo.feature"),
311 line,
312 text: Arc::from(text),
313 },
314 kind: StepKindId::from("hurl"),
315 payload: StepPayload::HurlEntries(payload.to_owned()),
316 optional,
317 retry: None,
318 when: None,
319 label: label.map(ToOwned::to_owned),
320 save_as: BTreeMap::new(),
321 }
322 }
323
324 fn scenario() -> LoweredScenario {
325 LoweredScenario {
326 name: "Search finds a client".to_owned(),
327 tags: vec!["api".to_owned()],
328 line: 4,
329 batches: vec![
330 StepBatch {
331 index: 0,
332 engine: EngineId::from("hurl"),
333 steps: vec![step(
334 5,
335 "the service is healthy",
336 "GET http://x/health\nHTTP 200\n\n",
337 true,
338 None,
339 )],
340 },
341 StepBatch {
342 index: 1,
343 engine: EngineId::from("hurl"),
344 steps: vec![step(
345 6,
346 "I search for \"Jansen\"",
347 "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nclientId: jsonpath \"$[0].id\"",
348 false,
349 Some("run the search"),
350 )],
351 },
352 ],
353 secrets: BTreeSet::from(["apiToken".to_owned()]),
354 globals: BTreeSet::from(["envName".to_owned()]),
355 warnings: Vec::new(),
356 }
357 }
358
359 #[test]
360 fn capture_scan_ends_at_the_next_entry() {
361 let body = [
362 "GET http://x/a",
363 "HTTP 200",
364 "[Captures]",
365 "id: jsonpath \"$.id\"",
366 "",
367 "# — next request",
368 "GET http://x/b",
369 "HTTP 200",
370 ];
371 assert_eq!(capture_names(&body), vec!["id"]);
372 }
373
374 #[test]
375 fn file_references_finds_file_bodies_and_multipart_parts() {
376 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";
377 assert_eq!(
378 file_references(text),
379 vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
380 );
381 }
382
383 #[test]
384 fn canonical_layout_map_and_vars() {
385 let mut store = GlobalStore::new();
386 store.insert("envName", Value::String("staging".into()));
387 let world = World::new(store);
388
389 let artifact = emit(&scenario(), "500_demo", &world).unwrap();
390 assert_eq!(artifact.slug, "500-demo--search-finds-a-client");
391
392 let lines: Vec<&str> = artifact.hurl_text.lines().collect();
393 assert_eq!(lines[0], "# proef artifact — Search finds a client");
394 assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
395 assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
396 assert_eq!(
397 lines[4],
398 "# tests/features/demo.feature:5 — the service is healthy"
399 );
400 assert_eq!(lines[5], "# optional");
401 assert_eq!(lines[6], "GET http://x/health");
402
403 let map = &artifact.map;
405 assert_eq!(map.schema, 1);
406 assert_eq!(map.entries.len(), 2);
407 assert_eq!(map.entries[0].hurl_lines, [7, 8]);
408 assert!(map.entries[0].optional);
409 assert_eq!(map.entries[0].batch, 0);
410 assert_eq!(map.entries[1].captures, vec!["clientId"]);
411 assert_eq!(map.entries[1].batch, 1);
412 let [start, end] = map.entries[1].hurl_lines;
413 assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
414 assert_eq!(end - start, 3);
415
416 let vars = artifact.vars.unwrap();
418 assert!(vars.contains("envName=staging"), "{vars}");
419 assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
420 assert!(!vars.contains("apiToken=\n"), "secret values never appear");
421 }
422
423 #[test]
424 fn no_hurl_entries_means_no_artifact() {
425 let empty = LoweredScenario {
426 name: "n".to_owned(),
427 tags: Vec::new(),
428 line: 1,
429 batches: Vec::new(),
430 secrets: BTreeSet::new(),
431 globals: BTreeSet::new(),
432 warnings: Vec::new(),
433 };
434 assert!(emit(&empty, "f", &World::default()).is_none());
435 }
436
437 #[test]
438 fn slugs_are_file_safe_and_stable() {
439 assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
440 assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
441 assert_eq!(slugify(" -- "), "");
442 }
443
444 #[test]
445 fn emission_is_deterministic() {
446 let world = World::default();
447 let a = emit(&scenario(), "500_demo", &world).unwrap();
448 let b = emit(&scenario(), "500_demo", &world).unwrap();
449 assert_eq!(a.hurl_text, b.hurl_text);
450 assert_eq!(
451 serde_json::to_string(&a.map).unwrap(),
452 serde_json::to_string(&b.map).unwrap()
453 );
454 }
455}