Skip to main content

lmn_core/config/
resolver.rs

1//! Scenario resolution: converts parsed `ScenarioConfig` entries into fully
2//! resolved `ResolvedScenario` structs ready for VU execution.
3//!
4//! The [`ScenarioResolver`] handles the three-layer header merge
5//! (global → scenario → step), `${ENV_VAR}` expansion, method parsing, and
6//! template loading — producing a `Vec<ResolvedScenario>` that executors
7//! consume directly.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use crate::capture::{CaptureDefinition, parse_json_path, scan_capture_refs};
13use crate::command::HttpMethod;
14use crate::config::ScenarioConfig;
15use crate::config::secret::{SensitiveString, resolve_env_placeholders};
16use crate::execution::{OnStepFailure, ResolvedScenario, ResolvedStep, build_request_config};
17use crate::request_template::Template;
18use crate::response_template::ResponseTemplate;
19use crate::response_template::field::TrackedField;
20
21// ── ScenarioResolver ─────────────────────────────────────────────────────────
22
23/// Resolves [`ScenarioConfig`] values into execution-ready [`ResolvedScenario`]
24/// structs.
25///
26/// Header merge order (case-insensitive last-wins):
27/// 1. **Global** — pre-resolved headers from `run.headers` / CLI `--header`.
28/// 2. **Scenario** — applied to every step in the scenario.
29/// 3. **Step** — applied to a single step only.
30///
31/// `${ENV_VAR}` placeholders in scenario and step headers are expanded during
32/// resolution. Global headers are assumed pre-resolved by the caller.
33pub struct ScenarioResolver<'a> {
34    global_headers: &'a [(String, SensitiveString)],
35}
36
37impl<'a> ScenarioResolver<'a> {
38    pub fn new(global_headers: &'a [(String, SensitiveString)]) -> Self {
39        Self { global_headers }
40    }
41
42    /// Resolve all scenarios from parsed config into execution-ready structs.
43    pub fn resolve(
44        &self,
45        configs: &[ScenarioConfig],
46    ) -> Result<Vec<ResolvedScenario>, Box<dyn std::error::Error>> {
47        configs
48            .iter()
49            .map(|cfg| self.resolve_scenario(cfg))
50            .collect()
51    }
52
53    fn resolve_scenario(
54        &self,
55        cfg: &ScenarioConfig,
56    ) -> Result<ResolvedScenario, Box<dyn std::error::Error>> {
57        let scenario_headers = self.merge_scenario_headers(cfg.headers.as_ref());
58
59        let steps = cfg
60            .steps
61            .iter()
62            .map(|step| self.resolve_step(step, &cfg.name, &scenario_headers))
63            .collect::<Result<Vec<_>, _>>()?;
64
65        // Static capture dependency validation: ensure every {{capture.KEY}}
66        // reference in a step is defined by a preceding step's captures.
67        let mut defined_aliases = HashSet::new();
68        for (i, step_cfg) in cfg.steps.iter().enumerate() {
69            // Collect references from headers and body/inline_body
70            let mut refs = Vec::new();
71            if let Some(ref headers) = step_cfg.headers {
72                for (name, value) in headers {
73                    refs.extend(scan_capture_refs(value).map_err(|e| {
74                        format!(
75                            "scenario '{}', step '{}': header '{name}': {e}",
76                            cfg.name, step_cfg.name
77                        )
78                    })?);
79                }
80            }
81            if let Some(ref body) = step_cfg.body {
82                refs.extend(scan_capture_refs(body).map_err(|e| {
83                    format!(
84                        "scenario '{}', step '{}': body: {e}",
85                        cfg.name, step_cfg.name
86                    )
87                })?);
88            }
89
90            for key in &refs {
91                if !defined_aliases.contains(key.as_str()) {
92                    return Err(format!(
93                        "scenario '{}', step '{}' (index {}): references \
94                         {{{{capture.{key}}}}} but no preceding step defines it",
95                        cfg.name, step_cfg.name, i
96                    )
97                    .into());
98                }
99            }
100
101            // Add this step's capture aliases to the defined set
102            if let Some(ref captures) = step_cfg.capture {
103                for alias in captures.keys() {
104                    defined_aliases.insert(alias.clone());
105                }
106            }
107        }
108
109        Ok(ResolvedScenario {
110            name: Arc::from(cfg.name.as_str()),
111            weight: cfg.weight.unwrap_or(1),
112            on_step_failure: parse_on_step_failure(cfg.on_step_failure.as_deref(), &cfg.name)?,
113            steps,
114        })
115    }
116
117    fn resolve_step(
118        &self,
119        step: &crate::config::ScenarioStepConfig,
120        scenario_name: &str,
121        scenario_headers: &[(String, String)],
122    ) -> Result<ResolvedStep, Box<dyn std::error::Error>> {
123        let ctx = StepContext {
124            scenario: scenario_name,
125            step: &step.name,
126        };
127
128        let merged_headers = merge_step_headers(scenario_headers, step.headers.as_ref());
129        let resolved_headers = resolve_header_env_vars(&merged_headers, &ctx)?;
130        let plain_headers = to_plain_headers(&resolved_headers);
131
132        let host =
133            resolve_env_placeholders(&step.host).map_err(|e| ctx.error(format!("host: {e}")))?;
134
135        let method = parse_method(&step.method).map_err(|e| ctx.error(e))?;
136
137        let request_config = build_request_config(host, method, None, None, resolved_headers, 1)
138            .map_err(|e| ctx.error(format!("request config: {e}")))?;
139
140        let request_template = load_request_template(step.request_template.as_deref(), &ctx)?;
141        let response_template = load_response_template(step.response_template.as_deref(), &ctx)?;
142
143        // Parse capture definitions
144        let captures = if let Some(ref capture_map) = step.capture {
145            capture_map
146                .iter()
147                .map(|(alias, path)| {
148                    let parsed = parse_json_path(path)
149                        .map_err(|e| ctx.error(format!("capture '{alias}': {e}")))?;
150                    Ok(CaptureDefinition {
151                        alias: alias.clone(),
152                        path: parsed,
153                    })
154                })
155                .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?
156        } else {
157            vec![]
158        };
159
160        // Detect if any header value contains {{capture. references
161        let has_capture_headers = plain_headers.iter().any(|(_, v)| v.contains("{{capture."));
162
163        // Handle inline body
164        let inline_body = step.body.as_deref().map(Arc::from);
165
166        Ok(ResolvedStep {
167            name: Arc::from(step.name.as_str()),
168            request_config,
169            plain_headers,
170            request_template,
171            response_template,
172            captures,
173            inline_body,
174            has_capture_headers,
175        })
176    }
177
178    /// Merge global headers with scenario-level overrides.
179    fn merge_scenario_headers(
180        &self,
181        scenario_headers: Option<&HashMap<String, String>>,
182    ) -> Vec<(String, String)> {
183        let mut headers: Vec<(String, String)> = self
184            .global_headers
185            .iter()
186            .map(|(k, v)| (k.clone(), v.to_string()))
187            .collect();
188        if let Some(overrides) = scenario_headers {
189            merge_headers_into(&mut headers, overrides);
190        }
191        headers
192    }
193}
194
195// ── Public entry point (convenience wrapper) ─────────────────────────────────
196
197/// Convenience function wrapping [`ScenarioResolver`].
198pub fn resolve_scenarios(
199    scenario_configs: &[ScenarioConfig],
200    global_headers: &[(String, SensitiveString)],
201) -> Result<Vec<ResolvedScenario>, Box<dyn std::error::Error>> {
202    ScenarioResolver::new(global_headers).resolve(scenario_configs)
203}
204
205// ── StepContext ──────────────────────────────────────────────────────────────
206
207/// Provides consistent error context for a specific scenario + step.
208struct StepContext<'a> {
209    scenario: &'a str,
210    step: &'a str,
211}
212
213impl StepContext<'_> {
214    fn error(&self, detail: impl std::fmt::Display) -> Box<dyn std::error::Error> {
215        format!(
216            "scenario '{}', step '{}': {detail}",
217            self.scenario, self.step
218        )
219        .into()
220    }
221}
222
223// ── Header helpers ──────────────────────────────────────────────────────────
224
225fn merge_step_headers(
226    base: &[(String, String)],
227    step_headers: Option<&HashMap<String, String>>,
228) -> Vec<(String, String)> {
229    let mut merged = base.to_vec();
230    if let Some(overrides) = step_headers {
231        merge_headers_into(&mut merged, overrides);
232    }
233    merged
234}
235
236fn merge_headers_into(base: &mut Vec<(String, String)>, incoming: &HashMap<String, String>) {
237    for (name, value) in incoming {
238        base.retain(|(k, _)| !k.eq_ignore_ascii_case(name));
239        base.push((name.clone(), value.clone()));
240    }
241}
242
243fn resolve_header_env_vars(
244    headers: &[(String, String)],
245    ctx: &StepContext<'_>,
246) -> Result<Vec<(String, SensitiveString)>, Box<dyn std::error::Error>> {
247    headers
248        .iter()
249        .map(|(name, value)| {
250            let resolved = resolve_env_placeholders(value)
251                .map_err(|e| ctx.error(format!("header '{name}': {e}")))?;
252            Ok((name.clone(), SensitiveString::new(resolved)))
253        })
254        .collect()
255}
256
257fn to_plain_headers(headers: &[(String, SensitiveString)]) -> Arc<Vec<(String, String)>> {
258    Arc::new(
259        headers
260            .iter()
261            .map(|(k, v)| (k.clone(), v.to_string()))
262            .collect(),
263    )
264}
265
266// ── Parsing helpers ─────────────────────────────────────────────────────────
267
268fn parse_on_step_failure(
269    s: Option<&str>,
270    scenario_name: &str,
271) -> Result<OnStepFailure, Box<dyn std::error::Error>> {
272    match s {
273        Some("abort_iteration") => Ok(OnStepFailure::AbortIteration),
274        Some("continue") | None => Ok(OnStepFailure::Continue),
275        Some(other) => Err(format!(
276            "scenario '{scenario_name}': invalid on_step_failure value '{other}'"
277        )
278        .into()),
279    }
280}
281
282fn parse_method(s: &str) -> Result<HttpMethod, String> {
283    match s.to_lowercase().as_str() {
284        "get" => Ok(HttpMethod::Get),
285        "post" => Ok(HttpMethod::Post),
286        "put" => Ok(HttpMethod::Put),
287        "patch" => Ok(HttpMethod::Patch),
288        "delete" => Ok(HttpMethod::Delete),
289        other => Err(format!(
290            "unknown method '{other}' — expected one of: get, post, put, patch, delete"
291        )),
292    }
293}
294
295// ── Template helpers ────────────────────────────────────────────────────────
296
297fn load_request_template(
298    path: Option<&str>,
299    ctx: &StepContext<'_>,
300) -> Result<Option<Arc<Template>>, Box<dyn std::error::Error>> {
301    path.map(|p| {
302        Template::parse(p.as_ref())
303            .map(Arc::new)
304            .map_err(|e| ctx.error(format!("request_template '{p}': {e}")))
305    })
306    .transpose()
307}
308
309fn load_response_template(
310    path: Option<&str>,
311    ctx: &StepContext<'_>,
312) -> Result<Option<Arc<Vec<TrackedField>>>, Box<dyn std::error::Error>> {
313    path.map(|p| {
314        ResponseTemplate::parse(p.as_ref())
315            .map(|rt| Arc::new(rt.fields))
316            .map_err(|e| ctx.error(format!("response_template '{p}': {e}")))
317    })
318    .transpose()
319}
320
321// ── Tests ─────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::config::ScenarioStepConfig;
327
328    fn step(
329        name: &str,
330        body: Option<&str>,
331        capture: Option<Vec<(&str, &str)>>,
332    ) -> ScenarioStepConfig {
333        ScenarioStepConfig {
334            name: name.to_string(),
335            host: "http://localhost".to_string(),
336            method: "get".to_string(),
337            headers: None,
338            request_template: None,
339            response_template: None,
340            body: body.map(|s| s.to_string()),
341            capture: capture.map(|pairs| {
342                pairs
343                    .into_iter()
344                    .map(|(k, v)| (k.to_string(), v.to_string()))
345                    .collect()
346            }),
347        }
348    }
349
350    fn scenario(steps: Vec<ScenarioStepConfig>) -> ScenarioConfig {
351        ScenarioConfig {
352            name: "test".to_string(),
353            weight: None,
354            on_step_failure: None,
355            headers: None,
356            steps,
357        }
358    }
359
360    fn resolve(cfg: &ScenarioConfig) -> Result<ResolvedScenario, Box<dyn std::error::Error>> {
361        let resolver = ScenarioResolver::new(&[]);
362        resolver.resolve_scenario(cfg)
363    }
364
365    #[test]
366    fn capture_ref_satisfied_by_preceding_step() {
367        let cfg = scenario(vec![
368            step("login", None, Some(vec![("token", "$.data.token")])),
369            step("use", Some(r#"{"t": "{{capture.token}}"}"#), None),
370        ]);
371        assert!(resolve(&cfg).is_ok());
372    }
373
374    #[test]
375    fn capture_ref_undefined_key_is_error() {
376        let cfg = scenario(vec![
377            step("login", None, None),
378            step("use", Some(r#"{"t": "{{capture.token}}"}"#), None),
379        ]);
380        let err = resolve(&cfg).err().expect("expected error").to_string();
381        assert!(
382            err.contains("capture.token"),
383            "error should mention the key: {err}"
384        );
385        assert!(
386            err.contains("no preceding step"),
387            "error should explain cause: {err}"
388        );
389    }
390
391    #[test]
392    fn capture_ref_in_same_step_is_error() {
393        // A step cannot reference its own captures — they haven't been extracted yet.
394        let cfg = scenario(vec![step(
395            "self_ref",
396            Some(r#"{"t": "{{capture.token}}"}"#),
397            Some(vec![("token", "$.data.token")]),
398        )]);
399        let err = resolve(&cfg).err().expect("expected error").to_string();
400        assert!(err.contains("capture.token"), "{err}");
401    }
402
403    #[test]
404    fn capture_ref_in_header_validated() {
405        let mut s = step("use", None, None);
406        s.headers = Some(
407            [(
408                "Authorization".to_string(),
409                "Bearer {{capture.token}}".to_string(),
410            )]
411            .into_iter()
412            .collect(),
413        );
414        let cfg = scenario(vec![s]);
415        let err = resolve(&cfg).err().expect("expected error").to_string();
416        assert!(err.contains("capture.token"), "{err}");
417    }
418
419    #[test]
420    fn multiple_captures_chain_across_steps() {
421        let cfg = scenario(vec![
422            step("s1", None, Some(vec![("a", "$.a")])),
423            step("s2", Some("{{capture.a}}"), Some(vec![("b", "$.b")])),
424            step("s3", Some("{{capture.a}} {{capture.b}}"), None),
425        ]);
426        assert!(resolve(&cfg).is_ok());
427    }
428
429    #[test]
430    fn no_captures_no_refs_is_ok() {
431        let cfg = scenario(vec![
432            step("s1", None, None),
433            step("s2", Some("plain body"), None),
434        ]);
435        assert!(resolve(&cfg).is_ok());
436    }
437}