Skip to main content

proef_core/
emit.rs

1//! Canonical artifact emission (ADR-0010, TECH-SPEC §4.5).
2//!
3//! For every scenario the emitter produces canonical `.hurl` text — **that
4//! exact text** is what `parse_hurl_file` + `run_entries` execute, so drift
5//! between artifact and execution is structurally impossible. Alongside it:
6//! the sidecar map (`<slug>.map.json`, schema v1: entry ↔ feature anchor,
7//! optional flags, capture names, batch boundaries) and, when the scenario
8//! references globals or secrets, a `<slug>.vars` file so the backend team can
9//! replay with `hurl --variables-file`. Secrets appear as *names only* —
10//! values never enter any artifact (ADR-0005).
11//!
12//! The canonical format is a compatibility surface, locked by the insta
13//! snapshot corpus — emitter changes require deliberate `cargo insta review`.
14
15use std::fmt::Write as _;
16
17use serde::Serialize;
18
19use crate::lower::LoweredScenario;
20use crate::step::{StepPayload, StepRef};
21use crate::world::World;
22
23/// Sidecar map schema version.
24pub const MAP_SCHEMA_VERSION: u32 = 1;
25
26/// One scenario's emitted artifact set.
27#[derive(Debug, Clone)]
28pub struct Artifact {
29    /// File-safe artifact name: `<feature-stem>--<scenario-slug>`.
30    pub slug: String,
31    /// The canonical `.hurl` text — the executed input (ADR-0010).
32    pub hurl_text: String,
33    /// The sidecar map (`<slug>.map.json`).
34    pub map: SidecarMap,
35    /// `<slug>.vars` content, when globals or secrets are referenced.
36    pub vars: Option<String>,
37}
38
39/// Sidecar map: entry ↔ feature anchors (TECH-SPEC §4.5, schema v1).
40#[derive(Debug, Clone, Serialize)]
41pub struct SidecarMap {
42    /// Schema version ([`MAP_SCHEMA_VERSION`]).
43    pub schema: u32,
44    /// One record per emitted entry block, in file order.
45    pub entries: Vec<MapEntry>,
46}
47
48/// One entry block in the artifact.
49#[derive(Debug, Clone, Serialize)]
50pub struct MapEntry {
51    /// 1-based inclusive line range of the entry's hurl text (comments excluded).
52    pub hurl_lines: [usize; 2],
53    /// The authored feature step this entry came from.
54    pub feature: FeatureAnchor,
55    /// Whether the step was `optional:`.
56    pub optional: bool,
57    /// Capture names this entry produces (never values).
58    pub captures: Vec<String>,
59    /// Batch index within the scenario (segmentation boundaries, ADR-0010).
60    pub batch: usize,
61    /// 0-based step ordinal *within the batch* — the sidecar↔step link is
62    /// explicit, never positional (steps without hurl entries would otherwise
63    /// shift the correspondence).
64    pub step: usize,
65}
66
67/// Feature anchor for one entry.
68#[derive(Debug, Clone, Serialize)]
69pub struct FeatureAnchor {
70    /// Feature file path as authored.
71    pub file: String,
72    /// 1-based step line.
73    pub line: usize,
74    /// Step text (keyword stripped).
75    pub text: String,
76}
77
78/// Emit one scenario's artifact set. `None` when the scenario lowers to no
79/// hurl entries (nothing to hand to the engine or the backend team).
80pub 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    // A mixed scenario may open with another engine's batch — the sidecar's
93    // real batch/step indices carry the mapping; no positional assumption holds.
94    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        // Placeholders, never values (ADR-0005) — the human fills them in.
120        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
165/// `# <file>:<line> — <step text>` (plus the pack entry label when present).
166fn 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
173/// Payload lines with trailing blank lines dropped (internal lines verbatim —
174/// they are already-validated hurl).
175fn 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
183/// Capture names declared in `[Captures]` sections (a textual scan over our
184/// own canonical text — the engine parses it for real).
185fn 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        // A new entry (method/status line or comment) ends the section — a
199        // stray `k: v`-shaped line after it must not read as a capture.
200        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
215/// Does this canonical-emission line open a new request or response (ending
216/// any `[Captures]` run)?
217fn 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
224/// Filenames referenced as hurl `file,<name>;` bodies or multipart parts in
225/// the artifact text. Stock `hurl --test <file>` resolves them relative to the
226/// `.hurl` file, so callers copy these next to emitted artifacts to keep the
227/// hand-off self-contained (ADR-0010).
228pub 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
245/// `<slug>.vars`: referenced globals as `name=value` (value from the World at
246/// emit time), secrets as names only (ADR-0005).
247fn 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
271/// File-safe slug: lowercase alphanumerics, everything else collapses to `-`.
272pub 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        // Map: line ranges point at the hurl text (comments excluded), 1-based.
404        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        // Vars: global value baked, secret as name only.
417        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}