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//!         }];
34//!         KINDS
35//!     }
36//!     fn doctor(&self) -> Vec<DoctorCheck> {
37//!         Vec::new()
38//!     }
39//!     fn open(&self, _ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError> {
40//!         Ok(Box::new(NullSession))
41//!     }
42//! }
43//!
44//! impl EngineSession for NullSession {
45//!     fn run_batch(
46//!         &mut self,
47//!         batch: &StepBatch,
48//!         _world: &mut World,
49//!         _events: &EventSink,
50//!         _cancel: &CancellationToken,
51//!     ) -> BatchResult {
52//!         BatchResult { steps: Vec::with_capacity(batch.steps.len()), error: None }
53//!     }
54//!     fn finish(&mut self) -> Result<(), EngineError> {
55//!         Ok(())
56//!     }
57//! }
58//!
59//! let factory: Box<dyn EngineFactory> = Box::new(NullEngine);
60//! assert_eq!(factory.id(), "null");
61//! ```
62
63use std::sync::Arc;
64
65use crate::cancel::CancellationToken;
66use crate::error::EngineError;
67use crate::event::EventSink;
68use crate::step::{BatchResult, StepBatch};
69use crate::world::World;
70
71/// Identifies an engine (`hurl`, …). A macro step's kind names the
72/// engine that executes it (ADR-0002 routing).
73#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct EngineId(Arc<str>);
75
76impl EngineId {
77    /// The engine id as referenced by step kinds and the registry.
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl From<&str> for EngineId {
84    fn from(s: &str) -> Self {
85        Self(Arc::from(s))
86    }
87}
88
89impl std::fmt::Display for EngineId {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(&self.0)
92    }
93}
94
95/// An engine's claim on a pack step kind: the key prefix (`hurl`; other
96/// prefixes reserved — ADR-0002 errata) plus the
97/// JSON-Schema fragment describing that step's payload, merged into `proef schema`
98/// output (TECH-SPEC §6), and an optional static payload validator used by pack
99/// validation pass 7 (probe-instantiation parse — TECH-SPEC §4.1).
100#[derive(Debug, Clone, Copy)]
101pub struct StepKindSpec {
102    /// Step-kind prefix as written in packs (without the trailing `:`).
103    pub prefix: &'static str,
104    /// JSON-Schema fragment for the step payload (`"true"` = any, until refined).
105    pub schema: &'static str,
106    /// Probe-validate a lowered payload text (`None` = no static validation).
107    /// Keeps the core engine-agnostic: the hurl parser stays behind the seam.
108    pub validate: Option<PayloadValidator>,
109}
110
111/// An engine-contributed static payload validator (pack validation pass 7).
112pub type PayloadValidator = fn(&str) -> Result<(), PayloadProbeError>;
113
114/// A syntax problem found while probe-validating a step payload
115/// (1-based line/column **within the payload text**; the pack loader maps it
116/// onto the pack file).
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct PayloadProbeError {
119    /// 1-based line within the payload text.
120    pub line: usize,
121    /// 1-based column within that line.
122    pub column: usize,
123    /// Parser message.
124    pub message: String,
125}
126
127/// Outcome of one environment check contributed by an engine.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct DoctorResult {
130    /// Pass / warn / fail.
131    pub status: DoctorStatus,
132    /// Human-readable detail (library version, remediation hint, …).
133    pub detail: String,
134}
135
136impl DoctorResult {
137    /// A passing check.
138    pub fn pass(detail: impl Into<String>) -> Self {
139        Self {
140            status: DoctorStatus::Pass,
141            detail: detail.into(),
142        }
143    }
144
145    /// A concerning-but-not-fatal check.
146    pub fn warn(detail: impl Into<String>) -> Self {
147        Self {
148            status: DoctorStatus::Warn,
149            detail: detail.into(),
150        }
151    }
152
153    /// A failing check (the engine cannot run).
154    pub fn fail(detail: impl Into<String>) -> Self {
155        Self {
156            status: DoctorStatus::Fail,
157            detail: detail.into(),
158        }
159    }
160}
161
162/// Severity of a [`DoctorResult`].
163#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
164pub enum DoctorStatus {
165    /// The prerequisite is satisfied.
166    Pass,
167    /// Usable, but attention is advised.
168    Warn,
169    /// The engine cannot run in this environment.
170    Fail,
171}
172
173/// One named environment check (native libraries, tool availability, …) surfaced
174/// through `proef doctor` (ADR-0002 capability hook).
175pub struct DoctorCheck {
176    /// Short human-readable check name.
177    pub name: &'static str,
178    /// The check itself; must be cheap and side-effect free.
179    pub run: fn() -> DoctorResult,
180}
181
182impl std::fmt::Debug for DoctorCheck {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("DoctorCheck")
185            .field("name", &self.name)
186            .finish_non_exhaustive()
187    }
188}
189
190/// Per-scenario context handed to [`EngineFactory::open`]. Fields grow additively
191/// as milestones land (artifact dirs, config, …).
192#[derive(Debug, Clone)]
193pub struct ScenarioCtx {
194    /// Injected run identifier.
195    pub run_id: Arc<str>,
196    /// Scenario name as authored.
197    pub scenario: Arc<str>,
198    /// The scenario's emitted artifact — for the hurl engine this *is* the
199    /// executed input (ADR-0010: same bytes as the parse-validated emission).
200    pub artifact: Option<ArtifactRef>,
201    /// Secret name → value pairs referenced by this scenario. Engines inject
202    /// them via their redacting mechanisms (`insert_secret`); values never
203    /// enter events or artifacts (ADR-0005).
204    pub secrets: Arc<std::collections::BTreeMap<String, String>>,
205    /// Engine option defaults from project config (`timeout-ms`, …).
206    pub http: HttpDefaults,
207    /// Root directory for file bodies (`context_dir` confinement, §13) —
208    /// the feature file's directory.
209    pub file_root: Option<std::path::PathBuf>,
210}
211
212/// Batch-level HTTP defaults (per-entry `[Options]` in artifacts override them
213/// — clone-then-override, verified TECH-SPEC §5).
214#[derive(Debug, Clone, Copy)]
215pub struct HttpDefaults {
216    /// Per-request timeout in milliseconds (clamped default — ADR-0007).
217    pub timeout_ms: u64,
218    /// Follow redirects.
219    pub follow_location: bool,
220}
221
222impl Default for HttpDefaults {
223    fn default() -> Self {
224        Self {
225            timeout_ms: 30_000,
226            follow_location: false,
227        }
228    }
229}
230
231/// A scenario's emitted artifact, shared with the engine that executes it.
232#[derive(Debug, Clone)]
233pub struct ArtifactRef {
234    /// The artifact slug (`<slug>.hurl` — failure messages point here).
235    pub slug: Arc<str>,
236    /// The canonical `.hurl` text (the executed input).
237    pub text: Arc<str>,
238    /// The sidecar map: entry line ranges ↔ feature anchors ↔ batch indices.
239    pub map: Arc<crate::emit::SidecarMap>,
240}
241
242/// Compiled-in engine entry point: identity, capability discovery, and session
243/// opening (ADR-0002). Registered in `proef-cli`'s registry, one line per engine.
244pub trait EngineFactory: Send + Sync {
245    /// Stable engine id (`hurl`, …).
246    fn id(&self) -> &'static str;
247
248    /// The pack step kinds this engine claims, with their payload schemas.
249    fn step_kinds(&self) -> &'static [StepKindSpec];
250
251    /// Environment checks surfaced through `proef doctor`.
252    fn doctor(&self) -> Vec<DoctorCheck>;
253
254    /// Open a session for one scenario. Sessions are opened lazily on the first
255    /// batch routed to this engine and torn down via [`EngineSession::finish`].
256    fn open(&self, ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError>;
257}
258
259/// A live per-scenario engine session (ADR-0002). Only a session runs batches —
260/// lifecycle is enforced by this ownership shape, not typestate.
261pub trait EngineSession: Send {
262    /// Execute one batch of contiguous same-engine steps, threading captures
263    /// through `world` and emitting progress on `events`. Engines *may* honor
264    /// `cancel` at finer grain than batch boundaries when they can (ADR-0007).
265    fn run_batch(
266        &mut self,
267        batch: &StepBatch,
268        world: &mut World,
269        events: &EventSink,
270        cancel: &CancellationToken,
271    ) -> BatchResult;
272
273    /// The wall-clock budget for the *next* dispatch of `batch` (ADR-0007:
274    /// Σ(entry timeout × (retries + 1)) + intervals + margin). `None` when the
275    /// engine cannot estimate — the orchestrator falls back to its default.
276    /// The watchdog abandons the scenario thread when the budget expires.
277    fn batch_budget(&mut self, _batch: &StepBatch) -> Option<std::time::Duration> {
278        None
279    }
280
281    /// Tear the session down (reverse open order; `Drop` is the backstop).
282    fn finish(&mut self) -> Result<(), EngineError>;
283}