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