proef_core/step.rs
1//! Lowered, engine-agnostic step and batch types (TECH-SPEC §3).
2//!
3//! A scenario lowers to an ordered list of [`LoweredStep`]s, segmented into
4//! [`StepBatch`]es of **contiguous same-engine steps** (batched maximally — splits
5//! happen only at `optional:` boundaries and engine changes, ADR-0010). Engines
6//! return a [`BatchResult`] with one [`StepOutcome`] per executed step.
7
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::engine::EngineId;
14use crate::error::EngineError;
15
16/// Anchor back to the authored `.feature` source (file, 1-based line, step text).
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct StepRef {
19 /// Feature file path as authored.
20 pub file: Arc<str>,
21 /// 1-based line of the step keyword in the feature file.
22 pub line: usize,
23 /// The full step text (keyword stripped).
24 pub text: Arc<str>,
25}
26
27/// Identifies the *kind* of a macro step (`hurl`, …). A step kind
28/// names the engine that claims it via [`crate::engine::StepKindSpec`] (ADR-0002).
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub struct StepKindId(Arc<str>);
31
32impl StepKindId {
33 /// The kind name as written in packs (without the trailing `:`).
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl From<&str> for StepKindId {
40 fn from(s: &str) -> Self {
41 Self(Arc::from(s))
42 }
43}
44
45impl std::fmt::Display for StepKindId {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(&self.0)
48 }
49}
50
51/// The engine-facing payload of a lowered step.
52#[derive(Debug, Clone, PartialEq)]
53pub enum StepPayload {
54 /// Lowered hurl text (`${…}` resolved, `{{…}}` untouched) — one or more entries.
55 HurlEntries(String),
56 /// Asserts an `expect:` macro merged into the *previous* request entry
57 /// (ADR-0004): the step owns the last `lines` assert lines appended to
58 /// that entry's text. It renders no bytes of its own — the sidecar
59 /// anchors those lines so results attribute to the authored `Then`.
60 MergedAsserts {
61 /// How many assert lines this step appended to the previous entry.
62 lines: usize,
63 },
64 /// Structured payload — reserved for future non-hurl engines (ADR-0004).
65 Structured(serde_json::Value),
66}
67
68/// Finite retry policy for a step (`retry:` — infinite retries are rejected at pack
69/// load by the finite-retry lint, ADR-0007).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub struct Retry {
72 /// Maximum number of retries (finite by construction).
73 pub count: u32,
74 /// Interval between attempts, in milliseconds.
75 pub interval_ms: u64,
76}
77
78/// A `when:` skip guard: the step runs unless the resolved expression is
79/// empty or a literal false (TECH-SPEC §6).
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Guard(pub String);
82
83impl Guard {
84 /// Should the guarded step be skipped? Empty means "no condition met",
85 /// and a literal `false`/`0` (case-insensitive) skips too — an author
86 /// writing `when: ${flag}` with `flag=false` means *skip*, not "the
87 /// string is non-empty, run anyway".
88 pub fn skips(&self) -> bool {
89 let value = self.0.trim();
90 value.is_empty() || value.eq_ignore_ascii_case("false") || value == "0"
91 }
92}
93
94/// One step after macro expansion and `${…}` lowering — engine-agnostic.
95#[derive(Debug, Clone, PartialEq)]
96pub struct LoweredStep {
97 /// Anchor to the authored feature line.
98 pub step: StepRef,
99 /// Which step kind (and therefore which engine) executes this step.
100 pub kind: StepKindId,
101 /// The engine-facing payload.
102 pub payload: StepPayload,
103 /// `optional:` steps warn instead of failing (and segment the batch).
104 pub optional: bool,
105 /// Skip guard, when configured (resolved text; the runtime skips the step
106 /// per [`Guard::skips`]).
107 pub when: Option<Guard>,
108 /// Pack-step entry label (events/console), when authored.
109 pub label: Option<String>,
110 /// The fragment this step executes, qualified as `file.hurl#name`
111 /// (ADR-0018); `None` for an inline `hurl:` block.
112 ///
113 /// Qualified rather than bare because this is what a *reader* of a run
114 /// record needs: ADR-0018 accepted "a test spans three files" as a cost on
115 /// the condition that tooling earn it back, and a bare `admin.search`
116 /// still leaves you grepping for the file it lives in. The `file#name`
117 /// spelling is the one `ref:` itself accepts, so what a record prints can
118 /// be pasted straight back into a pack.
119 pub fragment: Option<String>,
120 /// `saveAs:` promotions: capture name → `global` (ADR-0005).
121 pub save_as: std::collections::BTreeMap<String, String>,
122}
123
124/// A contiguous run of same-engine steps, dispatched as one unit (ADR-0002).
125#[derive(Debug, Clone, PartialEq)]
126pub struct StepBatch {
127 /// Ordinal of this batch within the *scenario* (the sidecar map's `batch`
128 /// key). Engines must select sidecar entries by this index — a per-session
129 /// counter diverges as soon as another engine's batch interleaves.
130 pub index: usize,
131 /// The engine that executes this batch.
132 pub engine: EngineId,
133 /// The steps, in authored order.
134 pub steps: Vec<LoweredStep>,
135}
136
137/// Outcome status of a step or scenario.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Status {
141 /// Ran and passed.
142 Passed,
143 /// Ran and failed.
144 Failed,
145 /// Not run (guard, earlier failure, filter).
146 Skipped,
147 /// An `optional:` step failed — reported as a warning, run continues.
148 Warned,
149}
150
151/// Per-step result reported by an engine.
152#[derive(Debug, Clone)]
153pub struct StepOutcome {
154 /// Anchor to the authored feature line.
155 pub step: StepRef,
156 /// Outcome status.
157 pub status: Status,
158 /// Number of attempts made (≥ 1 once the step ran).
159 pub attempts: u32,
160 /// Wall-clock duration of all attempts.
161 pub duration: Duration,
162 /// Engine-specific detail (assert message, timing breakdown, …).
163 pub detail: Option<String>,
164 /// Messages from earlier, failed attempts of a step that ultimately passed
165 /// — the flaky-failure detail. Empty for a clean single-attempt step;
166 /// engine-agnostic, so any engine with retries can fill it.
167 pub attempt_details: Vec<String>,
168 /// Engine-provided command to reproduce this step alone (engine-hurl fills
169 /// it with the redacted `curl` of the failing request). Set only on failure;
170 /// `None` otherwise and for engines that offer no hint.
171 pub reproduce_hint: Option<String>,
172 /// The fragment this step ran, copied from [`LoweredStep::fragment`]
173 /// (ADR-0018); `None` for an inline block.
174 ///
175 /// Carried on the outcome as well as the event because the two feed
176 /// different readers: the event stream reaches `explain`, while `JUnit` and
177 /// the GitHub summary are built from [`crate::runner::RunSummary`]. CI is
178 /// where a reader is *least* able to go looking, so it is the last place
179 /// provenance should drop out.
180 pub fragment: Option<String>,
181}
182
183/// Result of dispatching one [`StepBatch`] to an engine.
184#[derive(Debug)]
185pub struct BatchResult {
186 /// One outcome per step the engine reached.
187 pub steps: Vec<StepOutcome>,
188 /// A batch-level failure, if the batch stopped early.
189 pub error: Option<EngineError>,
190}
191
192#[cfg(test)]
193mod tests {
194 use super::Guard;
195
196 /// `when:` semantics (TECH-SPEC §6): empty and literal-false skip; any
197 /// other non-empty text runs.
198 #[test]
199 fn guard_skips_on_empty_and_literal_false() {
200 for skipping in ["", " ", "false", "FALSE", "0"] {
201 assert!(Guard(skipping.to_owned()).skips(), "{skipping:?}");
202 }
203 for running in ["true", "yes", "1", "anything"] {
204 assert!(!Guard(running.to_owned()).skips(), "{running:?}");
205 }
206 }
207}