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): the merges own the entry's trailing lines in order, so spans are
191/// one forward pass from `entry_end - total`. The caller guarantees every
192/// follower is `MergedAsserts` (that is how the range was delimited).
193fn merged_map_entries(
194    followers: &[(usize, usize, &crate::step::LoweredStep)],
195    entry_end: usize,
196) -> Vec<MapEntry> {
197    let total: usize = followers
198        .iter()
199        .map(|&(_, _, merged)| match merged.payload {
200            StepPayload::MergedAsserts { lines } => lines,
201            _ => unreachable!("followers are delimited by the MergedAsserts match"),
202        })
203        .sum();
204    let mut start = entry_end.saturating_sub(total) + 1;
205    followers
206        .iter()
207        .map(|&(batch, step, merged)| {
208            let StepPayload::MergedAsserts { lines } = merged.payload else {
209                unreachable!("followers are delimited by the MergedAsserts match");
210            };
211            let span = [start, start + lines - 1];
212            start += lines;
213            MapEntry {
214                hurl_lines: span,
215                feature: FeatureAnchor {
216                    file: merged.step.file.to_string(),
217                    line: merged.step.line,
218                    text: merged.step.text.to_string(),
219                },
220                optional: merged.optional,
221                captures: Vec::new(),
222                batch,
223                step,
224            }
225        })
226        .collect()
227}
228
229/// `# <file>:<line> — <step text>` (plus the pack entry label when present).
230fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
231    match label {
232        Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
233        None => format!("# {}:{} — {}", step.file, step.line, step.text),
234    }
235}
236
237/// Payload lines with trailing blank lines dropped (internal lines verbatim —
238/// they are already-validated hurl).
239fn trimmed_lines(payload: &str) -> Vec<&str> {
240    let mut lines: Vec<&str> = payload.lines().collect();
241    while lines.last().is_some_and(|l| l.trim().is_empty()) {
242        lines.pop();
243    }
244    lines
245}
246
247/// Capture names declared in `[Captures]` sections (a textual scan over our
248/// own canonical text — the engine parses it for real).
249fn capture_names(body: &[&str]) -> Vec<String> {
250    let mut names = Vec::new();
251    let mut in_captures = false;
252    for line in body {
253        let trimmed = line.trim();
254        if trimmed == "[Captures]" {
255            in_captures = true;
256            continue;
257        }
258        if trimmed.starts_with('[') {
259            in_captures = false;
260            continue;
261        }
262        // A new entry (method/status line or comment) ends the section — a
263        // stray `k: v`-shaped line after it must not read as a capture.
264        if starts_entry_line(trimmed) {
265            in_captures = false;
266            continue;
267        }
268        // So does a body opener: nothing after it in this entry is a capture.
269        if trimmed.starts_with('{') || trimmed.starts_with('<') || trimmed.starts_with("```") {
270            in_captures = false;
271            continue;
272        }
273        if in_captures && let Some((name, _)) = trimmed.split_once(':') {
274            let name = name.trim();
275            // Bare identifiers only — a JSON body line (`"status": "ok"`)
276            // must never read as the capture `"status"`.
277            if !name.is_empty()
278                && name
279                    .chars()
280                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
281            {
282                names.push(name.to_owned());
283            }
284        }
285    }
286    names
287}
288
289/// Does this canonical-emission line open a new request or response (ending
290/// any `[Captures]` run)?
291fn starts_entry_line(trimmed: &str) -> bool {
292    const STARTERS: &[&str] = &[
293        "GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "HTTP ", "HTTP/",
294    ];
295    trimmed.starts_with('#') || STARTERS.iter().any(|s| trimmed.starts_with(s))
296}
297
298/// Filenames referenced as hurl `file,<name>;` bodies or multipart parts in
299/// the artifact text. Stock `hurl --test <file>` resolves them relative to the
300/// `.hurl` file, so callers copy these next to emitted artifacts to keep the
301/// hand-off self-contained (ADR-0010).
302pub fn file_references(hurl_text: &str) -> Vec<String> {
303    let mut names: Vec<String> = Vec::new();
304    for line in hurl_text.lines() {
305        let mut rest = line;
306        while let Some(position) = rest.find("file,") {
307            let tail = &rest[position + "file,".len()..];
308            let Some(end) = tail.find(';') else { break };
309            let name = tail[..end].trim();
310            if !name.is_empty() && !names.iter().any(|n| n == name) {
311                names.push(name.to_owned());
312            }
313            rest = &tail[end + 1..];
314        }
315    }
316    names
317}
318
319/// `<slug>.vars`: referenced globals as `name=value` (value from the World at
320/// emit time), secrets as names only (ADR-0005).
321fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
322    use std::fmt::Write as _;
323
324    let mut out = String::new();
325    let _ = writeln!(out, "# proef variables for {slug}.hurl");
326    for name in &scenario.globals {
327        match world.get(name) {
328            Some(value) => {
329                let rendered = value.to_string();
330                if rendered.contains(['\n', '\r']) {
331                    // A raw newline would corrupt the `name=value` line format
332                    // `hurl --variables-file` parses — degrade like an unset
333                    // global, with the reason on record.
334                    let _ = writeln!(
335                        out,
336                        "# global `{name}` is not line-representable (value contains a newline)\n{name}="
337                    );
338                } else {
339                    let _ = writeln!(out, "{name}={rendered}");
340                }
341            }
342            None => {
343                let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
344            }
345        }
346    }
347    for name in &scenario.secrets {
348        let _ = writeln!(
349            out,
350            "# secret `{name}` — supply at replay: --secret {name}=<value>"
351        );
352    }
353    out
354}
355
356/// File-safe slug: lowercase alphanumerics, everything else collapses to `-`.
357pub fn slugify(text: &str) -> String {
358    let mut slug = String::with_capacity(text.len());
359    let mut dash_pending = false;
360    for c in text.chars() {
361        if c.is_alphanumeric() {
362            if dash_pending && !slug.is_empty() {
363                slug.push('-');
364            }
365            dash_pending = false;
366            slug.extend(c.to_lowercase());
367        } else {
368            dash_pending = true;
369        }
370    }
371    slug
372}
373
374#[cfg(test)]
375mod tests {
376    #![allow(clippy::unwrap_used)]
377
378    use std::collections::{BTreeMap, BTreeSet};
379    use std::sync::Arc;
380
381    use super::*;
382    use crate::engine::EngineId;
383    use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
384    use crate::world::{GlobalStore, Value};
385
386    fn step(
387        line: usize,
388        text: &str,
389        payload: &str,
390        optional: bool,
391        label: Option<&str>,
392    ) -> LoweredStep {
393        LoweredStep {
394            step: StepRef {
395                file: Arc::from("tests/features/demo.feature"),
396                line,
397                text: Arc::from(text),
398            },
399            kind: StepKindId::from("hurl"),
400            payload: StepPayload::HurlEntries(payload.to_owned()),
401            optional,
402            when: None,
403            label: label.map(ToOwned::to_owned),
404            save_as: BTreeMap::new(),
405        }
406    }
407
408    fn scenario() -> LoweredScenario {
409        LoweredScenario {
410            name: "Search finds a record".to_owned(),
411            tags: vec!["api".to_owned()],
412            line: 4,
413            batches: vec![
414                StepBatch {
415                    index: 0,
416                    engine: EngineId::from("hurl"),
417                    steps: vec![step(
418                        5,
419                        "the service is healthy",
420                        "GET http://x/health\nHTTP 200\n\n",
421                        true,
422                        None,
423                    )],
424                },
425                StepBatch {
426                    index: 1,
427                    engine: EngineId::from("hurl"),
428                    steps: vec![step(
429                        6,
430                        "I search for \"Jansen\"",
431                        "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nrecordId: jsonpath \"$[0].id\"",
432                        false,
433                        Some("run the search"),
434                    )],
435                },
436            ],
437            secrets: BTreeSet::from(["apiToken".to_owned()]),
438            globals: BTreeSet::from(["envName".to_owned()]),
439            warnings: Vec::new(),
440        }
441    }
442
443    #[test]
444    fn capture_scan_ends_at_the_next_entry() {
445        let body = [
446            "GET http://x/a",
447            "HTTP 200",
448            "[Captures]",
449            "id: jsonpath \"$.id\"",
450            "",
451            "# — next request",
452            "GET http://x/b",
453            "HTTP 200",
454        ];
455        assert_eq!(capture_names(&body), vec!["id"]);
456    }
457
458    #[test]
459    fn file_references_finds_file_bodies_and_multipart_parts() {
460        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";
461        assert_eq!(
462            file_references(text),
463            vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
464        );
465    }
466
467    #[test]
468    fn canonical_layout_map_and_vars() {
469        let mut store = GlobalStore::new();
470        store.insert("envName", Value::String("staging".into()));
471        let world = World::new(store);
472
473        let artifact = emit(&scenario(), "500_demo", &world).unwrap();
474        assert_eq!(artifact.slug, "500-demo--search-finds-a-record");
475
476        let lines: Vec<&str> = artifact.hurl_text.lines().collect();
477        assert_eq!(lines[0], "# proef artifact — Search finds a record");
478        assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
479        assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
480        assert_eq!(
481            lines[4],
482            "# tests/features/demo.feature:5 — the service is healthy"
483        );
484        assert_eq!(lines[5], "# optional");
485        assert_eq!(lines[6], "GET http://x/health");
486
487        // Map: line ranges point at the hurl text (comments excluded), 1-based.
488        let map = &artifact.map;
489        assert_eq!(map.schema, 1);
490        assert_eq!(map.entries.len(), 2);
491        assert_eq!(map.entries[0].hurl_lines, [7, 8]);
492        assert!(map.entries[0].optional);
493        assert_eq!(map.entries[0].batch, 0);
494        assert_eq!(map.entries[1].captures, vec!["recordId"]);
495        assert_eq!(map.entries[1].batch, 1);
496        let [start, end] = map.entries[1].hurl_lines;
497        assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
498        assert_eq!(end - start, 3);
499
500        // Vars: global value baked, secret as name only.
501        let vars = artifact.vars.unwrap();
502        assert!(vars.contains("envName=staging"), "{vars}");
503        assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
504        assert!(!vars.contains("apiToken=\n"), "secret values never appear");
505    }
506
507    #[test]
508    fn no_hurl_entries_means_no_artifact() {
509        let empty = LoweredScenario {
510            name: "n".to_owned(),
511            tags: Vec::new(),
512            line: 1,
513            batches: Vec::new(),
514            secrets: BTreeSet::new(),
515            globals: BTreeSet::new(),
516            warnings: Vec::new(),
517        };
518        assert!(emit(&empty, "f", &World::default()).is_none());
519    }
520
521    #[test]
522    fn slugs_are_file_safe_and_stable() {
523        assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
524        assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
525        assert_eq!(slugify("  --  "), "");
526    }
527
528    #[test]
529    fn emission_is_deterministic() {
530        let world = World::default();
531        let a = emit(&scenario(), "500_demo", &world).unwrap();
532        let b = emit(&scenario(), "500_demo", &world).unwrap();
533        assert_eq!(a.hurl_text, b.hurl_text);
534        assert_eq!(
535            serde_json::to_string(&a.map).unwrap(),
536            serde_json::to_string(&b.map).unwrap()
537        );
538    }
539}