1use 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
21pub 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 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 let mut defined_aliases = HashSet::new();
68 for (i, step_cfg) in cfg.steps.iter().enumerate() {
69 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 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 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 let has_capture_headers = plain_headers.iter().any(|(_, v)| v.contains("{{capture."));
162
163 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 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
195pub 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
205struct 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
223fn 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
266fn 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
295fn 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#[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 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}