Skip to main content

proef_core/
engine.rs

1//! The engine seam (ADR-0002): `EngineFactory` / `EngineSession`, step-kind routing,
2//! and capability hooks (`step_kinds`, `doctor`).
3//!
4//! Both traits are **sync + dyn** (ADR-0006) and used as `Box<dyn …>`. Adding an
5//! engine is a new crate implementing both traits plus one registry line in
6//! `proef-cli` — with **zero changes to this crate** (the structural acceptance
7//! test for M6).
8//!
9//! # Example: a minimal engine
10//!
11//! ```
12//! use proef_core::cancel::CancellationToken;
13//! use proef_core::engine::{
14//!     DoctorCheck, DoctorResult, EngineFactory, EngineSession, ScenarioCtx, StepKindSpec,
15//! };
16//! use proef_core::error::EngineError;
17//! use proef_core::event::EventSink;
18//! use proef_core::step::{BatchResult, StepBatch};
19//! use proef_core::world::World;
20//!
21//! struct NullEngine;
22//! struct NullSession;
23//!
24//! impl EngineFactory for NullEngine {
25//!     fn id(&self) -> &'static str {
26//!         "null"
27//!     }
28//!     fn step_kinds(&self) -> &'static [StepKindSpec] {
29//!         const KINDS: &[StepKindSpec] = &[StepKindSpec {
30//!             prefix: "null",
31//!             schema: "true",
32//!             validate: None,
33//!             fragments: None,
34//!         }];
35//!         KINDS
36//!     }
37//!     fn doctor(&self) -> Vec<DoctorCheck> {
38//!         Vec::new()
39//!     }
40//!     fn open(&self, _ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError> {
41//!         Ok(Box::new(NullSession))
42//!     }
43//! }
44//!
45//! impl EngineSession for NullSession {
46//!     fn run_batch(
47//!         &mut self,
48//!         batch: &StepBatch,
49//!         _world: &mut World,
50//!         _events: &EventSink,
51//!         _cancel: &CancellationToken,
52//!     ) -> BatchResult {
53//!         BatchResult { steps: Vec::with_capacity(batch.steps.len()), error: None }
54//!     }
55//!     fn finish(&mut self) -> Result<(), EngineError> {
56//!         Ok(())
57//!     }
58//! }
59//!
60//! let factory: Box<dyn EngineFactory> = Box::new(NullEngine);
61//! assert_eq!(factory.id(), "null");
62//! ```
63
64use std::sync::Arc;
65
66use crate::cancel::CancellationToken;
67use crate::error::EngineError;
68use crate::event::EventSink;
69use crate::step::{BatchResult, StepBatch};
70use crate::world::World;
71
72/// Identifies an engine (`hurl`, …). A macro step's kind names the
73/// engine that executes it (ADR-0002 routing).
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct EngineId(Arc<str>);
76
77impl EngineId {
78    /// The engine id as referenced by step kinds and the registry.
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl From<&str> for EngineId {
85    fn from(s: &str) -> Self {
86        Self(Arc::from(s))
87    }
88}
89
90impl std::fmt::Display for EngineId {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.write_str(&self.0)
93    }
94}
95
96/// An engine's claim on a pack step kind: the key prefix (`hurl`; other
97/// prefixes reserved — ADR-0002 errata) plus the
98/// JSON-Schema fragment describing that step's payload, merged into `proef schema`
99/// output (TECH-SPEC §6), and an optional static payload validator used by pack
100/// validation pass 7 (probe-instantiation parse — TECH-SPEC §4.1).
101#[derive(Debug, Clone, Copy)]
102pub struct StepKindSpec {
103    /// Step-kind prefix as written in packs (without the trailing `:`).
104    pub prefix: &'static str,
105    /// JSON-Schema fragment for the step payload (`"true"` = any, until refined).
106    pub schema: &'static str,
107    /// Probe-validate a lowered payload text (`None` = no static validation).
108    /// Keeps the core engine-agnostic: the hurl parser stays behind the seam.
109    pub validate: Option<PayloadValidator>,
110    /// Fragment support, or `None` when the kind has no fragment form
111    /// (ADR-0018). One `Option` rather than a separate extension and scanner,
112    /// so the two can never disagree: a kind that claims `.http` files but
113    /// cannot read them is not expressible.
114    pub fragments: Option<FragmentSupport>,
115}
116
117/// An engine-contributed static payload validator (pack validation pass 7).
118pub type PayloadValidator = fn(&str) -> Result<(), PayloadProbeError>;
119
120/// An engine-contributed reader for one fragment file's whole text (ADR-0018).
121pub type FragmentScanner = fn(&str) -> Result<Vec<ScannedFragment>, FragmentScanError>;
122
123/// What a step kind needs to own a fragment file format: the extension that
124/// identifies one and the parser that reads it. Source discovery asks the
125/// registry for the extension rather than naming a file type itself, so adding
126/// an engine never teaches the CLI a new one (ADR-0002).
127#[derive(Debug, Clone, Copy)]
128pub struct FragmentSupport {
129    /// File extension without the dot (`"hurl"`).
130    pub ext: &'static str,
131    /// Reader for a whole file of that extension.
132    pub scan: FragmentScanner,
133}
134
135/// One entry of a fragment file, as the claiming engine's own parser sees it.
136///
137/// Engine-agnostic by construction: `proef-core` never learns a hurl type, and
138/// a future engine fills the same shape from its own AST. Everything here is
139/// *read* from the entry — nothing is declared separately and nothing can
140/// therefore drift from the file.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ScannedFragment {
143    /// The name its `# @proef <name>` annotation gave it.
144    ///
145    /// A scanner reports **only annotated entries**. An unannotated one is not a
146    /// fragment — nothing can `ref:` it — and a corpus proef did not write is
147    /// expected to be mostly those, so building them only to be discarded is
148    /// the bulk of a scan for no one's benefit.
149    pub name: String,
150    /// The entry's own source text, annotation included (provenance survives
151    /// into the artifact).
152    pub text: String,
153    /// 1-based line the entry starts on, for diagnostics.
154    pub line: usize,
155    /// Every variable the entry *reads*, in first-seen order — its required
156    /// inputs.
157    pub placeholders: Vec<String>,
158    /// Option families the entry sets for itself, which a referencing step may
159    /// then not also set (`proef::pack::option_declared_twice`). A list rather
160    /// than one flag per option, so the core applies its general rule to
161    /// whatever it knows about and a new family costs no engine change.
162    ///
163    /// **Every element must be one of [`OPTION_FAMILIES`]** — these strings are
164    /// matched against the pack's own option keys, so a spelling only the engine
165    /// knows silences the check rather than failing it.
166    pub declared_options: Vec<String>,
167    /// Every variable the entry *supplies to itself*, in first-seen order — the
168    /// engine equivalent of a `bind:`, written into the fragment file.
169    ///
170    /// Kept apart from [`Self::declared_options`] because the two clash on
171    /// different keys: an option family is a closed vocabulary compared
172    /// family-to-family, while a supplied variable is an open set compared
173    /// *name to name* — `token` clashes with a `bind:` of `token` and with
174    /// nothing else. Folding them together would make [`OPTION_FAMILIES`]'
175    /// "every element is one of these" invariant unstatable.
176    ///
177    /// Both halves of this field are load-bearing. A name here **satisfies** a
178    /// placeholder of the same name (the fragment answers its own question, so
179    /// the file still runs standalone under the engine's own binary — ADR-0018's
180    /// premise), and it **collides** with a `bind:` of that name
181    /// (`proef::pack::option_declared_twice`), because the engine may resolve
182    /// the pair silently rather than refusing it.
183    pub supplied_variables: Vec<String>,
184}
185
186/// The option families a pack step and a fragment can *both* declare, spelled as
187/// the pack spells them.
188///
189/// This is the vocabulary [`ScannedFragment::declared_options`] must use: the
190/// double-declaration rule works by string equality against the keys a step
191/// writes in YAML, so an engine reporting hurl's own spelling (`retry-interval`,
192/// say) would match nothing and the clash would go quiet — which is exactly the
193/// silent last-wins `proef::pack::option_declared_twice` exists to refuse.
194/// Engines fold their spellings into these (hurl's `retry-interval` is `retry`:
195/// one policy, and a step's `retry:` sets both).
196///
197/// Kept in step with `MacroStep::declared_options`, which derives the other half
198/// of the same comparison.
199pub const OPTION_FAMILIES: &[&str] = &["retry", "delay"];
200
201/// A fragment file the claiming engine's parser could not read (1-based
202/// line/column **within that file**).
203///
204/// Distinct from [`PayloadProbeError`] despite the same shape: that one is
205/// positioned inside a pack's payload block and gets mapped onto the pack
206/// file, this one already points at a real file of its own.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct FragmentScanError {
209    /// 1-based line within the fragment file.
210    pub line: usize,
211    /// 1-based column within that line.
212    pub column: usize,
213    /// Parser message.
214    pub message: String,
215}
216
217/// A syntax problem found while probe-validating a step payload
218/// (1-based line/column **within the payload text**; the pack loader maps it
219/// onto the pack file).
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct PayloadProbeError {
222    /// 1-based line within the payload text.
223    pub line: usize,
224    /// 1-based column within that line.
225    pub column: usize,
226    /// Parser message.
227    pub message: String,
228}
229
230/// Outcome of one environment check contributed by an engine.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct DoctorResult {
233    /// Pass / warn / fail.
234    pub status: DoctorStatus,
235    /// Human-readable detail (library version, remediation hint, …).
236    pub detail: String,
237}
238
239impl DoctorResult {
240    /// A passing check.
241    pub fn pass(detail: impl Into<String>) -> Self {
242        Self {
243            status: DoctorStatus::Pass,
244            detail: detail.into(),
245        }
246    }
247
248    /// A concerning-but-not-fatal check.
249    pub fn warn(detail: impl Into<String>) -> Self {
250        Self {
251            status: DoctorStatus::Warn,
252            detail: detail.into(),
253        }
254    }
255
256    /// A failing check (the engine cannot run).
257    pub fn fail(detail: impl Into<String>) -> Self {
258        Self {
259            status: DoctorStatus::Fail,
260            detail: detail.into(),
261        }
262    }
263}
264
265/// Severity of a [`DoctorResult`].
266#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
267pub enum DoctorStatus {
268    /// The prerequisite is satisfied.
269    Pass,
270    /// Usable, but attention is advised.
271    Warn,
272    /// The engine cannot run in this environment.
273    Fail,
274}
275
276/// One named environment check (native libraries, tool availability, …) surfaced
277/// through `proef doctor` (ADR-0002 capability hook).
278pub struct DoctorCheck {
279    /// Short human-readable check name.
280    pub name: &'static str,
281    /// The check itself; must be cheap and side-effect free.
282    pub run: fn() -> DoctorResult,
283}
284
285impl std::fmt::Debug for DoctorCheck {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("DoctorCheck")
288            .field("name", &self.name)
289            .finish_non_exhaustive()
290    }
291}
292
293/// Per-scenario context handed to [`EngineFactory::open`]. Fields grow additively
294/// as milestones land (artifact dirs, config, …).
295#[derive(Debug, Clone)]
296pub struct ScenarioCtx {
297    /// Injected run identifier.
298    pub run_id: Arc<str>,
299    /// Scenario name as authored.
300    pub scenario: Arc<str>,
301    /// The scenario's emitted artifact — for the hurl engine this *is* the
302    /// executed input (ADR-0010: same bytes as the parse-validated emission).
303    pub artifact: Option<ArtifactRef>,
304    /// Secret name → value pairs referenced by this scenario. Engines inject
305    /// them via their redacting mechanisms (`insert_secret`); values never
306    /// enter events or artifacts (ADR-0005).
307    pub secrets: Arc<std::collections::BTreeMap<String, String>>,
308    /// Engine variable name → secret name for this scenario. Do not join this
309    /// against `secrets` by hand — call [`secret_variables`], the one place
310    /// that knows how (ADR-0018).
311    pub secret_bindings: Arc<std::collections::BTreeMap<String, String>>,
312    /// Engine option defaults from project config (`timeout-ms`, …).
313    pub http: HttpDefaults,
314    /// Root directory for file bodies (`context_dir` confinement, §13) —
315    /// the feature file's directory.
316    pub file_root: Option<std::path::PathBuf>,
317}
318
319/// Every engine variable a scenario must inject as a secret, paired with its
320/// value — [`ScenarioCtx::secret_bindings`] joined against
321/// [`ScenarioCtx::secrets`]. **The one place that join is written.**
322///
323/// It lives in core, not in each engine, because it is easy to get subtly
324/// wrong: inject under the *secret* name rather than the *variable* name and a
325/// renamed binding (ADR-0018) resolves to nothing, so the request goes out with
326/// an unresolved `{{…}}` and fails far from the cause.
327///
328/// It yields borrows on purpose. Returning an owned variable→value map would put
329/// a second copy of every secret value in memory for each scenario; ADR-0005
330/// keeps values in exactly one place, the run-level `secrets` map.
331///
332/// A binding whose secret is absent is skipped — the CLI already refuses a run
333/// whose secrets it cannot resolve, so that is defence in depth, not a path.
334pub fn secret_variables<'a>(
335    bindings: &'a std::collections::BTreeMap<String, String>,
336    secrets: &'a std::collections::BTreeMap<String, String>,
337) -> impl Iterator<Item = (&'a str, &'a str)> {
338    bindings.iter().filter_map(|(variable, secret)| {
339        secrets
340            .get(secret)
341            .map(|value| (variable.as_str(), value.as_str()))
342    })
343}
344
345/// Batch-level HTTP defaults (per-entry `[Options]` in artifacts override them
346/// — clone-then-override, verified TECH-SPEC §5).
347#[derive(Debug, Clone, Copy)]
348pub struct HttpDefaults {
349    /// Per-request timeout in milliseconds (clamped default — ADR-0007).
350    pub timeout_ms: u64,
351    /// Follow redirects.
352    pub follow_location: bool,
353}
354
355impl Default for HttpDefaults {
356    fn default() -> Self {
357        Self {
358            timeout_ms: 30_000,
359            follow_location: false,
360        }
361    }
362}
363
364/// A scenario's emitted artifact, shared with the engine that executes it.
365#[derive(Debug, Clone)]
366pub struct ArtifactRef {
367    /// The artifact slug (`<slug>.hurl` — failure messages point here).
368    pub slug: Arc<str>,
369    /// The canonical `.hurl` text (the executed input).
370    pub text: Arc<str>,
371    /// The sidecar map: entry line ranges ↔ feature anchors ↔ batch indices.
372    pub map: Arc<crate::emit::SidecarMap>,
373}
374
375/// Compiled-in engine entry point: identity, capability discovery, and session
376/// opening (ADR-0002). Registered in `proef-cli`'s registry, one line per engine.
377pub trait EngineFactory: Send + Sync {
378    /// Stable engine id (`hurl`, …).
379    fn id(&self) -> &'static str;
380
381    /// The pack step kinds this engine claims, with their payload schemas.
382    fn step_kinds(&self) -> &'static [StepKindSpec];
383
384    /// Environment checks surfaced through `proef doctor`.
385    fn doctor(&self) -> Vec<DoctorCheck>;
386
387    /// Open a session for one scenario. Sessions are opened lazily on the first
388    /// batch routed to this engine and torn down via [`EngineSession::finish`].
389    fn open(&self, ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError>;
390}
391
392/// A live per-scenario engine session (ADR-0002). Only a session runs batches —
393/// lifecycle is enforced by this ownership shape, not typestate.
394pub trait EngineSession: Send {
395    /// Execute one batch of contiguous same-engine steps, threading captures
396    /// through `world` and emitting progress on `events`. Engines *may* honor
397    /// `cancel` at finer grain than batch boundaries when they can (ADR-0007).
398    fn run_batch(
399        &mut self,
400        batch: &StepBatch,
401        world: &mut World,
402        events: &EventSink,
403        cancel: &CancellationToken,
404    ) -> BatchResult;
405
406    /// The wall-clock budget for the *next* dispatch of `batch` (ADR-0007:
407    /// Σ(entry timeout × (retries + 1)) + intervals + margin). `None` when the
408    /// engine cannot estimate — the orchestrator falls back to its default.
409    /// The watchdog abandons the scenario thread when the budget expires.
410    fn batch_budget(&mut self, _batch: &StepBatch) -> Option<std::time::Duration> {
411        None
412    }
413
414    /// Tear the session down (reverse open order; `Drop` is the backstop).
415    fn finish(&mut self) -> Result<(), EngineError>;
416}