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)> = 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    // A mixed scenario may open with another engine's batch — the sidecar's
96    // real batch/step indices carry the mapping; no positional assumption holds.
97    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        // Placeholders, never values (ADR-0005) — the human fills them in.
125        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            // A merged-asserts step before any request cannot lower (the
135            // `then_before_when` diagnostic fires) — nothing to render.
136            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        // Merged-asserts steps own the trailing assert lines of the entry
167        // just rendered (§2.7); their text is already inside `body`.
168        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
189/// Sidecar rows for the merged-asserts steps that follow one rendered entry
190/// (§2.7): line spans are assigned back-to-front from the entry's last line
191/// `entry_end` — the last merge sits closest to the end.
192fn 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
224/// `# <file>:<line> — <step text>` (plus the pack entry label when present).
225fn 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
232/// Payload lines with trailing blank lines dropped (internal lines verbatim —
233/// they are already-validated hurl).
234fn 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
242/// Capture names declared in `[Captures]` sections (a textual scan over our
243/// own canonical text — the engine parses it for real).
244fn 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        // A new entry (method/status line or comment) ends the section — a
258        // stray `k: v`-shaped line after it must not read as a capture.
259        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
274/// Does this canonical-emission line open a new request or response (ending
275/// any `[Captures]` run)?
276fn 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
283/// Filenames referenced as hurl `file,<name>;` bodies or multipart parts in
284/// the artifact text. Stock `hurl --test <file>` resolves them relative to the
285/// `.hurl` file, so callers copy these next to emitted artifacts to keep the
286/// hand-off self-contained (ADR-0010).
287pub 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
304/// `<slug>.vars`: referenced globals as `name=value` (value from the World at
305/// emit time), secrets as names only (ADR-0005).
306fn 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
330/// File-safe slug: lowercase alphanumerics, everything else collapses to `-`.
331pub 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        // Map: line ranges point at the hurl text (comments excluded), 1-based.
462        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        // Vars: global value baked, secret as name only.
475        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}