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, is_method_line};
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 variable in scenario.secrets.keys() {
124        // Placeholders, never values (ADR-0005) — the human fills them in.
125        // Keyed by the *hurl* variable name, which is what a replay must set;
126        // a binding may have renamed it away from the secret's own name.
127        let _ = write!(replay, " --secret {variable}=<value>");
128    }
129    push_line(&mut text, &mut line, &replay);
130
131    let mut entries = Vec::new();
132    let mut index = 0usize;
133    while index < steps.len() {
134        let (batch_index, step_index, step) = steps[index];
135        let StepPayload::HurlEntries(payload) = &step.payload else {
136            // A merged-asserts step before any request cannot lower (the
137            // `then_before_when` diagnostic fires) — nothing to render.
138            index += 1;
139            continue;
140        };
141        push_line(&mut text, &mut line, "");
142        push_line(
143            &mut text,
144            &mut line,
145            &entry_comment(&step.step, step.label.as_deref()),
146        );
147        if step.optional {
148            push_line(&mut text, &mut line, "# optional");
149        }
150        let body: Vec<&str> = trimmed_lines(payload);
151        let start = line + 1;
152        for body_line in &body {
153            push_line(&mut text, &mut line, body_line);
154        }
155        entries.push(MapEntry {
156            hurl_lines: [start, line],
157            feature: FeatureAnchor {
158                file: step.step.file.to_string(),
159                line: step.step.line,
160                text: step.step.text.to_string(),
161            },
162            optional: step.optional,
163            captures: capture_names(&body),
164            batch: batch_index,
165            step: step_index,
166        });
167
168        // Merged-asserts steps own the trailing assert lines of the entry
169        // just rendered (§2.7); their text is already inside `body`.
170        index += 1;
171        let first_merged = index;
172        while index < steps.len()
173            && matches!(steps[index].2.payload, StepPayload::MergedAsserts { .. })
174        {
175            index += 1;
176        }
177        entries.extend(merged_map_entries(&steps[first_merged..index], line));
178    }
179
180    Some(Artifact {
181        hurl_text: text,
182        map: SidecarMap {
183            schema: MAP_SCHEMA_VERSION,
184            entries,
185        },
186        vars: has_vars.then(|| vars_content(scenario, &slug, world)),
187        slug,
188    })
189}
190
191/// Sidecar rows for the merged-asserts steps that follow one rendered entry
192/// (§2.7): the merges own the entry's trailing lines in order, so spans are
193/// one forward pass from `entry_end - total`. The caller guarantees every
194/// follower is `MergedAsserts` (that is how the range was delimited).
195fn merged_map_entries(
196    followers: &[(usize, usize, &crate::step::LoweredStep)],
197    entry_end: usize,
198) -> Vec<MapEntry> {
199    let total: usize = followers
200        .iter()
201        .map(|&(_, _, merged)| match merged.payload {
202            StepPayload::MergedAsserts { lines } => lines,
203            _ => unreachable!("followers are delimited by the MergedAsserts match"),
204        })
205        .sum();
206    let mut start = entry_end.saturating_sub(total) + 1;
207    followers
208        .iter()
209        .filter_map(|&(batch, step, merged)| {
210            let StepPayload::MergedAsserts { lines } = merged.payload else {
211                unreachable!("followers are delimited by the MergedAsserts match");
212            };
213            // A `Then` whose fragment resolved to nothing (e.g. an
214            // env-conditional `${vars:key}` that is blank in this
215            // environment — pack validation sees only the unresolved,
216            // non-blank text, so it cannot catch this) appended zero lines
217            // to the entry: there is no hurl-text span for it to own. Any
218            // span we could invent here either inverts (`start > end`) or
219            // falsely claims a line another follower already owns, so the
220            // step gets no sidecar row at all — nothing was emitted, so
221            // nothing is reported. `start` is left untouched (`+= 0`), so
222            // this can never perturb a later follower's span.
223            if lines == 0 {
224                return None;
225            }
226            let span = [start, start + lines - 1];
227            start += lines;
228            Some(MapEntry {
229                hurl_lines: span,
230                feature: FeatureAnchor {
231                    file: merged.step.file.to_string(),
232                    line: merged.step.line,
233                    text: merged.step.text.to_string(),
234                },
235                optional: merged.optional,
236                captures: Vec::new(),
237                batch,
238                step,
239            })
240        })
241        .collect()
242}
243
244/// `# <file>:<line> — <step text>` (plus the pack entry label when present).
245fn entry_comment(step: &StepRef, label: Option<&str>) -> String {
246    match label {
247        Some(label) => format!("# {}:{} — {} ({label})", step.file, step.line, step.text),
248        None => format!("# {}:{} — {}", step.file, step.line, step.text),
249    }
250}
251
252/// Payload lines with trailing blank lines dropped (internal lines verbatim —
253/// they are already-validated hurl).
254fn trimmed_lines(payload: &str) -> Vec<&str> {
255    let mut lines: Vec<&str> = payload.lines().collect();
256    while lines.last().is_some_and(|l| l.trim().is_empty()) {
257        lines.pop();
258    }
259    lines
260}
261
262/// Capture names declared in `[Captures]` sections (a textual scan over our
263/// own canonical text — the engine parses it for real).
264///
265/// Fence-aware: a fenced (```…```) body is opaque to the scan — a literal
266/// `[Captures]` line inside a docstring body must not re-arm it — and a
267/// custom-method entry line ends the previous entry via [`is_method_line`],
268/// the same recogniser the lowering pass uses. Otherwise phantom rows reach
269/// `.map.json`, a normative artifact (ADR-0010).
270pub(crate) fn capture_names(body: &[&str]) -> Vec<String> {
271    let mut names = Vec::new();
272    let mut in_captures = false;
273    let mut in_fence = false;
274    for line in body {
275        let trimmed = line.trim();
276        if trimmed.starts_with("```") {
277            in_fence = !in_fence;
278            in_captures = false;
279            continue;
280        }
281        if in_fence {
282            continue;
283        }
284        if trimmed == "[Captures]" {
285            in_captures = true;
286            continue;
287        }
288        if trimmed.starts_with('[') {
289            in_captures = false;
290            continue;
291        }
292        // A capture-shaped line inside an open run is a capture, full stop —
293        // checked ahead of `starts_entry_line` because the generic recogniser
294        // cannot tell an uppercase capture name (hurl permits a space before
295        // its `:`) from a custom-method entry line, and must not be allowed
296        // to guess wrong on this scan's own territory.
297        if in_captures && let Some(name) = capture_name(trimmed) {
298            names.push(name.to_owned());
299            continue;
300        }
301        // A new entry (method or response line) ends the section — a stray
302        // `k: v`-shaped line after it must not read as a capture.
303        if starts_entry_line(trimmed) {
304            in_captures = false;
305            continue;
306        }
307        // So does a body opener: nothing after it in this entry is a capture.
308        if trimmed.starts_with('{') || trimmed.starts_with('<') {
309            in_captures = false;
310        }
311    }
312    names
313}
314
315/// Is `trimmed` shaped like a capture definition (`name: query`)? Bare
316/// identifiers only — a JSON body line (`"status": "ok"`) must never read as
317/// the capture `"status"`, and an entry-opening line never has this shape (a
318/// method line's first token carries no colon; a response line has no colon
319/// at all).
320fn capture_name(trimmed: &str) -> Option<&str> {
321    let (name, _) = trimmed.split_once(':')?;
322    let name = name.trim();
323    (!name.is_empty()
324        && name
325            .chars()
326            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
327    .then_some(name)
328}
329
330/// Does this canonical-emission line open a new request or response (ending
331/// any `[Captures]` run)? Requests are recognised via [`is_method_line`] —
332/// the lowering pass's recogniser — so a custom method (`PROPFIND`, …) ends
333/// the scan exactly as it ends an entry there. The response check requires
334/// its own delimiter (`HTTP ` / `HTTP/`) rather than a bare prefix match — a
335/// capture merely *named* starting with `HTTP` (`HTTPStatus: …`) is not a
336/// response line, and must not be read as one.
337///
338/// A comment is **not** an entry opener. Commenting a capture is ordinary
339/// authoring, and closing the run on `#` dropped every capture after the
340/// comment from `.map.json` (ADR-0010). Nothing needs it to close: the entry
341/// that follows opens with a method or response line, which closes the run
342/// itself. (One gap: [`is_method_line`] wants three characters, so a one- or
343/// two-letter method — legal hurl, unwritten in practice — opens an entry
344/// this scan does not see, and `#` no longer covers for it.)
345fn starts_entry_line(trimmed: &str) -> bool {
346    trimmed.starts_with("HTTP ") || trimmed.starts_with("HTTP/") || is_method_line(trimmed)
347}
348
349/// Filenames referenced as hurl `file,<name>;` bodies or multipart parts in
350/// the artifact text. Stock `hurl --test <file>` resolves them relative to the
351/// `.hurl` file, so callers copy these next to emitted artifacts to keep the
352/// hand-off self-contained (ADR-0010).
353pub fn file_references(hurl_text: &str) -> Vec<String> {
354    let mut names: Vec<String> = Vec::new();
355    for line in hurl_text.lines() {
356        let mut rest = line;
357        while let Some(position) = rest.find("file,") {
358            let tail = &rest[position + "file,".len()..];
359            let Some(end) = tail.find(';') else { break };
360            let name = tail[..end].trim();
361            if !name.is_empty() && !names.iter().any(|n| n == name) {
362                names.push(name.to_owned());
363            }
364            rest = &tail[end + 1..];
365        }
366    }
367    names
368}
369
370/// `<slug>.vars`: referenced globals as `name=value` (value from the World at
371/// emit time), secrets as names only (ADR-0005).
372fn vars_content(scenario: &LoweredScenario, slug: &str, world: &World) -> String {
373    use std::fmt::Write as _;
374
375    let mut out = String::new();
376    let _ = writeln!(out, "# proef variables for {slug}.hurl");
377    for name in &scenario.globals {
378        match world.get(name) {
379            Some(value) => {
380                let rendered = value.to_string();
381                if rendered.contains(['\n', '\r']) {
382                    // A raw newline would corrupt the `name=value` line format
383                    // `hurl --variables-file` parses — degrade like an unset
384                    // global, with the reason on record.
385                    let _ = writeln!(
386                        out,
387                        "# global `{name}` is not line-representable (value contains a newline)\n{name}="
388                    );
389                } else {
390                    let _ = writeln!(out, "{name}={rendered}");
391                }
392            }
393            None => {
394                let _ = writeln!(out, "# global `{name}` was unset at emit time\n{name}=");
395            }
396        }
397    }
398    for (variable, secret) in &scenario.secrets {
399        // Names only, never values (ADR-0005). When a binding renamed one, say
400        // both: the replay flag needs the variable, the vault needs the secret.
401        let source = if variable == secret {
402            String::new()
403        } else {
404            format!(" (from secret `{secret}`)")
405        };
406        let _ = writeln!(
407            out,
408            "# secret `{variable}`{source} — supply at replay: --secret {variable}=<value>"
409        );
410    }
411    out
412}
413
414/// File-safe slug: lowercase alphanumerics, everything else collapses to `-`.
415pub fn slugify(text: &str) -> String {
416    let mut slug = String::with_capacity(text.len());
417    let mut dash_pending = false;
418    for c in text.chars() {
419        if c.is_alphanumeric() {
420            if dash_pending && !slug.is_empty() {
421                slug.push('-');
422            }
423            dash_pending = false;
424            slug.extend(c.to_lowercase());
425        } else {
426            dash_pending = true;
427        }
428    }
429    slug
430}
431
432#[cfg(test)]
433mod tests {
434    #![allow(clippy::unwrap_used)]
435
436    use std::collections::{BTreeMap, BTreeSet};
437    use std::sync::Arc;
438
439    use super::*;
440    use crate::engine::EngineId;
441    use crate::step::{LoweredStep, StepBatch, StepKindId, StepRef};
442    use crate::world::{GlobalStore, Value};
443
444    fn step(
445        line: usize,
446        text: &str,
447        payload: &str,
448        optional: bool,
449        label: Option<&str>,
450    ) -> LoweredStep {
451        LoweredStep {
452            step: StepRef {
453                file: Arc::from("tests/features/demo.feature"),
454                line,
455                text: Arc::from(text),
456            },
457            kind: StepKindId::from("hurl"),
458            payload: StepPayload::HurlEntries(payload.to_owned()),
459            optional,
460            when: None,
461            label: label.map(ToOwned::to_owned),
462            fragment: None,
463            save_as: BTreeMap::new(),
464        }
465    }
466
467    fn scenario() -> LoweredScenario {
468        LoweredScenario {
469            name: "Search finds a record".to_owned(),
470            tags: vec!["api".to_owned()],
471            line: 4,
472            batches: vec![
473                StepBatch {
474                    index: 0,
475                    engine: EngineId::from("hurl"),
476                    steps: vec![step(
477                        5,
478                        "the service is healthy",
479                        "GET http://x/health\nHTTP 200\n\n",
480                        true,
481                        None,
482                    )],
483                },
484                StepBatch {
485                    index: 1,
486                    engine: EngineId::from("hurl"),
487                    steps: vec![step(
488                        6,
489                        "I search for \"Jansen\"",
490                        "GET http://x/search?q=Jansen\nHTTP 200\n[Captures]\nrecordId: jsonpath \"$[0].id\"",
491                        false,
492                        Some("run the search"),
493                    )],
494                },
495            ],
496            secrets: BTreeMap::from([("apiToken".to_owned(), "apiToken".to_owned())]),
497            globals: BTreeSet::from(["envName".to_owned()]),
498            warnings: Vec::new(),
499        }
500    }
501
502    #[test]
503    fn capture_scan_ends_at_the_next_entry() {
504        let body = [
505            "GET http://x/a",
506            "HTTP 200",
507            "[Captures]",
508            "id: jsonpath \"$.id\"",
509            "",
510            "# — next request",
511            "GET http://x/b",
512            "HTTP 200",
513        ];
514        assert_eq!(capture_names(&body), vec!["id"]);
515    }
516
517    #[test]
518    fn capture_scan_ignores_fenced_lines_and_ends_at_custom_methods() {
519        // A fenced `[Captures]` must not re-arm the scan, and a custom method
520        // must end the previous entry — otherwise phantom rows reach the
521        // sidecar, which is a normative artifact (ADR-0010).
522        let body = [
523            "GET http://x/a",
524            "HTTP 200",
525            "[Captures]",
526            "real: jsonpath \"$.id\"",
527            "",
528            "PROPFIND http://x/b",
529            "```",
530            "[Captures]",
531            "phantom: jsonpath \"$.nope\"",
532            "```",
533            "HTTP 207",
534        ];
535        let names = capture_names(&body);
536        assert!(names.contains(&"real".to_owned()), "{names:?}");
537        assert!(
538            !names.contains(&"phantom".to_owned()),
539            "fenced capture leaked into the sidecar: {names:?}"
540        );
541    }
542
543    #[test]
544    fn capture_names_keeps_a_capture_whose_name_starts_with_http() {
545        // End-to-end invariant: a capture merely *named* starting with
546        // "HTTP" (e.g. `HTTPStatus`) must survive the scan, or it — and
547        // every capture after it in the entry — is silently missing from
548        // the sidecar (ADR-0010: no legitimate row may be dropped). This
549        // goes green via the capture-shape guard in `capture_names`, which
550        // recognises `HTTPStatus: …` as a capture before `starts_entry_line`
551        // is ever consulted; `starts_entry_line_requires_a_delimiter_after_http`
552        // pins the response-line predicate itself.
553        let body = [
554            "GET http://x/a",
555            "HTTP 200",
556            "[Captures]",
557            "HTTPStatus: jsonpath \"$.status\"",
558            "plain: jsonpath \"$.id\"",
559        ];
560        assert_eq!(
561            capture_names(&body),
562            vec!["HTTPStatus".to_owned(), "plain".to_owned()]
563        );
564    }
565
566    #[test]
567    fn starts_entry_line_requires_a_delimiter_after_http() {
568        // Pins the predicate directly: the response check must require its
569        // own delimiter (`HTTP ` / `HTTP/`), not a bare prefix match, or it
570        // misreads a capture merely *named* starting with `HTTP` as a
571        // response line. Real response lines must still match.
572        assert!(!starts_entry_line("HTTPStatus: jsonpath \"$.status\""));
573        assert!(starts_entry_line("HTTP 200"));
574        assert!(starts_entry_line("HTTP/1.1 200"));
575        // And a custom-method entry line must be recognised too — this is
576        // what `is_method_line` (shared with the lowering pass) buys over a
577        // fixed prefix list of the stock HTTP verbs.
578        assert!(starts_entry_line("PROPFIND http://x/b"));
579    }
580
581    #[test]
582    fn capture_scan_ends_the_previous_entry_at_a_custom_method_line() {
583        // Direct (unfenced) reproduction of the phantom-row hazard: an open
584        // `[Captures]` run must not survive past a custom-method entry line.
585        // A blank line does not close the run on its own (only an
586        // entry-opening line, a body opener, or a new bracketed section
587        // does), so if `PROPFIND` were not recognised as one, `in_captures`
588        // would still be armed when the scan reaches `Depth: 1` — a plain
589        // request header of the *new* entry — and misread it as a capture
590        // named `Depth`. That phantom row would then reach `.map.json`, a
591        // normative artifact (ADR-0010). No fence is involved, so this is
592        // blind to whether fencing alone happens to save the day.
593        let body = [
594            "GET http://x/a",
595            "HTTP 200",
596            "[Captures]",
597            "real: jsonpath \"$.id\"",
598            "PROPFIND http://x/b",
599            "Depth: 1",
600            "HTTP 207",
601        ];
602        let names = capture_names(&body);
603        assert_eq!(
604            names,
605            vec!["real".to_owned()],
606            "a custom-method entry line must end the previous entry's capture scan: {names:?}"
607        );
608    }
609
610    #[test]
611    fn a_comment_inside_a_captures_run_does_not_drop_the_captures_after_it() {
612        // Commenting a capture is ordinary authoring, and a comment carries no
613        // captures of its own, so it must not close the run: doing so drops
614        // every later capture in the entry from `.map.json`, a normative
615        // artifact (ADR-0010). Nothing is lost by letting it through — an
616        // entry always opens with a method or response line, and that ends the
617        // run on its own (`capture_scan_ends_the_previous_entry_at_a_custom_method_line`).
618        let body = [
619            "GET http://x/a",
620            "HTTP 200",
621            "[Captures]",
622            "# the id we reuse later",
623            "id: jsonpath \"$.id\"",
624            "other: jsonpath \"$.other\"",
625        ];
626        let names = capture_names(&body);
627        assert_eq!(
628            names,
629            vec!["id".to_owned(), "other".to_owned()],
630            "a comment inside the run dropped the captures following it: {names:?}"
631        );
632    }
633
634    #[test]
635    fn capture_names_with_a_space_before_the_colon_are_not_mistaken_for_a_method_line() {
636        // hurl's own grammar permits whitespace between a capture's name and
637        // its `:` (space0/space1 in hurl_core's `capture()` parser), so an
638        // all-uppercase capture name written that way (`STATUS : …`) has
639        // exactly the shape `is_method_line` looks for (a ≥3-char uppercase
640        // word followed by another token) — it must still read as a capture,
641        // not as a new entry that ends the scan (ADR-0010: no legitimate row
642        // may be dropped from the sidecar).
643        let body = [
644            "GET http://x/a",
645            "HTTP 200",
646            "[Captures]",
647            "STATUS : jsonpath \"$.s\"",
648            "plain: jsonpath \"$.id\"",
649        ];
650        assert_eq!(
651            capture_names(&body),
652            vec!["STATUS".to_owned(), "plain".to_owned()]
653        );
654    }
655
656    #[test]
657    fn file_references_finds_file_bodies_and_multipart_parts() {
658        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";
659        assert_eq!(
660            file_references(text),
661            vec!["fixture.jpg".to_owned(), "payload.bin".to_owned()]
662        );
663    }
664
665    #[test]
666    fn canonical_layout_map_and_vars() {
667        let mut store = GlobalStore::new();
668        store.insert("envName", Value::String("staging".into()));
669        let world = World::new(store);
670
671        let artifact = emit(&scenario(), "500_demo", &world).unwrap();
672        assert_eq!(artifact.slug, "500-demo--search-finds-a-record");
673
674        let lines: Vec<&str> = artifact.hurl_text.lines().collect();
675        assert_eq!(lines[0], "# proef artifact — Search finds a record");
676        assert_eq!(lines[1], "# source: tests/features/demo.feature:4");
677        assert!(lines[2].contains("--variables-file"), "{}", lines[2]);
678        assert_eq!(
679            lines[4],
680            "# tests/features/demo.feature:5 — the service is healthy"
681        );
682        assert_eq!(lines[5], "# optional");
683        assert_eq!(lines[6], "GET http://x/health");
684
685        // Map: line ranges point at the hurl text (comments excluded), 1-based.
686        let map = &artifact.map;
687        assert_eq!(map.schema, 1);
688        assert_eq!(map.entries.len(), 2);
689        assert_eq!(map.entries[0].hurl_lines, [7, 8]);
690        assert!(map.entries[0].optional);
691        assert_eq!(map.entries[0].batch, 0);
692        assert_eq!(map.entries[1].captures, vec!["recordId"]);
693        assert_eq!(map.entries[1].batch, 1);
694        let [start, end] = map.entries[1].hurl_lines;
695        assert_eq!(lines[start - 1], "GET http://x/search?q=Jansen");
696        assert_eq!(end - start, 3);
697
698        // Vars: global value baked, secret as name only.
699        let vars = artifact.vars.unwrap();
700        assert!(vars.contains("envName=staging"), "{vars}");
701        assert!(vars.contains("--secret apiToken=<value>"), "{vars}");
702        assert!(!vars.contains("apiToken=\n"), "secret values never appear");
703    }
704
705    #[test]
706    fn no_hurl_entries_means_no_artifact() {
707        let empty = LoweredScenario {
708            name: "n".to_owned(),
709            tags: Vec::new(),
710            line: 1,
711            batches: Vec::new(),
712            secrets: BTreeMap::new(),
713            globals: BTreeSet::new(),
714            warnings: Vec::new(),
715        };
716        assert!(emit(&empty, "f", &World::default()).is_none());
717    }
718
719    #[test]
720    fn slugs_are_file_safe_and_stable() {
721        assert_eq!(slugify("500_api message — sync!"), "500-api-message-sync");
722        assert_eq!(slugify("Ütf ærgh"), "ütf-ærgh");
723        assert_eq!(slugify("  --  "), "");
724    }
725
726    #[test]
727    fn emission_is_deterministic() {
728        let world = World::default();
729        let a = emit(&scenario(), "500_demo", &world).unwrap();
730        let b = emit(&scenario(), "500_demo", &world).unwrap();
731        assert_eq!(a.hurl_text, b.hurl_text);
732        assert_eq!(
733            serde_json::to_string(&a.map).unwrap(),
734            serde_json::to_string(&b.map).unwrap()
735        );
736    }
737}