Skip to main content

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}