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