Skip to main content

proef_engine_hurl/
lib.rs

1//! The proef API engine: [`EngineFactory`]/[`EngineSession`] over **embedded hurl**
2//! (ADR-0001).
3//!
4//! hurl's crates are pinned exactly (`=8.0.1`) and built `--locked` — the crates
5//! break API in minor releases; upgrades go only through the canary + runbook
6//! (ADR-0003, IMPLEMENTATION-PLAN §7).
7//!
8//! The engine claims the `hurl` step kind, contributes doctor checks, and
9//! executes scenario artifacts via the embedded `run_entries` (see the
10//! `session` module source for the adapter mechanics).
11
12mod fragment;
13mod session;
14
15use proef_core::engine::{
16    DoctorCheck, DoctorResult, EngineFactory, EngineSession, FragmentSupport, PayloadProbeError,
17    RawOption, RawOptionValue, ScenarioCtx, StepKindSpec,
18};
19use proef_core::error::EngineError;
20
21/// The exact embedded hurl release (kept in lockstep with the Cargo pin —
22/// asserted by `embedded_version_matches_the_cargo_pin`).
23pub const EMBEDDED_HURL_VERSION: &str = "8.0.1";
24
25/// A parseable single-entry probe file used by doctor checks and smoke tests.
26const PROBE_HURL: &str = "GET http://localhost/health\nHTTP 200\n";
27
28/// Step kinds claimed by this engine: the `hurl:` raw block (ADR-0004,
29/// TECH-SPEC §6 — the pack key doubles as the routing kind). The validate hook
30/// is pack validation pass 7's probe parser; the fragment hooks are ADR-0018's
31/// `.hurl` scanner, which reads named entries out of real hurl files.
32const STEP_KINDS: &[StepKindSpec] = &[StepKindSpec {
33    prefix: "hurl",
34    schema: r#"{ "type": "string", "description": "Raw hurl entries; ${…} lowered at author time, {{…}} resolved by hurl at run time" }"#,
35    validate: Some(validate_payload),
36    fragments: Some(FragmentSupport {
37        ext: "hurl",
38        scan: fragment::scan,
39    }),
40    options: Some(recognise_option),
41}];
42
43/// hurl's `[Options]` keys, as the core's budget rules see them (ADR-0007).
44///
45/// The one place these spellings live. `retry-interval` folds into the `retry`
46/// family — they are one policy, and a step's `retry:` sets both — which is the
47/// same mapping [`fragment::scan`] makes from `OptionKind::RetryInterval`, now
48/// written once instead of once per body form.
49fn recognise_option(key: &str) -> Option<RawOption> {
50    let (family, value) = match key {
51        "retry" => (Some("retry"), Some(RawOptionValue::Count)),
52        // No YAML twin, so it cannot be declared twice — but an infinite
53        // `repeat` is exactly as unbounded as an infinite `retry`.
54        "repeat" => (None, Some(RawOptionValue::Count)),
55        "delay" => (Some("delay"), Some(RawOptionValue::Duration)),
56        // Part of the retry policy for double-declaration purposes; its own
57        // value carries no separate cap.
58        "retry-interval" => (Some("retry"), None),
59        _ => return None,
60    };
61    Some(RawOption { family, value })
62}
63
64/// The compiled-in hurl engine, registered by `proef-cli` (ADR-0002).
65pub struct HurlEngineFactory;
66
67impl EngineFactory for HurlEngineFactory {
68    fn id(&self) -> &'static str {
69        "hurl"
70    }
71
72    fn step_kinds(&self) -> &'static [StepKindSpec] {
73        STEP_KINDS
74    }
75
76    fn doctor(&self) -> Vec<DoctorCheck> {
77        vec![
78            DoctorCheck {
79                name: "embedded hurl",
80                run: check_embedded_version,
81            },
82            DoctorCheck {
83                name: "hurl parser",
84                run: check_parser,
85            },
86            DoctorCheck {
87                name: "libcurl",
88                run: check_libcurl,
89            },
90        ]
91    }
92
93    fn open(&self, ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError> {
94        Ok(Box::new(session::HurlSession::open(ctx)?))
95    }
96}
97
98/// Report the pinned hurl release compiled into this binary.
99fn check_embedded_version() -> DoctorResult {
100    DoctorResult::pass(format!(
101        "hurl {EMBEDDED_HURL_VERSION} (exact pin, ADR-0003)"
102    ))
103}
104
105/// Exercise `hurl_core`'s parser on a probe file — proves the parser linkage that
106/// pack loading (M1) and artifact validation (M2) rely on. Because `hurl_core`
107/// links libxml2, a running probe also proves the libxml2 dynamic linkage loads.
108fn check_parser() -> DoctorResult {
109    match hurl_core::parser::parse_hurl_file(PROBE_HURL) {
110        Ok(file) if file.entries.len() == 1 => DoctorResult::pass(
111            "parsed 1-entry probe file (hurl_core + libxml2 linkage loads)".to_owned(),
112        ),
113        Ok(file) => DoctorResult::warn(format!(
114            "probe parsed with unexpected entry count {}",
115            file.entries.len()
116        )),
117        Err(err) => DoctorResult::fail(format!("cannot parse probe file: {err:?}")),
118    }
119}
120
121/// Probe-parse a lowered payload with hurl's real parser (pack validation
122/// pass 7 — the seam hook on [`StepKindSpec`]).
123fn validate_payload(text: &str) -> Result<(), PayloadProbeError> {
124    let mut normalized = text.to_owned();
125    if !normalized.ends_with('\n') {
126        normalized.push('\n');
127    }
128    match hurl_core::parser::parse_hurl_file(&normalized) {
129        // A parseable payload with zero entries (only comments or blank
130        // lines) would execute nothing while the step reports green — reject
131        // it at load so dry-run and execution agree.
132        Ok(file) if file.entries.is_empty() => Err(PayloadProbeError {
133            line: 1,
134            column: 1,
135            message: "contains no hurl entries (only comments or blank lines)".to_owned(),
136        }),
137        Ok(_) => Ok(()),
138        Err(err) => Err(PayloadProbeError {
139            line: err.pos.line,
140            column: err.pos.column,
141            message: format!("{:?}", err.kind),
142        }),
143    }
144}
145
146/// Report the libcurl this binary is linked against (mirrors `curl --version`) —
147/// the library `run_entries` drives during execution.
148fn check_libcurl() -> DoctorResult {
149    let info = hurl::http::libcurl_version_info();
150    if info.libraries.is_empty() {
151        return DoctorResult::warn("libcurl loaded but reported no libraries".to_owned());
152    }
153    DoctorResult::pass(format!("{} (host {})", info.libraries.join(" "), info.host))
154}
155
156#[cfg(test)]
157mod tests {
158    #![allow(clippy::unwrap_used)]
159
160    use super::*;
161
162    /// The seam facts this engine relies on are pinned (ADR-0001): parseable
163    /// probe, one entry, exact crate version.
164    #[test]
165    fn parser_seam_smoke() {
166        let result = check_parser();
167        assert_eq!(
168            result.status,
169            proef_core::engine::DoctorStatus::Pass,
170            "{}",
171            result.detail
172        );
173    }
174
175    #[test]
176    fn factory_claims_the_hurl_step_kind() {
177        let factory = HurlEngineFactory;
178        assert_eq!(factory.id(), "hurl");
179        assert_eq!(factory.step_kinds().len(), 1);
180        assert_eq!(factory.step_kinds()[0].prefix, "hurl");
181    }
182
183    /// The doc on [`EMBEDDED_HURL_VERSION`] promises lockstep with the Cargo
184    /// pin — this is that assertion: a runbook pin bump that forgets the
185    /// const (or vice versa) fails here, not at doctor time mid-upgrade.
186    #[test]
187    fn embedded_version_matches_the_cargo_pin() {
188        let manifest = std::fs::read_to_string(
189            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.toml"),
190        )
191        .unwrap();
192        for dep in ["hurl", "hurl_core"] {
193            let needle = format!("{dep} = \"={EMBEDDED_HURL_VERSION}\"");
194            assert!(
195                manifest.contains(&needle),
196                "workspace Cargo.toml must contain `{needle}` (ADR-0003 lockstep)"
197            );
198        }
199    }
200
201    #[test]
202    fn payload_probe_accepts_valid_and_rejects_broken_hurl() {
203        assert!(validate_payload("GET http://x/one\nHTTP 200").is_ok());
204        // An unknown response section name is unambiguously invalid hurl.
205        let err = validate_payload("GET http://x/one\nHTTP 200\n[Wrong]\n").unwrap_err();
206        assert!(
207            err.line >= 2,
208            "position should be near the broken section: {err:?}"
209        );
210    }
211}