use std::fmt::Write as _;
use serde::Serialize;
use crate::lower::LoweredScenario;
use crate::step::{StepPayload, StepRef};
use crate::world::World;
pub const MAP_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct Artifact {
pub slug: String,
pub hurl_text: String,
pub map: SidecarMap,
pub vars: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SidecarMap {
pub schema: u32,
pub entries: Vec<MapEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MapEntry {
pub hurl_lines: [usize; 2],
pub feature: FeatureAnchor,
pub optional: bool,
pub captures: Vec<String>,
pub batch: usize,
pub step: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct FeatureAnchor {
pub file: String,
pub line: usize,
pub text: String,
}
pub fn emit(scenario: &LoweredScenario, feature_stem: &str, world: &World) -> Option<Artifact> {
let slug = format!("{}--{}", slugify(feature_stem), slugify(&scenario.name));
let has_vars = !scenario.globals.is_empty() || !scenario.secrets.is_empty();
let mut steps: Vec<(usize, usize, &crate::step::LoweredStep)> = Vec::new();
for (batch_index, batch) in scenario.batches.iter().enumerate() {
for (step_index, step) in batch.steps.iter().enumerate() {
if matches!(
step.payload,
StepPayload::HurlEntries(_) | StepPayload::MergedAsserts { .. }
) {
steps.push((batch_index, step_index, step));
}
}
}
let (_, _, first_step) = *steps
.iter()
.find(|(_, _, s)| matches!(s.payload, StepPayload::HurlEntries(_)))?;
let mut text = String::new();
let mut line = 0usize;
let push_line = |text: &mut String, line: &mut usize, content: &str| {
text.push_str(content);
text.push('\n');
*line += 1;
};
push_line(
&mut text,
&mut line,
&format!("# proef artifact — {}", scenario.name),
);
push_line(
&mut text,
&mut line,
&format!("# source: {}:{}", first_step.step.file, scenario.line),
);
let mut replay = format!("# replay: hurl --test {slug}.hurl");
if has_vars {
let _ = write!(replay, " --variables-file {slug}.vars");
}
for secret in &scenario.secrets {
let _ = write!(replay, " --secret {secret}=<value>");
}
push_line(&mut text, &mut line, &replay);
let mut entries = Vec::new();
let mut index = 0usize;
while index < steps.len() {
let (batch_index, step_index, step) = steps[index];
let StepPayload::HurlEntries(payload) = &step.payload else {
index += 1;
continue;
};
push_line(&mut text, &mut line, "");
push_line(
&mut text,
&mut line,
&entry_comment(&step.step, step.label.as_deref()),
);
if step.optional {
push_line(&mut text, &mut line, "# optional");
}
let body: Vec<&str> = trimmed_lines(payload);
let start = line + 1;
for body_line in &body {
push_line(&mut text, &mut line, body_line);
}
entries.push(MapEntry {
hurl_lines: [start, line],
feature: FeatureAnchor {
file: step.step.file.to_string(),
line: step.step.line,
text: step.step.text.to_string(),
},
optional: step.optional,
captures: capture_names(&body),
batch: batch_index,
step: step_index,
});
index += 1;
let first_merged = index;
while index < steps.len()
&& matches!(steps[index].2.payload, StepPayload::MergedAsserts { .. })
{
index += 1;
}
entries.extend(merged_map_entries(&steps[first_merged..index], line));
}
Some(Artifact {
hurl_text: text,
map: SidecarMap {
schema: MAP_SCHEMA_VERSION,
entries,
},
vars: has_vars.then(|| vars_content(scenario, &slug, world)),
slug,
})
}
fn merged_map_entries(
followers: &[(usize, usize, &crate::step::LoweredStep)],
entry_end: usize,
) -> Vec<MapEntry> {
let total: usize = followers
.iter()
.map(|&(_, _, merged)| match merged.payload {
StepPayload::MergedAsserts { lines } => lines,
_ => unreachable!("followers are delimited by the MergedAsserts match"),
})
.sum();
let mut start = entry_end.saturating_sub(total) + 1;
followers
.iter()
.map(|&(batch, step, merged)| {
let StepPayload::MergedAsserts { lines } = merged.payload else {
unreachable!("followers are delimited by the MergedAsserts match");
};
let span = [start, start + lines - 1];
start += lines;
MapEntry {
hurl_lines: span,
feature: FeatureAnchor {
file: merged.step.file.to_string(),
line: merged.step.line,
text: merged.step.text.to_string(),
},
optional: merged.optional,
captures: Vec::new(),
batch,
step,
}
})
.collect()
}
fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
match label {
Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
None => format!("# {}:{} — {}", step.file, step.line, step.text),
}
}
fn trimmed_lines(payload: &str) -> Vec<&str> {
let mut lines: Vec<&str> = payload.lines().collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
lines
}
fn capture_names(body: &[&str]) -> Vec<String> {
let mut names = Vec::new();
let mut in_captures = false;
for line in body {
let trimmed = line.trim();
if trimmed == "[Captures]" {
in_captures = true;
continue;
}
if trimmed.starts_with('[') {
in_captures = false;
continue;
}
if starts_entry_line(trimmed) {
in_captures = false;
continue;
}
if trimmed.starts_with('{') || trimmed.starts_with('<') || trimmed.starts_with("```") {
in_captures = false;
continue;
}
if in_captures && let Some((name, _)) = trimmed.split_once(':') {
let name = name.trim();
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
names.push(name.to_owned());
}
}
}
names
}
fn starts_entry_line(trimmed: &str) -> bool {
const STARTERS: &[&str] = &[
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "HTTP ", "HTTP/",
];
trimmed.starts_with('#') || STARTERS.iter().any(|s| trimmed.starts_with(s))
}
pub fn file_references(hurl_text: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for line in hurl_text.lines() {
let mut rest = line;
while let Some(position) = rest.find("file,") {
let tail = &rest[position + "file,".len()..];
let Some(end) = tail.find(';') else { break };
let name = tail[..end].trim();
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_owned());
}
rest = &tail[end + 1..];
}
}
names
}
fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "# proef variables for {slug}.hurl");
for name in &scenario.globals {
match world.get(name) {
Some(value) => {
let rendered = value.to_string();
if rendered.contains(['\n', '\r']) {
let _ = writeln!(
out,
"# global `{name}` is not line-representable (value contains a newline)\n{name}="
);
} else {
let _ = writeln!(out, "{name}={rendered}");
}
}
None => {
let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
}
}
}
for name in &scenario.secrets {
let _ = writeln!(
out,
"# secret `{name}` — supply at replay: --secret {name}=<value>"
);
}
out
}
pub fn slugify(text: &str) -> String {
let mut slug = String::with_capacity(text.len());
let mut dash_pending = false;
for c in text.chars() {
if c.is_alphanumeric() {
if dash_pending && !slug.is_empty() {
slug.push('-');
}
dash_pending = false;
slug.extend(c.to_lowercase());
} else {
dash_pending = true;
}
}
slug
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use super::*;
use crate::engine::EngineId;
use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
use crate::world::{GlobalStore, Value};
fn step(
line: usize,
text: &str,
payload: &str,
optional: bool,
label: Option<&str>,
) -> LoweredStep {
LoweredStep {
step: StepRef {
file: Arc::from("tests/features/demo.feature"),
line,
text: Arc::from(text),
},
kind: StepKindId::from("hurl"),
payload: StepPayload::HurlEntries(payload.to_owned()),
optional,
when: None,
label: label.map(ToOwned::to_owned),
save_as: BTreeMap::new(),
}
}
fn scenario() -> LoweredScenario {
LoweredScenario {
name: "Search finds a record".to_owned(),
tags: vec!["api".to_owned()],
line: 4,
batches: vec![
StepBatch {
index: 0,
engine: EngineId::from("hurl"),
steps: vec![step(
5,
"the service is healthy",
"GET http://x/health\nHTTP 200\n\n",
true,
None,
)],
},
StepBatch {
index: 1,
engine: EngineId::from("hurl"),
steps: vec![step(
6,
"I search for \"Jansen\"",
"GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nrecordId: jsonpath \"$[0].id\"",
false,
Some("run the search"),
)],
},
],
secrets: BTreeSet::from(["apiToken".to_owned()]),
globals: BTreeSet::from(["envName".to_owned()]),
warnings: Vec::new(),
}
}
#[test]
fn capture_scan_ends_at_the_next_entry() {
let body = [
"GET http://x/a",
"HTTP 200",
"[Captures]",
"id: jsonpath \"$.id\"",
"",
"# — next request",
"GET http://x/b",
"HTTP 200",
];
assert_eq!(capture_names(&body), vec!["id"]);
}
#[test]
fn file_references_finds_file_bodies_and_multipart_parts() {
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";
assert_eq!(
file_references(text),
vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
);
}
#[test]
fn canonical_layout_map_and_vars() {
let mut store = GlobalStore::new();
store.insert("envName", Value::String("staging".into()));
let world = World::new(store);
let artifact = emit(&scenario(), "500_demo", &world).unwrap();
assert_eq!(artifact.slug, "500-demo--search-finds-a-record");
let lines: Vec<&str> = artifact.hurl_text.lines().collect();
assert_eq!(lines[0], "# proef artifact — Search finds a record");
assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
assert_eq!(
lines[4],
"# tests/features/demo.feature:5 — the service is healthy"
);
assert_eq!(lines[5], "# optional");
assert_eq!(lines[6], "GET http://x/health");
let map = &artifact.map;
assert_eq!(map.schema, 1);
assert_eq!(map.entries.len(), 2);
assert_eq!(map.entries[0].hurl_lines, [7, 8]);
assert!(map.entries[0].optional);
assert_eq!(map.entries[0].batch, 0);
assert_eq!(map.entries[1].captures, vec!["recordId"]);
assert_eq!(map.entries[1].batch, 1);
let [start, end] = map.entries[1].hurl_lines;
assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
assert_eq!(end - start, 3);
let vars = artifact.vars.unwrap();
assert!(vars.contains("envName=staging"), "{vars}");
assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
assert!(!vars.contains("apiToken=\n"), "secret values never appear");
}
#[test]
fn no_hurl_entries_means_no_artifact() {
let empty = LoweredScenario {
name: "n".to_owned(),
tags: Vec::new(),
line: 1,
batches: Vec::new(),
secrets: BTreeSet::new(),
globals: BTreeSet::new(),
warnings: Vec::new(),
};
assert!(emit(&empty, "f", &World::default()).is_none());
}
#[test]
fn slugs_are_file_safe_and_stable() {
assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
assert_eq!(slugify(" -- "), "");
}
#[test]
fn emission_is_deterministic() {
let world = World::default();
let a = emit(&scenario(), "500_demo", &world).unwrap();
let b = emit(&scenario(), "500_demo", &world).unwrap();
assert_eq!(a.hurl_text, b.hurl_text);
assert_eq!(
serde_json::to_string(&a.map).unwrap(),
serde_json::to_string(&b.map).unwrap()
);
}
}