Skip to main content

mockforge_bench/conformance/
executor.rs

1//! Native Rust conformance test executor
2//!
3//! Replaces the k6-based execution path with direct HTTP requests via reqwest,
4//! producing the same `ConformanceReport` output. This removes the k6 binary
5//! dependency and enables API/SDK integration.
6
7use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18/// A field-level schema validation violation
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21    /// JSON path to the field that failed validation (e.g., "/name", "/items/0/age")
22    pub field_path: String,
23    /// Type of violation (e.g., "type", "required", "additionalProperties")
24    pub violation_type: String,
25    /// What the schema expected
26    pub expected: String,
27    /// What was actually found
28    pub actual: String,
29}
30
31/// A single conformance check to execute
32#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34    /// Check name (e.g., "param:path:string" or "param:path:string:/users/{id}")
35    pub name: String,
36    /// HTTP method
37    pub method: Method,
38    /// Relative path (appended to base_url)
39    pub path: String,
40    /// Request headers
41    pub headers: Vec<(String, String)>,
42    /// Optional request body
43    pub body: Option<CheckBody>,
44    /// How to validate the response
45    pub validation: CheckValidation,
46}
47
48/// Request body variants
49#[derive(Debug, Clone)]
50pub enum CheckBody {
51    /// JSON body
52    Json(serde_json::Value),
53    /// Form-urlencoded body
54    FormUrlencoded(Vec<(String, String)>),
55    /// Raw string body with content type
56    Raw {
57        content: String,
58        content_type: String,
59    },
60    /// Round 38 (#79) — multipart/form-data with file attachments.
61    /// Bytes are read from disk at request time so a YAML can name a
62    /// 50 MiB `.docx` without inflating the config. Use one entry per
63    /// part; the executor builds a single `reqwest::multipart::Form`
64    /// containing all of them so multi-file uploads land in one
65    /// request.
66    Multipart { parts: Vec<MultipartPart> },
67}
68
69/// Round 38 (#79) — one part of a multipart/form-data request body.
70#[derive(Debug, Clone)]
71pub struct MultipartPart {
72    /// Bytes that go on the wire as the part body.
73    pub bytes: Vec<u8>,
74    /// `Content-Type` for this part (e.g. `image/jpeg`,
75    /// `application/octet-stream`, `application/json`).
76    pub content_type: String,
77    /// Multipart form field name (the key on the receiving server).
78    pub field_name: String,
79    /// Filename announced in this part's `Content-Disposition` header.
80    pub filename: String,
81}
82
83/// Round 38 (#79) — chain metadata stored alongside a custom
84/// `ConformanceCheck`. Tells the executor how to substitute captured
85/// values into the request, how to capture values from the response,
86/// and how to repeat the check.
87#[derive(Debug, Clone, Default)]
88struct ChainMeta {
89    /// What to capture from the response on success. Empty when the
90    /// check declared no `extract` rules.
91    extract: super::custom::ExtractRules,
92    /// How many times to fire this check within one outer
93    /// `chain_iterations` pass, sequentially or in parallel.
94    repeat: super::custom::Repeat,
95}
96
97/// Round 38 (#79) — values captured during a chain run, keyed by the
98/// name the YAML asked for them under. `cookies` is kept separate so
99/// `${cookie:session}` and `${var:session}` substitute different
100/// things even if they happen to share a name.
101#[derive(Debug, Default, Clone)]
102struct ChainContext {
103    vars: HashMap<String, String>,
104    cookies: HashMap<String, String>,
105}
106
107impl ChainContext {
108    /// Substitute every `${var:NAME}`, `${cookie:NAME}`, and
109    /// `${header:NAME}` token in `text` with the corresponding
110    /// captured value. Unknown tokens are left in place verbatim so a
111    /// missing capture is obvious in the request log rather than
112    /// silently sending an empty string.
113    fn substitute(&self, text: &str) -> String {
114        let mut out = String::with_capacity(text.len());
115        let mut rest = text;
116        while let Some(start) = rest.find("${") {
117            out.push_str(&rest[..start]);
118            let after = &rest[start + 2..];
119            if let Some(end) = after.find('}') {
120                let token = &after[..end];
121                let replaced = if let Some(name) = token.strip_prefix("var:") {
122                    self.vars.get(name).cloned()
123                } else if let Some(name) = token.strip_prefix("cookie:") {
124                    self.cookies.get(name).cloned()
125                } else {
126                    // Round 38 — `${header:...}` aliases `${var:...}` for
127                    // captures recorded under the `headers` extraction
128                    // block. Same map, just a more readable token shape.
129                    token.strip_prefix("header:").and_then(|name| self.vars.get(name).cloned())
130                };
131                if let Some(value) = replaced {
132                    out.push_str(&value);
133                } else {
134                    // Preserve the original `${...}` so a missing
135                    // capture is visible in failure detail / logs.
136                    out.push_str("${");
137                    out.push_str(token);
138                    out.push('}');
139                }
140                rest = &after[end + 1..];
141            } else {
142                out.push_str("${");
143                rest = after;
144                break;
145            }
146        }
147        out.push_str(rest);
148        out
149    }
150}
151
152/// How to validate a conformance check response
153#[derive(Debug, Clone)]
154pub enum CheckValidation {
155    /// status >= min && status < max_exclusive
156    StatusRange { min: u16, max_exclusive: u16 },
157    /// status === code
158    ExactStatus(u16),
159    /// Schema validation: status in range + JSON body matches schema
160    SchemaValidation {
161        status_min: u16,
162        status_max: u16,
163        schema: serde_json::Value,
164    },
165    /// Custom: exact status + optional header regex + optional body field type checks
166    Custom {
167        expected_status: u16,
168        expected_headers: Vec<(String, String)>,
169        expected_body_fields: Vec<(String, String)>,
170    },
171}
172
173/// Progress event for SSE streaming
174#[derive(Debug, Clone, serde::Serialize)]
175#[serde(tag = "type")]
176pub enum ConformanceProgress {
177    /// Test run started
178    #[serde(rename = "started")]
179    Started { total_checks: usize },
180    /// A single check completed
181    #[serde(rename = "check_completed")]
182    CheckCompleted {
183        name: String,
184        passed: bool,
185        checks_done: usize,
186    },
187    /// All checks finished
188    #[serde(rename = "finished")]
189    Finished,
190    /// An error occurred
191    #[serde(rename = "error")]
192    Error { message: String },
193}
194
195/// Result of executing a single conformance check
196#[derive(Debug)]
197struct CheckResult {
198    name: String,
199    passed: bool,
200    failure_detail: Option<FailureDetail>,
201    /// Captured request/response data for --export-requests (always populated
202    /// when export_requests is enabled, regardless of pass/fail).
203    captured: Option<CapturedExchange>,
204}
205
206/// A captured HTTP exchange for the --export-requests feature
207#[derive(Debug, serde::Serialize)]
208struct CapturedExchange {
209    method: String,
210    url: String,
211    request_headers: HashMap<String, String>,
212    request_body: String,
213    response_status: u16,
214    response_headers: HashMap<String, String>,
215    response_body: String,
216}
217
218/// Native conformance executor using reqwest
219pub struct NativeConformanceExecutor {
220    config: ConformanceConfig,
221    client: Client,
222    checks: Vec<ConformanceCheck>,
223    /// Round 38 (#79) — per-check chain metadata. Keyed by the index
224    /// in `checks`. Entries are only present for custom checks that
225    /// declared a non-default `extract` block or `repeat` config in
226    /// the YAML.
227    chain_meta: HashMap<usize, ChainMeta>,
228    /// Round 38 (#79) — how many times to repeat the entire chain
229    /// of custom checks. Built-in spec checks always run once. Reset
230    /// to a fresh `ChainContext` at the start of each iteration so
231    /// captures from iteration K do not leak into K+1.
232    chain_iterations: u32,
233    /// Round 46 (#79) — Srikanth on 0.3.190 Q3: append-only sink for
234    /// network-level wire errors that fell short of an HTTP response
235    /// (connect refused, TLS reject, request timeout, etc). Drained
236    /// at the end of the run into `conformance-network-events.json`
237    /// so the user can see WHEN traffic stopped landing and WHY,
238    /// without having to grep through reqwest's debug logs.
239    network_events: std::sync::Mutex<Vec<NetworkEvent>>,
240}
241
242/// Round 46 (#79) — one wire-level error captured by the native
243/// executor (no HTTP response from the target). Persisted to
244/// `conformance-network-events.json` so the user has a grep-able log
245/// of when the target stopped responding and the classified reason.
246#[derive(Debug, Clone, serde::Serialize)]
247pub struct NetworkEvent {
248    /// When the failure was observed by the bench client.
249    pub timestamp: chrono::DateTime<chrono::Utc>,
250    /// Check name (e.g. `body:json:/v1/organizations` or `custom:get`).
251    pub check: String,
252    /// HTTP method we attempted.
253    pub method: String,
254    /// Full target URL (post-substitution) we tried to reach.
255    pub url: String,
256    /// Classified error kind: `connect` (TCP refused / no route),
257    /// `timeout`, `tls`, `body`, `decode`, `request`, `other`.
258    pub kind: String,
259    /// Verbatim error string from reqwest for context.
260    pub message: String,
261}
262
263impl NativeConformanceExecutor {
264    /// Create a new executor from a `ConformanceConfig`
265    pub fn new(config: ConformanceConfig) -> Result<Self> {
266        let mut builder = Client::builder()
267            .timeout(Duration::from_secs(30))
268            .connect_timeout(Duration::from_secs(10));
269
270        if config.skip_tls_verify {
271            builder = builder.danger_accept_invalid_certs(true);
272        }
273
274        let client = builder
275            .build()
276            .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
277
278        Ok(Self {
279            config,
280            client,
281            checks: Vec::new(),
282            chain_meta: HashMap::new(),
283            chain_iterations: 1,
284            network_events: std::sync::Mutex::new(Vec::new()),
285        })
286    }
287
288    /// Round 46 (#79) — push one wire-level failure into the
289    /// `conformance-network-events.json` sink. Best-effort; if the
290    /// mutex is poisoned the event is dropped silently rather than
291    /// crashing the run mid-bench.
292    fn record_network_event(&self, ev: NetworkEvent) {
293        if let Ok(mut guard) = self.network_events.lock() {
294            guard.push(ev);
295        }
296    }
297
298    /// Populate checks from hardcoded reference endpoints (`/conformance/*`).
299    /// Used when no `--spec` is provided.
300    #[must_use]
301    pub fn with_reference_checks(mut self) -> Self {
302        // --- Parameters ---
303        if self.config.should_include_category("Parameters") {
304            self.add_ref_get("param:path:string", "/conformance/params/hello");
305            self.add_ref_get("param:path:integer", "/conformance/params/42");
306            self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
307            self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
308            self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
309            self.checks.push(ConformanceCheck {
310                name: "param:header".to_string(),
311                method: Method::GET,
312                path: "/conformance/params/header".to_string(),
313                headers: self
314                    .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
315                body: None,
316                validation: CheckValidation::StatusRange {
317                    min: 200,
318                    max_exclusive: 500,
319                },
320            });
321            self.checks.push(ConformanceCheck {
322                name: "param:cookie".to_string(),
323                method: Method::GET,
324                path: "/conformance/params/cookie".to_string(),
325                headers: self
326                    .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
327                body: None,
328                validation: CheckValidation::StatusRange {
329                    min: 200,
330                    max_exclusive: 500,
331                },
332            });
333        }
334
335        // --- Request Bodies ---
336        if self.config.should_include_category("Request Bodies") {
337            self.checks.push(ConformanceCheck {
338                name: "body:json".to_string(),
339                method: Method::POST,
340                path: "/conformance/body/json".to_string(),
341                headers: self.merge_headers(vec![(
342                    "Content-Type".to_string(),
343                    "application/json".to_string(),
344                )]),
345                body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
346                validation: CheckValidation::StatusRange {
347                    min: 200,
348                    max_exclusive: 500,
349                },
350            });
351            self.checks.push(ConformanceCheck {
352                name: "body:form-urlencoded".to_string(),
353                method: Method::POST,
354                path: "/conformance/body/form".to_string(),
355                headers: self.custom_headers_only(),
356                body: Some(CheckBody::FormUrlencoded(vec![
357                    ("field1".to_string(), "value1".to_string()),
358                    ("field2".to_string(), "value2".to_string()),
359                ])),
360                validation: CheckValidation::StatusRange {
361                    min: 200,
362                    max_exclusive: 500,
363                },
364            });
365            self.checks.push(ConformanceCheck {
366                name: "body:multipart".to_string(),
367                method: Method::POST,
368                path: "/conformance/body/multipart".to_string(),
369                headers: self.custom_headers_only(),
370                body: Some(CheckBody::Raw {
371                    content: "test content".to_string(),
372                    content_type: "text/plain".to_string(),
373                }),
374                validation: CheckValidation::StatusRange {
375                    min: 200,
376                    max_exclusive: 500,
377                },
378            });
379        }
380
381        // --- Schema Types ---
382        if self.config.should_include_category("Schema Types") {
383            let types = [
384                ("string", r#"{"value": "hello"}"#, "schema:string"),
385                ("integer", r#"{"value": 42}"#, "schema:integer"),
386                ("number", r#"{"value": 3.14}"#, "schema:number"),
387                ("boolean", r#"{"value": true}"#, "schema:boolean"),
388                ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
389                ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
390            ];
391            for (type_name, body_str, check_name) in types {
392                self.checks.push(ConformanceCheck {
393                    name: check_name.to_string(),
394                    method: Method::POST,
395                    path: format!("/conformance/schema/{}", type_name),
396                    headers: self.merge_headers(vec![(
397                        "Content-Type".to_string(),
398                        "application/json".to_string(),
399                    )]),
400                    body: Some(CheckBody::Json(
401                        serde_json::from_str(body_str).expect("valid JSON"),
402                    )),
403                    validation: CheckValidation::StatusRange {
404                        min: 200,
405                        max_exclusive: 500,
406                    },
407                });
408            }
409        }
410
411        // --- Composition ---
412        if self.config.should_include_category("Composition") {
413            let compositions = [
414                ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
415                ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
416                ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
417            ];
418            for (kind, body_str, check_name) in compositions {
419                self.checks.push(ConformanceCheck {
420                    name: check_name.to_string(),
421                    method: Method::POST,
422                    path: format!("/conformance/composition/{}", kind),
423                    headers: self.merge_headers(vec![(
424                        "Content-Type".to_string(),
425                        "application/json".to_string(),
426                    )]),
427                    body: Some(CheckBody::Json(
428                        serde_json::from_str(body_str).expect("valid JSON"),
429                    )),
430                    validation: CheckValidation::StatusRange {
431                        min: 200,
432                        max_exclusive: 500,
433                    },
434                });
435            }
436        }
437
438        // --- String Formats ---
439        if self.config.should_include_category("String Formats") {
440            let formats = [
441                ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
442                ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
443                ("email", r#"{"value": "test@example.com"}"#, "format:email"),
444                ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
445                ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
446                ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
447                ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
448            ];
449            for (fmt, body_str, check_name) in formats {
450                self.checks.push(ConformanceCheck {
451                    name: check_name.to_string(),
452                    method: Method::POST,
453                    path: format!("/conformance/formats/{}", fmt),
454                    headers: self.merge_headers(vec![(
455                        "Content-Type".to_string(),
456                        "application/json".to_string(),
457                    )]),
458                    body: Some(CheckBody::Json(
459                        serde_json::from_str(body_str).expect("valid JSON"),
460                    )),
461                    validation: CheckValidation::StatusRange {
462                        min: 200,
463                        max_exclusive: 500,
464                    },
465                });
466            }
467        }
468
469        // --- Constraints ---
470        if self.config.should_include_category("Constraints") {
471            let constraints = [
472                ("required", r#"{"required_field": "present"}"#, "constraint:required"),
473                ("optional", r#"{}"#, "constraint:optional"),
474                ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
475                ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
476                ("enum", r#"{"status": "active"}"#, "constraint:enum"),
477            ];
478            for (kind, body_str, check_name) in constraints {
479                self.checks.push(ConformanceCheck {
480                    name: check_name.to_string(),
481                    method: Method::POST,
482                    path: format!("/conformance/constraints/{}", kind),
483                    headers: self.merge_headers(vec![(
484                        "Content-Type".to_string(),
485                        "application/json".to_string(),
486                    )]),
487                    body: Some(CheckBody::Json(
488                        serde_json::from_str(body_str).expect("valid JSON"),
489                    )),
490                    validation: CheckValidation::StatusRange {
491                        min: 200,
492                        max_exclusive: 500,
493                    },
494                });
495            }
496        }
497
498        // --- Response Codes ---
499        if self.config.should_include_category("Response Codes") {
500            for (code_str, check_name) in [
501                ("200", "response:200"),
502                ("201", "response:201"),
503                ("204", "response:204"),
504                ("400", "response:400"),
505                ("404", "response:404"),
506            ] {
507                let code: u16 = code_str.parse().unwrap();
508                self.checks.push(ConformanceCheck {
509                    name: check_name.to_string(),
510                    method: Method::GET,
511                    path: format!("/conformance/responses/{}", code_str),
512                    headers: self.custom_headers_only(),
513                    body: None,
514                    validation: CheckValidation::ExactStatus(code),
515                });
516            }
517        }
518
519        // --- HTTP Methods ---
520        if self.config.should_include_category("HTTP Methods") {
521            self.add_ref_get("method:GET", "/conformance/methods");
522            for (method, check_name) in [
523                (Method::POST, "method:POST"),
524                (Method::PUT, "method:PUT"),
525                (Method::PATCH, "method:PATCH"),
526            ] {
527                self.checks.push(ConformanceCheck {
528                    name: check_name.to_string(),
529                    method,
530                    path: "/conformance/methods".to_string(),
531                    headers: self.merge_headers(vec![(
532                        "Content-Type".to_string(),
533                        "application/json".to_string(),
534                    )]),
535                    body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
536                    validation: CheckValidation::StatusRange {
537                        min: 200,
538                        max_exclusive: 500,
539                    },
540                });
541            }
542            for (method, check_name) in [
543                (Method::DELETE, "method:DELETE"),
544                (Method::HEAD, "method:HEAD"),
545                (Method::OPTIONS, "method:OPTIONS"),
546            ] {
547                self.checks.push(ConformanceCheck {
548                    name: check_name.to_string(),
549                    method,
550                    path: "/conformance/methods".to_string(),
551                    headers: self.custom_headers_only(),
552                    body: None,
553                    validation: CheckValidation::StatusRange {
554                        min: 200,
555                        max_exclusive: 500,
556                    },
557                });
558            }
559        }
560
561        // --- Content Types ---
562        if self.config.should_include_category("Content Types") {
563            self.checks.push(ConformanceCheck {
564                name: "content:negotiation".to_string(),
565                method: Method::GET,
566                path: "/conformance/content-types".to_string(),
567                headers: self
568                    .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
569                body: None,
570                validation: CheckValidation::StatusRange {
571                    min: 200,
572                    max_exclusive: 500,
573                },
574            });
575        }
576
577        // --- Security ---
578        if self.config.should_include_category("Security") {
579            // Bearer
580            self.checks.push(ConformanceCheck {
581                name: "security:bearer".to_string(),
582                method: Method::GET,
583                path: "/conformance/security/bearer".to_string(),
584                headers: self.merge_headers(vec![(
585                    "Authorization".to_string(),
586                    "Bearer test-token-123".to_string(),
587                )]),
588                body: None,
589                validation: CheckValidation::StatusRange {
590                    min: 200,
591                    max_exclusive: 500,
592                },
593            });
594
595            // API Key
596            let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
597            self.checks.push(ConformanceCheck {
598                name: "security:apikey".to_string(),
599                method: Method::GET,
600                path: "/conformance/security/apikey".to_string(),
601                headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
602                body: None,
603                validation: CheckValidation::StatusRange {
604                    min: 200,
605                    max_exclusive: 500,
606                },
607            });
608
609            // Basic auth
610            let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
611            use base64::Engine;
612            let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
613            self.checks.push(ConformanceCheck {
614                name: "security:basic".to_string(),
615                method: Method::GET,
616                path: "/conformance/security/basic".to_string(),
617                headers: self.merge_headers(vec![(
618                    "Authorization".to_string(),
619                    format!("Basic {}", encoded),
620                )]),
621                body: None,
622                validation: CheckValidation::StatusRange {
623                    min: 200,
624                    max_exclusive: 500,
625                },
626            });
627        }
628
629        self
630    }
631
632    /// Populate checks from annotated spec operations (spec-driven mode)
633    #[must_use]
634    pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
635        // Track which features have been seen to deduplicate in default mode
636        let mut feature_seen: HashSet<&'static str> = HashSet::new();
637
638        for op in operations {
639            for feature in &op.features {
640                let category = feature.category();
641                if !self.config.should_include_category(category) {
642                    continue;
643                }
644
645                let check_name_base = feature.check_name();
646
647                if self.config.all_operations {
648                    // All-operations mode: test every operation with path-qualified names
649                    let check_name = format!("{}:{}", check_name_base, op.path);
650                    let check = self.build_spec_check(&check_name, op, feature);
651                    self.checks.push(check);
652                } else {
653                    // Default mode: one representative operation per feature
654                    if feature_seen.insert(check_name_base) {
655                        let check_name = format!("{}:{}", check_name_base, op.path);
656                        let check = self.build_spec_check(&check_name, op, feature);
657                        self.checks.push(check);
658                    }
659                }
660            }
661        }
662
663        self
664    }
665
666    /// Load custom checks from the configured YAML file
667    pub fn with_custom_checks(self) -> Result<Self> {
668        let path = match &self.config.custom_checks_file {
669            Some(p) => p.clone(),
670            None => return Ok(self),
671        };
672        let custom_config = CustomConformanceConfig::from_file(&path)?;
673        self.append_custom_checks(&custom_config)
674    }
675
676    /// Load custom checks from an already-parsed `CustomConformanceConfig`.
677    ///
678    /// Counterpart to [`Self::with_custom_checks`] for callers that
679    /// don't have a filesystem path — the cloud test-runner receives
680    /// the YAML inline on the suite config and parses it server-side
681    /// before reaching the executor, so it can't (and shouldn't) write
682    /// the bytes to a tempfile just to satisfy a file-based API.
683    pub fn with_custom_checks_from_config(
684        self,
685        custom_config: CustomConformanceConfig,
686    ) -> Result<Self> {
687        self.append_custom_checks(&custom_config)
688    }
689
690    /// Shared implementation: filter + add a parsed
691    /// `CustomConformanceConfig`'s entries onto the check list.
692    /// Pulled out of `with_custom_checks` so the inline-config and
693    /// file-based paths can't drift.
694    fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
695        let filter_re = match &self.config.custom_filter {
696            Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
697                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
698            })?),
699            None => None,
700        };
701
702        let mut included = 0usize;
703        let total = custom_config.custom_checks.len();
704        // Round 38 — propagate the chain-iteration count from the YAML
705        // into the executor. Saturates to `1` when the YAML omits the
706        // field, matching the existing single-pass behaviour.
707        self.chain_iterations = custom_config.chain_iterations.max(1);
708        for check in &custom_config.custom_checks {
709            if let Some(ref re) = filter_re {
710                if !re.is_match(&check.name) && !re.is_match(&check.path) {
711                    continue;
712                }
713            }
714            self.add_custom_check(check);
715            included += 1;
716        }
717
718        if filter_re.is_some() {
719            tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
720        }
721
722        Ok(self)
723    }
724
725    /// Return the number of checks that will be executed
726    pub fn check_count(&self) -> usize {
727        self.checks.len()
728    }
729
730    /// Execute all checks and return a `ConformanceReport`
731    pub async fn execute(&self) -> Result<ConformanceReport> {
732        let chain_iters = self.chain_iterations.max(1);
733        let mut results = Vec::with_capacity(self.checks.len() * chain_iters as usize);
734        let delay = self.config.request_delay_ms;
735
736        for _iter in 0..chain_iters {
737            // Round 38 (#79) — every iteration starts with a fresh
738            // chain context so captures from iteration K do not leak
739            // into K+1. This is what Srikanth wants for repeated
740            // login + work + logout cycles.
741            let mut ctx = ChainContext::default();
742            for (i, check) in self.checks.iter().enumerate() {
743                if delay > 0 && i > 0 {
744                    tokio::time::sleep(Duration::from_millis(delay)).await;
745                }
746                if let Some(meta) = self.chain_meta.get(&i).cloned() {
747                    results.extend(self.execute_chain_check(check, &meta, &mut ctx).await);
748                } else {
749                    results.push(self.execute_check(check).await);
750                }
751            }
752        }
753
754        // Write request log if --export-requests was set
755        if self.config.export_requests {
756            if let Some(ref output_dir) = self.config.output_dir {
757                let request_log: Vec<_> = results
758                    .iter()
759                    .filter_map(|r| {
760                        r.captured.as_ref().map(|c| {
761                            serde_json::json!({
762                                "check": r.name,
763                                "passed": r.passed,
764                                "request": {
765                                    "method": c.method,
766                                    "url": c.url,
767                                    "headers": c.request_headers,
768                                    "body": c.request_body,
769                                },
770                                "response": {
771                                    "status": c.response_status,
772                                    "headers": c.response_headers,
773                                    "body": c.response_body,
774                                },
775                            })
776                        })
777                    })
778                    .collect();
779                let path = output_dir.join("conformance-requests.json");
780                if let Ok(json) = serde_json::to_string_pretty(&request_log) {
781                    let _ = std::fs::write(&path, json);
782                    tracing::info!(
783                        "Exported {} request/response pairs to {}",
784                        request_log.len(),
785                        path.display()
786                    );
787                }
788            }
789        }
790
791        // Round 46 (#79) — drain the wire-level network-events sink
792        // into `conformance-network-events.json` so the user can see
793        // every connect / timeout / TLS failure with a timestamp + the
794        // classified `kind` field. Empty file when the run had no
795        // wire failures (cleanest signal that connectivity stayed up).
796        if let Some(ref output_dir) = self.config.output_dir {
797            if let Ok(guard) = self.network_events.lock() {
798                if !guard.is_empty() {
799                    let path = output_dir.join("conformance-network-events.json");
800                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
801                        let _ = std::fs::write(&path, json);
802                        tracing::warn!(
803                            "Recorded {} wire-level network event(s) to {} — see file for timestamps and classified reasons",
804                            guard.len(),
805                            path.display()
806                        );
807                    }
808                }
809            }
810        }
811
812        Ok(Self::aggregate(results))
813    }
814
815    /// Execute all checks with progress events sent to the channel
816    pub async fn execute_with_progress(
817        &self,
818        tx: mpsc::Sender<ConformanceProgress>,
819    ) -> Result<ConformanceReport> {
820        let chain_iters = self.chain_iterations.max(1);
821        let total = self.checks.len() * chain_iters as usize;
822        let delay = self.config.request_delay_ms;
823        let _ = tx
824            .send(ConformanceProgress::Started {
825                total_checks: total,
826            })
827            .await;
828
829        let mut results = Vec::with_capacity(total);
830
831        for _iter in 0..chain_iters {
832            let mut ctx = ChainContext::default();
833            for (i, check) in self.checks.iter().enumerate() {
834                if delay > 0 && i > 0 {
835                    tokio::time::sleep(Duration::from_millis(delay)).await;
836                }
837                let new_results = if let Some(meta) = self.chain_meta.get(&i).cloned() {
838                    self.execute_chain_check(check, &meta, &mut ctx).await
839                } else {
840                    vec![self.execute_check(check).await]
841                };
842                for result in new_results {
843                    let passed = result.passed;
844                    let name = result.name.clone();
845                    results.push(result);
846                    let _ = tx
847                        .send(ConformanceProgress::CheckCompleted {
848                            name,
849                            passed,
850                            checks_done: results.len(),
851                        })
852                        .await;
853                }
854            }
855        }
856
857        let _ = tx.send(ConformanceProgress::Finished).await;
858        Ok(Self::aggregate(results))
859    }
860
861    /// Execute a single check
862    async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
863        let base_url = self.config.effective_base_url();
864        let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
865
866        let mut request = self.client.request(check.method.clone(), &url);
867
868        // Add headers
869        for (name, value) in &check.headers {
870            request = request.header(name.as_str(), value.as_str());
871        }
872
873        // Add body
874        match &check.body {
875            Some(CheckBody::Json(value)) => {
876                request = request.json(value);
877            }
878            Some(CheckBody::FormUrlencoded(fields)) => {
879                request = request.form(fields);
880            }
881            Some(CheckBody::Raw {
882                content,
883                content_type,
884            }) => {
885                // For multipart, use the multipart API
886                if content_type == "text/plain" && check.path.contains("multipart") {
887                    let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
888                        .file_name("test.txt")
889                        .mime_str(content_type)
890                        .unwrap_or_else(|_| {
891                            reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
892                        });
893                    let form = reqwest::multipart::Form::new().part("field", part);
894                    request = request.multipart(form);
895                } else {
896                    request =
897                        request.header("Content-Type", content_type.as_str()).body(content.clone());
898                }
899            }
900            // Round 38 (#79) — file-upload via multipart/form-data.
901            // One reqwest `Part` per declared upload, all merged into a
902            // single `Form`. `mime_str` rejects malformed Content-Types
903            // (e.g. "application / json" with spaces); fall back to
904            // octet-stream so the request still goes out and the
905            // server can decide what to do with the bytes.
906            Some(CheckBody::Multipart { parts }) => {
907                let mut form = reqwest::multipart::Form::new();
908                for part_spec in parts {
909                    let mut part = reqwest::multipart::Part::bytes(part_spec.bytes.clone())
910                        .file_name(part_spec.filename.clone());
911                    part = match part.mime_str(&part_spec.content_type) {
912                        Ok(p) => p,
913                        Err(_) => reqwest::multipart::Part::bytes(part_spec.bytes.clone())
914                            .file_name(part_spec.filename.clone())
915                            .mime_str("application/octet-stream")
916                            .expect("application/octet-stream is a valid MIME type"),
917                    };
918                    form = form.part(part_spec.field_name.clone(), part);
919                }
920                request = request.multipart(form);
921            }
922            None => {}
923        }
924
925        let req_body_str = match &check.body {
926            Some(CheckBody::Json(v)) => v.to_string(),
927            Some(CheckBody::FormUrlencoded(f)) => {
928                f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
929            }
930            Some(CheckBody::Raw { content, .. }) => content.clone(),
931            // Round 38 — for `--export-requests`, write a brief
932            // multipart summary (`<N files: a.jpg (image/jpeg, 1234
933            // bytes), b.json (application/json, 56 bytes)>`) instead
934            // of the raw multipart envelope (which would be megabytes
935            // for real file uploads and not useful in a JSON capture
936            // anyway).
937            Some(CheckBody::Multipart { parts }) => {
938                let summary: Vec<String> = parts
939                    .iter()
940                    .map(|p| {
941                        format!("{} ({}, {} bytes)", p.filename, p.content_type, p.bytes.len())
942                    })
943                    .collect();
944                format!("<{} file(s): {}>", parts.len(), summary.join(", "))
945            }
946            None => String::new(),
947        };
948
949        let response = match request.send().await {
950            Ok(resp) => resp,
951            Err(e) => {
952                // Round 46 (#79) — Srikanth on 0.3.190 Q3: "Is there a
953                // way or logs in mockforge client to understand
954                // timestamp range of traffic drops and client reason
955                // for traffic drop (network reachability, duplicate
956                // ip, choking, congestion etc)". Classify the
957                // reqwest::Error into a coarse `kind` (connect /
958                // timeout / tls / decode / body / other) and push it
959                // to the run's network-events sink so the user can
960                // grep `conformance-network-events.json` for what
961                // went wrong on the wire and when.
962                let kind = if e.is_connect() {
963                    "connect"
964                } else if e.is_timeout() {
965                    "timeout"
966                } else if e.is_request() {
967                    "request"
968                } else if e.is_body() {
969                    "body"
970                } else if e.is_decode() {
971                    "decode"
972                } else if format!("{}", e).to_ascii_lowercase().contains("tls") {
973                    "tls"
974                } else {
975                    "other"
976                };
977                self.record_network_event(NetworkEvent {
978                    timestamp: chrono::Utc::now(),
979                    check: check.name.clone(),
980                    method: check.method.to_string(),
981                    url: url.clone(),
982                    kind: kind.to_string(),
983                    message: format!("{}", e),
984                });
985                return CheckResult {
986                    name: check.name.clone(),
987                    passed: false,
988                    failure_detail: Some(FailureDetail {
989                        check: check.name.clone(),
990                        request: FailureRequest {
991                            method: check.method.to_string(),
992                            url: url.clone(),
993                            headers: HashMap::new(),
994                            body: String::new(),
995                        },
996                        response: FailureResponse {
997                            status: 0,
998                            headers: HashMap::new(),
999                            body: format!("Request failed ({}): {}", kind, e),
1000                        },
1001                        expected: format!("{:?}", check.validation),
1002                        schema_violations: Vec::new(),
1003                    }),
1004                    captured: None,
1005                };
1006            }
1007        };
1008
1009        let status = response.status().as_u16();
1010        let resp_headers: HashMap<String, String> = response
1011            .headers()
1012            .iter()
1013            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
1014            .collect();
1015        let resp_body = response.text().await.unwrap_or_default();
1016
1017        let (passed, schema_violations) =
1018            self.validate_response(&check.validation, status, &resp_headers, &resp_body);
1019
1020        // Capture the exchange when export_requests is on OR when any
1021        // check in this run declared chain semantics (extract /
1022        // repeat). The chain executor needs the response headers and
1023        // body to extract captured values — without `captured` the
1024        // chain context can't populate `${cookie:...}` /
1025        // `${var:...}`. Round 38 (#79).
1026        let need_capture = self.config.export_requests || !self.chain_meta.is_empty();
1027        let captured = if need_capture {
1028            Some(CapturedExchange {
1029                method: check.method.to_string(),
1030                url: url.clone(),
1031                request_headers: check.headers.iter().cloned().collect(),
1032                // Round 45 (#79) — Srikanth on 0.3.189: showed a 13MB
1033                // multipart upload getting cut mid-PEM at the 2000-byte
1034                // cap. Bump to 64KB so a single PEM cert + headers fits
1035                // inline and we still get the boundary chain for visual
1036                // confirmation. Append a marker reporting the original
1037                // size so a downstream consumer can tell a truncation
1038                // from a full payload.
1039                request_body: if req_body_str.len() > 65536 {
1040                    format!(
1041                        "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1042                        &req_body_str[..65536],
1043                        req_body_str.len()
1044                    )
1045                } else {
1046                    req_body_str
1047                },
1048                response_status: status,
1049                response_headers: resp_headers.clone(),
1050                response_body: if resp_body.len() > 65536 {
1051                    format!(
1052                        "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1053                        &resp_body[..65536],
1054                        resp_body.len()
1055                    )
1056                } else {
1057                    resp_body.clone()
1058                },
1059            })
1060        } else {
1061            None
1062        };
1063
1064        let failure_detail = if !passed {
1065            Some(FailureDetail {
1066                check: check.name.clone(),
1067                request: FailureRequest {
1068                    method: check.method.to_string(),
1069                    url,
1070                    headers: check.headers.iter().cloned().collect(),
1071                    body: match &check.body {
1072                        Some(CheckBody::Json(v)) => v.to_string(),
1073                        Some(CheckBody::FormUrlencoded(f)) => f
1074                            .iter()
1075                            .map(|(k, v)| format!("{}={}", k, v))
1076                            .collect::<Vec<_>>()
1077                            .join("&"),
1078                        Some(CheckBody::Raw { content, .. }) => content.clone(),
1079                        // Round 38 — same brief summary used by the
1080                        // export path; keeps failure detail readable
1081                        // without dumping multi-MB upload bytes.
1082                        Some(CheckBody::Multipart { parts }) => {
1083                            format!("<{} multipart file(s)>", parts.len())
1084                        }
1085                        None => String::new(),
1086                    },
1087                },
1088                response: FailureResponse {
1089                    status,
1090                    headers: resp_headers,
1091                    body: if resp_body.len() > 500 {
1092                        format!("{}...", &resp_body[..500])
1093                    } else {
1094                        resp_body
1095                    },
1096                },
1097                expected: Self::describe_validation(&check.validation),
1098                schema_violations,
1099            })
1100        } else {
1101            None
1102        };
1103
1104        CheckResult {
1105            name: check.name.clone(),
1106            passed,
1107            failure_detail,
1108            captured,
1109        }
1110    }
1111
1112    /// Round 38 (#79) — execute a custom check with chain semantics:
1113    /// substitute captured values from `ctx` into the request, run
1114    /// the configured repeat (sequential or parallel), and pour any
1115    /// `extract`-matched response data back into `ctx` for the next
1116    /// check to use. The first response's data is the one that
1117    /// becomes visible to extract; under `parallel` repeat the
1118    /// captures from the racing requests would be ambiguous so we
1119    /// pull only from the first to finish.
1120    ///
1121    /// Returns a vector because a `repeat.count > 1` produces N
1122    /// `CheckResult`s, all of which need to flow through the aggregate.
1123    async fn execute_chain_check(
1124        &self,
1125        check: &ConformanceCheck,
1126        meta: &ChainMeta,
1127        ctx: &mut ChainContext,
1128    ) -> Vec<CheckResult> {
1129        let substituted = apply_chain_context(check, ctx);
1130        let count = meta.repeat.count.max(1);
1131        let results = match meta.repeat.mode {
1132            super::custom::RepeatMode::Sequential => {
1133                let mut out = Vec::with_capacity(count as usize);
1134                for _ in 0..count {
1135                    out.push(self.execute_check(&substituted).await);
1136                }
1137                out
1138            }
1139            super::custom::RepeatMode::Parallel => {
1140                let futs = (0..count).map(|_| self.execute_check(&substituted));
1141                futures::future::join_all(futs).await
1142            }
1143        };
1144
1145        // Extract from the first response. Under sequential repeat
1146        // this is the first request fired; under parallel it's the
1147        // first slot in the result array, which join_all preserves
1148        // by input order (not completion order), so the extraction
1149        // is at least deterministic.
1150        if !meta.extract.is_empty() {
1151            if let Some(first) = results.first() {
1152                if let Some(captured) = &first.captured {
1153                    extract_into_context(
1154                        &meta.extract,
1155                        &captured.response_headers,
1156                        &captured.response_body,
1157                        ctx,
1158                    );
1159                }
1160            }
1161        }
1162        results
1163    }
1164
1165    /// Validate a response against the check's validation rules.
1166    ///
1167    /// Returns `(passed, schema_violations)` where `schema_violations` contains
1168    /// field-level details when a `SchemaValidation` check fails.
1169    fn validate_response(
1170        &self,
1171        validation: &CheckValidation,
1172        status: u16,
1173        headers: &HashMap<String, String>,
1174        body: &str,
1175    ) -> (bool, Vec<SchemaViolation>) {
1176        match validation {
1177            CheckValidation::StatusRange { min, max_exclusive } => {
1178                (status >= *min && status < *max_exclusive, Vec::new())
1179            }
1180            CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1181            CheckValidation::SchemaValidation {
1182                status_min,
1183                status_max,
1184                schema,
1185            } => {
1186                if status < *status_min || status >= *status_max {
1187                    return (false, Vec::new());
1188                }
1189                // Parse body as JSON and validate against schema
1190                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1191                    return (
1192                        false,
1193                        vec![SchemaViolation {
1194                            field_path: "/".to_string(),
1195                            violation_type: "parse_error".to_string(),
1196                            expected: "valid JSON".to_string(),
1197                            actual: "non-JSON response body".to_string(),
1198                        }],
1199                    );
1200                };
1201                match jsonschema::validator_for(schema) {
1202                    Ok(validator) => {
1203                        let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1204                        if errors.is_empty() {
1205                            (true, Vec::new())
1206                        } else {
1207                            let violations = errors
1208                                .iter()
1209                                .map(|err| {
1210                                    let field_path = err.instance_path.to_string();
1211                                    let field_path = if field_path.is_empty() {
1212                                        "/".to_string()
1213                                    } else {
1214                                        field_path
1215                                    };
1216                                    SchemaViolation {
1217                                        field_path,
1218                                        violation_type: format!("{:?}", err.kind)
1219                                            .split('(')
1220                                            .next()
1221                                            .unwrap_or("unknown")
1222                                            .split('{')
1223                                            .next()
1224                                            .unwrap_or("unknown")
1225                                            .split(' ')
1226                                            .next()
1227                                            .unwrap_or("unknown")
1228                                            .trim()
1229                                            .to_string(),
1230                                        expected: {
1231                                            // Extract human-readable expected value from the error.
1232                                            // The schema_path is like "/properties/field/type" —
1233                                            // extract the last meaningful segment instead.
1234                                            let schema_str = format!("{}", err.schema_path);
1235                                            match &err.kind {
1236                                                jsonschema::error::ValidationErrorKind::Type { kind } => {
1237                                                    format!("type: {:?}", kind)
1238                                                }
1239                                                jsonschema::error::ValidationErrorKind::Required { property } => {
1240                                                    format!("required field: {}", property)
1241                                                }
1242                                                _ => {
1243                                                    // For other kinds, use last path segment
1244                                                    schema_str
1245                                                        .rsplit('/')
1246                                                        .next()
1247                                                        .unwrap_or(&schema_str)
1248                                                        .to_string()
1249                                                }
1250                                            }
1251                                        },
1252                                        actual: format!("{}", err),
1253                                    }
1254                                })
1255                                .collect();
1256                            (false, violations)
1257                        }
1258                    }
1259                    Err(_) => {
1260                        // Schema compilation failed — fall back to is_valid behavior
1261                        (
1262                            false,
1263                            vec![SchemaViolation {
1264                                field_path: "/".to_string(),
1265                                violation_type: "schema_compile_error".to_string(),
1266                                expected: "valid JSON schema".to_string(),
1267                                actual: "schema failed to compile".to_string(),
1268                            }],
1269                        )
1270                    }
1271                }
1272            }
1273            CheckValidation::Custom {
1274                expected_status,
1275                expected_headers,
1276                expected_body_fields,
1277            } => {
1278                if status != *expected_status {
1279                    return (false, Vec::new());
1280                }
1281                // Check headers with regex
1282                for (header_name, pattern) in expected_headers {
1283                    let header_val = headers
1284                        .get(header_name)
1285                        .or_else(|| headers.get(&header_name.to_lowercase()))
1286                        .map(|s| s.as_str())
1287                        .unwrap_or("");
1288                    if let Ok(re) = regex::Regex::new(pattern) {
1289                        if !re.is_match(header_val) {
1290                            return (false, Vec::new());
1291                        }
1292                    }
1293                }
1294                // Check body field types
1295                if !expected_body_fields.is_empty() {
1296                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1297                        return (false, Vec::new());
1298                    };
1299                    for (field_name, field_type) in expected_body_fields {
1300                        let field = &body_value[field_name];
1301                        let ok = match field_type.as_str() {
1302                            "string" => field.is_string(),
1303                            "integer" => field.is_i64() || field.is_u64(),
1304                            "number" => field.is_number(),
1305                            "boolean" => field.is_boolean(),
1306                            "array" => field.is_array(),
1307                            "object" => field.is_object(),
1308                            _ => !field.is_null(),
1309                        };
1310                        if !ok {
1311                            return (false, Vec::new());
1312                        }
1313                    }
1314                }
1315                (true, Vec::new())
1316            }
1317        }
1318    }
1319
1320    /// Human-readable validation description for failure reports
1321    fn describe_validation(validation: &CheckValidation) -> String {
1322        match validation {
1323            CheckValidation::StatusRange { min, max_exclusive } => {
1324                format!("status >= {} && status < {}", min, max_exclusive)
1325            }
1326            CheckValidation::ExactStatus(code) => format!("status === {}", code),
1327            CheckValidation::SchemaValidation {
1328                status_min,
1329                status_max,
1330                ..
1331            } => {
1332                format!("status >= {} && status < {} + schema validation", status_min, status_max)
1333            }
1334            CheckValidation::Custom {
1335                expected_status, ..
1336            } => {
1337                format!("status === {}", expected_status)
1338            }
1339        }
1340    }
1341
1342    /// Aggregate check results into a `ConformanceReport`
1343    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1344        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1345        let mut failure_details = Vec::new();
1346
1347        for result in results {
1348            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1349            if result.passed {
1350                entry.0 += 1;
1351            } else {
1352                entry.1 += 1;
1353            }
1354            if let Some(detail) = result.failure_detail {
1355                failure_details.push(detail);
1356            }
1357        }
1358
1359        ConformanceReport::from_results(check_results, failure_details)
1360    }
1361
1362    // --- Helper methods ---
1363
1364    /// Build a spec-driven check from an annotated operation and feature
1365    fn build_spec_check(
1366        &self,
1367        check_name: &str,
1368        op: &AnnotatedOperation,
1369        feature: &ConformanceFeature,
1370    ) -> ConformanceCheck {
1371        // Build URL path with parameters substituted
1372        let mut url_path = op.path.clone();
1373        for (name, value) in &op.path_params {
1374            url_path = url_path.replace(&format!("{{{}}}", name), value);
1375        }
1376        // Append query params
1377        if !op.query_params.is_empty() {
1378            let qs: Vec<String> =
1379                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1380            url_path = format!("{}?{}", url_path, qs.join("&"));
1381        }
1382
1383        // Build effective headers
1384        let mut effective_headers = self.effective_headers(&op.header_params);
1385
1386        // For non-default response codes, add mock server header
1387        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1388            let code = match feature {
1389                ConformanceFeature::Response400 => "400",
1390                ConformanceFeature::Response404 => "404",
1391                _ => unreachable!(),
1392            };
1393            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1394        }
1395
1396        // Inject auth headers for security checks or secured endpoints
1397        let needs_auth = matches!(
1398            feature,
1399            ConformanceFeature::SecurityBearer
1400                | ConformanceFeature::SecurityBasic
1401                | ConformanceFeature::SecurityApiKey
1402        ) || !op.security_schemes.is_empty();
1403
1404        if needs_auth {
1405            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1406        }
1407
1408        // Determine method
1409        let method = match op.method.as_str() {
1410            "GET" => Method::GET,
1411            "POST" => Method::POST,
1412            "PUT" => Method::PUT,
1413            "PATCH" => Method::PATCH,
1414            "DELETE" => Method::DELETE,
1415            "HEAD" => Method::HEAD,
1416            "OPTIONS" => Method::OPTIONS,
1417            _ => Method::GET,
1418        };
1419
1420        // Determine body
1421        let body = match method {
1422            Method::POST | Method::PUT | Method::PATCH => {
1423                if let Some(sample) = &op.sample_body {
1424                    // Add Content-Type if not present
1425                    let content_type =
1426                        op.request_body_content_type.as_deref().unwrap_or("application/json");
1427                    if !effective_headers
1428                        .iter()
1429                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1430                    {
1431                        effective_headers
1432                            .push(("Content-Type".to_string(), content_type.to_string()));
1433                    }
1434                    match content_type {
1435                        "application/x-www-form-urlencoded" => {
1436                            // Parse as form fields
1437                            let fields: Vec<(String, String)> = serde_json::from_str::<
1438                                serde_json::Value,
1439                            >(
1440                                sample
1441                            )
1442                            .ok()
1443                            .and_then(|v| {
1444                                v.as_object().map(|obj| {
1445                                    obj.iter()
1446                                        .map(|(k, v)| {
1447                                            (k.clone(), v.as_str().unwrap_or("").to_string())
1448                                        })
1449                                        .collect()
1450                                })
1451                            })
1452                            .unwrap_or_default();
1453                            Some(CheckBody::FormUrlencoded(fields))
1454                        }
1455                        _ => {
1456                            // Try JSON, fall back to raw
1457                            match serde_json::from_str::<serde_json::Value>(sample) {
1458                                Ok(v) => Some(CheckBody::Json(v)),
1459                                Err(_) => Some(CheckBody::Raw {
1460                                    content: sample.clone(),
1461                                    content_type: content_type.to_string(),
1462                                }),
1463                            }
1464                        }
1465                    }
1466                } else {
1467                    None
1468                }
1469            }
1470            _ => None,
1471        };
1472
1473        // Determine validation
1474        let validation = self.determine_validation(feature, op);
1475
1476        ConformanceCheck {
1477            name: check_name.to_string(),
1478            method,
1479            path: url_path,
1480            headers: effective_headers,
1481            body,
1482            validation,
1483        }
1484    }
1485
1486    /// Determine validation strategy based on the conformance feature
1487    fn determine_validation(
1488        &self,
1489        feature: &ConformanceFeature,
1490        op: &AnnotatedOperation,
1491    ) -> CheckValidation {
1492        match feature {
1493            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1494            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1495            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1496            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1497            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1498            ConformanceFeature::SecurityBearer
1499            | ConformanceFeature::SecurityBasic
1500            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1501                min: 200,
1502                max_exclusive: 400,
1503            },
1504            ConformanceFeature::ResponseValidation => {
1505                if let Some(schema) = &op.response_schema {
1506                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
1507                    let schema_json = openapi_schema_to_json_schema(schema);
1508                    CheckValidation::SchemaValidation {
1509                        status_min: 200,
1510                        status_max: 500,
1511                        schema: schema_json,
1512                    }
1513                } else {
1514                    CheckValidation::StatusRange {
1515                        min: 200,
1516                        max_exclusive: 500,
1517                    }
1518                }
1519            }
1520            _ => CheckValidation::StatusRange {
1521                min: 200,
1522                max_exclusive: 500,
1523            },
1524        }
1525    }
1526
1527    /// Add a simple GET reference check with default status range validation
1528    fn add_ref_get(&mut self, name: &str, path: &str) {
1529        self.checks.push(ConformanceCheck {
1530            name: name.to_string(),
1531            method: Method::GET,
1532            path: path.to_string(),
1533            headers: self.custom_headers_only(),
1534            body: None,
1535            validation: CheckValidation::StatusRange {
1536                min: 200,
1537                max_exclusive: 500,
1538            },
1539        });
1540    }
1541
1542    /// Merge spec-derived headers with custom headers (custom overrides spec)
1543    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1544        let mut headers = Vec::new();
1545        for (k, v) in spec_headers {
1546            // Skip if custom headers override this one
1547            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1548                continue;
1549            }
1550            headers.push((k.clone(), v.clone()));
1551        }
1552        // Append custom headers
1553        headers.extend(self.config.custom_headers.clone());
1554        headers
1555    }
1556
1557    /// Merge provided headers with custom headers
1558    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1559        for (k, v) in &self.config.custom_headers {
1560            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1561                headers.push((k.clone(), v.clone()));
1562            }
1563        }
1564        headers
1565    }
1566
1567    /// Return only custom headers (for checks that don't have spec-derived headers)
1568    fn custom_headers_only(&self) -> Vec<(String, String)> {
1569        self.config.custom_headers.clone()
1570    }
1571
1572    /// Inject security headers based on resolved security schemes.
1573    /// If the user provides a Cookie header via --conformance-header, skip automatic
1574    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1575    fn inject_security_headers(
1576        &self,
1577        schemes: &[SecuritySchemeInfo],
1578        headers: &mut Vec<(String, String)>,
1579    ) {
1580        // If user provides Cookie header, they're using session-based auth — skip auto auth
1581        let has_cookie_auth =
1582            self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1583        let mut to_add: Vec<(String, String)> = Vec::new();
1584
1585        for scheme in schemes {
1586            match scheme {
1587                SecuritySchemeInfo::Bearer => {
1588                    if !has_cookie_auth
1589                        && !Self::header_present(
1590                            "Authorization",
1591                            headers,
1592                            &self.config.custom_headers,
1593                        )
1594                    {
1595                        to_add.push((
1596                            "Authorization".to_string(),
1597                            "Bearer mockforge-conformance-test-token".to_string(),
1598                        ));
1599                    }
1600                }
1601                SecuritySchemeInfo::Basic => {
1602                    if !has_cookie_auth
1603                        && !Self::header_present(
1604                            "Authorization",
1605                            headers,
1606                            &self.config.custom_headers,
1607                        )
1608                    {
1609                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1610                        use base64::Engine;
1611                        let encoded =
1612                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1613                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1614                    }
1615                }
1616                SecuritySchemeInfo::ApiKey { location, name } => match location {
1617                    ApiKeyLocation::Header => {
1618                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1619                            let key = self
1620                                .config
1621                                .api_key
1622                                .as_deref()
1623                                .unwrap_or("mockforge-conformance-test-key");
1624                            to_add.push((name.clone(), key.to_string()));
1625                        }
1626                    }
1627                    ApiKeyLocation::Cookie => {
1628                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1629                            to_add.push((
1630                                "Cookie".to_string(),
1631                                format!("{}=mockforge-conformance-test-session", name),
1632                            ));
1633                        }
1634                    }
1635                    ApiKeyLocation::Query => {
1636                        // Handled in URL, not headers
1637                    }
1638                },
1639            }
1640        }
1641
1642        headers.extend(to_add);
1643    }
1644
1645    /// Check if a header name is present in either the existing headers or custom headers
1646    fn header_present(
1647        name: &str,
1648        headers: &[(String, String)],
1649        custom_headers: &[(String, String)],
1650    ) -> bool {
1651        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1652            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1653    }
1654
1655    /// Add a custom check from YAML config
1656    fn add_custom_check(&mut self, check: &CustomCheck) {
1657        let method = match check.method.to_uppercase().as_str() {
1658            "GET" => Method::GET,
1659            "POST" => Method::POST,
1660            "PUT" => Method::PUT,
1661            "PATCH" => Method::PATCH,
1662            "DELETE" => Method::DELETE,
1663            "HEAD" => Method::HEAD,
1664            "OPTIONS" => Method::OPTIONS,
1665            _ => Method::GET,
1666        };
1667
1668        // Build headers
1669        let mut headers: Vec<(String, String)> =
1670            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1671        // Add global custom headers (check-specific take priority)
1672        for (k, v) in &self.config.custom_headers {
1673            if !check.headers.contains_key(k) {
1674                headers.push((k.clone(), v.clone()));
1675            }
1676        }
1677        // Add Content-Type for JSON body if not present
1678        if check.body.is_some()
1679            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1680        {
1681            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1682        }
1683
1684        // Body. Round 38 (#79) — `body` wins over `upload` /
1685        // `uploads` when both are set; the YAML is a misconfiguration
1686        // and we warn rather than silently picking one. When `body`
1687        // is absent and `upload` / `uploads` is set, every file is
1688        // read off disk at construction time and folded into a
1689        // `CheckBody::Multipart`. The file read is *eager* (not at
1690        // request time) so the executor's send path stays purely
1691        // synchronous to the network and a missing file is surfaced
1692        // here, not mid-run.
1693        let upload_specs: Vec<&super::custom::UploadFile> =
1694            check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1695        let body = if check.body.is_some() {
1696            if !upload_specs.is_empty() {
1697                eprintln!(
1698                    "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1699                    check.name
1700                );
1701            }
1702            check.body.as_ref().and_then(|b| {
1703                serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1704            })
1705        } else if !upload_specs.is_empty() {
1706            let mut parts = Vec::with_capacity(upload_specs.len());
1707            for spec in upload_specs {
1708                match std::fs::read(&spec.path) {
1709                    Ok(bytes) => {
1710                        let filename = spec.filename.clone().unwrap_or_else(|| {
1711                            std::path::Path::new(&spec.path)
1712                                .file_name()
1713                                .and_then(|n| n.to_str())
1714                                .unwrap_or("upload.bin")
1715                                .to_string()
1716                        });
1717                        parts.push(MultipartPart {
1718                            bytes,
1719                            content_type: spec.content_type.clone(),
1720                            field_name: spec.field_name.clone(),
1721                            filename,
1722                        });
1723                    }
1724                    Err(e) => {
1725                        eprintln!(
1726                            "warning: custom check '{}' could not read upload '{}': {}",
1727                            check.name, spec.path, e
1728                        );
1729                    }
1730                }
1731            }
1732            if parts.is_empty() {
1733                None
1734            } else {
1735                Some(CheckBody::Multipart { parts })
1736            }
1737        } else {
1738            None
1739        };
1740
1741        // Build expected headers for validation
1742        let expected_headers: Vec<(String, String)> =
1743            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1744
1745        // Build expected body fields
1746        let expected_body_fields: Vec<(String, String)> = check
1747            .expected_body_fields
1748            .iter()
1749            .map(|f| (f.name.clone(), f.field_type.clone()))
1750            .collect();
1751
1752        // Round 38 (#79) — register chain metadata when the YAML
1753        // asked for capture / replay. Only non-default extract or
1754        // repeat blocks reach the chain map so the steady-state
1755        // (single-shot custom check) still skips the chain path.
1756        let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1757        let next_index = self.checks.len();
1758        if needs_chain {
1759            self.chain_meta.insert(
1760                next_index,
1761                ChainMeta {
1762                    extract: check.extract.clone(),
1763                    repeat: check.repeat.clone(),
1764                },
1765            );
1766        }
1767
1768        // Primary status check
1769        self.checks.push(ConformanceCheck {
1770            name: check.name.clone(),
1771            method,
1772            path: check.path.clone(),
1773            headers,
1774            body,
1775            validation: CheckValidation::Custom {
1776                expected_status: check.expected_status,
1777                expected_headers,
1778                expected_body_fields,
1779            },
1780        });
1781    }
1782}
1783
1784/// Round 38 (#79) — return a clone of `check` with every
1785/// `${var:...}` / `${cookie:...}` / `${header:...}` token in `path`,
1786/// header values, and string bodies replaced by the corresponding
1787/// captured value. Free function (not `&self`) so unit tests can
1788/// drive it directly. JSON bodies are substituted by walking each
1789/// string leaf; non-string JSON values are left untouched.
1790fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1791    let path = ctx.substitute(&check.path);
1792    let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1793    let body = check.body.as_ref().map(|b| match b {
1794        CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1795        CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1796            fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1797        ),
1798        CheckBody::Raw {
1799            content,
1800            content_type,
1801        } => CheckBody::Raw {
1802            content: ctx.substitute(content),
1803            content_type: content_type.clone(),
1804        },
1805        // Multipart bytes are not text and are not template-targets;
1806        // pass-through unchanged so binary uploads are byte-identical
1807        // across iterations.
1808        CheckBody::Multipart { parts } => CheckBody::Multipart {
1809            parts: parts.clone(),
1810        },
1811    });
1812    ConformanceCheck {
1813        name: check.name.clone(),
1814        method: check.method.clone(),
1815        path,
1816        headers,
1817        body,
1818        validation: check.validation.clone(),
1819    }
1820}
1821
1822fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1823    use serde_json::Value;
1824    match value {
1825        Value::String(s) => Value::String(ctx.substitute(s)),
1826        Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1827        Value::Object(obj) => Value::Object(
1828            obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1829        ),
1830        other => other.clone(),
1831    }
1832}
1833
1834/// Round 38 (#79) — read captured cookies / headers / body fields off
1835/// a response and store them in the chain context for subsequent
1836/// requests to reference. Unknown extractions (e.g. a cookie name the
1837/// server didn't set) are silently skipped so a partially-met
1838/// extract block doesn't fail the whole chain.
1839fn extract_into_context(
1840    rules: &super::custom::ExtractRules,
1841    response_headers: &HashMap<String, String>,
1842    response_body: &str,
1843    ctx: &mut ChainContext,
1844) {
1845    // Cookies: every Set-Cookie header is parsed for `name=value`
1846    // (the cookie's own attributes after the value are dropped).
1847    // Multi-Set-Cookie responses (different `Set-Cookie` values
1848    // each with the same header name) are coalesced into one
1849    // comma-separated string when reqwest collects the headers,
1850    // so we split on commas and try each candidate.
1851    for cookie_name in &rules.cookies {
1852        if let Some(raw) = response_headers
1853            .iter()
1854            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1855            .map(|(_, v)| v)
1856        {
1857            // Each Set-Cookie entry looks like `name=value; attr=...`.
1858            // Multiple cookies in one header are comma-separated.
1859            for entry in raw.split(',') {
1860                let head = entry.split(';').next().unwrap_or(entry).trim();
1861                if let Some((name, value)) = head.split_once('=') {
1862                    if name.trim().eq_ignore_ascii_case(cookie_name) {
1863                        ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1864                        break;
1865                    }
1866                }
1867            }
1868        }
1869    }
1870    // Headers: case-insensitive lookup.
1871    for (var_name, header_name) in &rules.headers {
1872        if let Some((_, value)) =
1873            response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1874        {
1875            ctx.vars.insert(var_name.clone(), value.clone());
1876        }
1877    }
1878    // Body fields via simple dotted lookup. Empty / non-JSON bodies
1879    // simply contribute nothing rather than failing the chain.
1880    if !rules.body_fields.is_empty() {
1881        if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1882            for (var_name, field_path) in &rules.body_fields {
1883                if let Some(value) = lookup_json_path(&json, field_path) {
1884                    let stringified = match value {
1885                        serde_json::Value::String(s) => s.clone(),
1886                        other => other.to_string(),
1887                    };
1888                    ctx.vars.insert(var_name.clone(), stringified);
1889                }
1890            }
1891        }
1892    }
1893}
1894
1895fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1896    let mut current = value;
1897    for segment in path.split('.') {
1898        current = match current {
1899            serde_json::Value::Object(obj) => obj.get(segment)?,
1900            _ => return None,
1901        };
1902    }
1903    Some(current)
1904}
1905
1906/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1907/// suitable for use with the `jsonschema` crate.
1908fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1909    use openapiv3::{SchemaKind, Type};
1910
1911    match &schema.schema_kind {
1912        SchemaKind::Type(Type::Object(obj)) => {
1913            let mut props = serde_json::Map::new();
1914            for (name, prop_ref) in &obj.properties {
1915                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1916                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1917                }
1918            }
1919            let mut schema_obj = serde_json::json!({
1920                "type": "object",
1921                "properties": props,
1922            });
1923            if !obj.required.is_empty() {
1924                schema_obj["required"] = serde_json::Value::Array(
1925                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1926                );
1927            }
1928            schema_obj
1929        }
1930        SchemaKind::Type(Type::Array(arr)) => {
1931            let mut schema_obj = serde_json::json!({"type": "array"});
1932            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1933                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1934            }
1935            schema_obj
1936        }
1937        SchemaKind::Type(Type::String(s)) => {
1938            let mut obj = serde_json::json!({"type": "string"});
1939            if let Some(min) = s.min_length {
1940                obj["minLength"] = serde_json::json!(min);
1941            }
1942            if let Some(max) = s.max_length {
1943                obj["maxLength"] = serde_json::json!(max);
1944            }
1945            if let Some(pattern) = &s.pattern {
1946                obj["pattern"] = serde_json::json!(pattern);
1947            }
1948            if !s.enumeration.is_empty() {
1949                obj["enum"] = serde_json::Value::Array(
1950                    s.enumeration
1951                        .iter()
1952                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1953                        .collect(),
1954                );
1955            }
1956            obj
1957        }
1958        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1959        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1960        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1961        _ => serde_json::json!({}),
1962    }
1963}
1964
1965#[cfg(test)]
1966mod tests {
1967    use super::*;
1968
1969    /// Round 38 (#79) — substitution replaces `${var:...}`,
1970    /// `${cookie:...}`, and `${header:...}` tokens with the matching
1971    /// captured value. Unknown tokens are preserved verbatim so a
1972    /// missing capture is visible in the request log.
1973    #[test]
1974    fn chain_context_substitutes_all_token_kinds() {
1975        let mut ctx = ChainContext::default();
1976        ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1977        ctx.vars.insert("trace".to_string(), "xyz".to_string());
1978        ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1979        assert_eq!(ctx.substitute("plain"), "plain");
1980        assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1981        assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1982        // header: aliases var: so the same map is used.
1983        assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1984        // Unknown name preserved verbatim (no silent empty string).
1985        assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1986    }
1987
1988    /// Round 38 — extraction pulls cookies, headers, and body fields
1989    /// off a response and stores them in the chain context under the
1990    /// caller-named keys.
1991    #[test]
1992    fn extract_into_context_captures_cookies_headers_and_body_fields() {
1993        let mut headers = HashMap::new();
1994        headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
1995        headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
1996        let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
1997        let mut rules = super::super::custom::ExtractRules::default();
1998        rules.cookies.push("session".to_string());
1999        rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2000        rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
2001        rules.body_fields.insert("user_id".to_string(), "id".to_string());
2002        let mut ctx = ChainContext::default();
2003        extract_into_context(&rules, &headers, body, &mut ctx);
2004        assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
2005        assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
2006        assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
2007        // Numeric body field stringifies to "42".
2008        assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
2009    }
2010
2011    /// Round 38 — a missing cookie name doesn't poison subsequent
2012    /// extractions; the cookie pull silently no-ops and the header /
2013    /// body extractions still happen.
2014    #[test]
2015    fn extract_into_context_skips_missing_captures_gracefully() {
2016        let mut headers = HashMap::new();
2017        headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
2018        let body = r#"{"id":1}"#;
2019        let mut rules = super::super::custom::ExtractRules::default();
2020        rules.cookies.push("never-set".to_string());
2021        rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2022        let mut ctx = ChainContext::default();
2023        extract_into_context(&rules, &headers, body, &mut ctx);
2024        assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
2025        assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
2026    }
2027
2028    /// Round 38 — substitution into a ConformanceCheck flows through
2029    /// path, headers, raw bodies, JSON bodies, and form-urlencoded
2030    /// fields. Multipart bodies are pass-through (binary uploads
2031    /// shouldn't be touched by string templating).
2032    #[test]
2033    fn apply_chain_context_substitutes_path_headers_and_body() {
2034        let mut ctx = ChainContext::default();
2035        ctx.vars.insert("user".to_string(), "alice".to_string());
2036        ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
2037        let check = ConformanceCheck {
2038            name: "custom:t".into(),
2039            method: Method::POST,
2040            path: "/users/${var:user}".into(),
2041            headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
2042            body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
2043            validation: CheckValidation::ExactStatus(200),
2044        };
2045        let substituted = apply_chain_context(&check, &ctx);
2046        assert_eq!(substituted.path, "/users/alice");
2047        assert_eq!(substituted.headers[0].1, "sid=deadbeef");
2048        match substituted.body {
2049            Some(CheckBody::Json(v)) => {
2050                assert_eq!(v["by"], "alice");
2051                assert_eq!(v["ts"], 1);
2052            }
2053            _ => panic!("expected json body"),
2054        }
2055    }
2056
2057    #[test]
2058    fn test_reference_check_count() {
2059        let config = ConformanceConfig {
2060            target_url: "http://localhost:3000".to_string(),
2061            ..Default::default()
2062        };
2063        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2064        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
2065        // + 5 response codes + 7 methods + 1 content + 3 security = 47
2066        assert_eq!(executor.check_count(), 47);
2067    }
2068
2069    #[test]
2070    fn with_custom_checks_from_config_appends() {
2071        // Construct an inline CustomConformanceConfig and verify both
2072        // that the checks are appended on top of reference-checks
2073        // mode and that the filter regex (when present) drops
2074        // non-matching entries.
2075        let custom_yaml = r#"
2076custom_checks:
2077  - name: "custom:health"
2078    path: /health
2079    method: GET
2080    expected_status: 200
2081  - name: "custom:create"
2082    path: /widgets
2083    method: POST
2084    expected_status: 201
2085"#;
2086        let parsed: CustomConformanceConfig =
2087            serde_yaml::from_str(custom_yaml).expect("YAML parses");
2088        assert_eq!(parsed.custom_checks.len(), 2);
2089
2090        let base = ConformanceConfig {
2091            target_url: "http://localhost:3000".to_string(),
2092            ..Default::default()
2093        };
2094        let executor = NativeConformanceExecutor::new(base)
2095            .unwrap()
2096            .with_reference_checks()
2097            .with_custom_checks_from_config(parsed)
2098            .expect("custom checks load");
2099        // 47 reference checks + 2 custom = 49.
2100        assert_eq!(executor.check_count(), 49);
2101    }
2102
2103    #[test]
2104    fn with_custom_checks_from_config_respects_filter() {
2105        // custom_filter is regex-based; only matching entries should
2106        // make it onto the executor.
2107        let custom_yaml = r#"
2108custom_checks:
2109  - name: "custom:health"
2110    path: /health
2111    method: GET
2112    expected_status: 200
2113  - name: "custom:create-widget"
2114    path: /widgets
2115    method: POST
2116    expected_status: 201
2117"#;
2118        let parsed: CustomConformanceConfig =
2119            serde_yaml::from_str(custom_yaml).expect("YAML parses");
2120
2121        let base = ConformanceConfig {
2122            target_url: "http://localhost:3000".to_string(),
2123            // Reference checks would add 47; turn them off so the
2124            // count is purely the custom set after filtering.
2125            categories: Some(vec!["no_such_category".to_string()]),
2126            custom_filter: Some("health".to_string()),
2127            ..Default::default()
2128        };
2129        let executor = NativeConformanceExecutor::new(base)
2130            .unwrap()
2131            .with_reference_checks()
2132            .with_custom_checks_from_config(parsed)
2133            .expect("custom checks load");
2134        // categories filter drops all reference checks; custom_filter
2135        // keeps the one entry whose name matches /health/.
2136        assert_eq!(executor.check_count(), 1);
2137    }
2138
2139    #[test]
2140    fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2141        let parsed: CustomConformanceConfig =
2142            serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2143        let base = ConformanceConfig {
2144            target_url: "http://localhost:3000".to_string(),
2145            custom_filter: Some("[unclosed".to_string()),
2146            ..Default::default()
2147        };
2148        let result = NativeConformanceExecutor::new(base)
2149            .unwrap()
2150            .with_reference_checks()
2151            .with_custom_checks_from_config(parsed);
2152        assert!(result.is_err(), "bad regex should bubble up as BenchError");
2153    }
2154
2155    #[test]
2156    fn test_reference_checks_with_category_filter() {
2157        let config = ConformanceConfig {
2158            target_url: "http://localhost:3000".to_string(),
2159            categories: Some(vec!["Parameters".to_string()]),
2160            ..Default::default()
2161        };
2162        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2163        assert_eq!(executor.check_count(), 7);
2164    }
2165
2166    #[test]
2167    fn test_validate_status_range() {
2168        let config = ConformanceConfig {
2169            target_url: "http://localhost:3000".to_string(),
2170            ..Default::default()
2171        };
2172        let executor = NativeConformanceExecutor::new(config).unwrap();
2173        let headers = HashMap::new();
2174
2175        assert!(
2176            executor
2177                .validate_response(
2178                    &CheckValidation::StatusRange {
2179                        min: 200,
2180                        max_exclusive: 500,
2181                    },
2182                    200,
2183                    &headers,
2184                    "",
2185                )
2186                .0
2187        );
2188        assert!(
2189            executor
2190                .validate_response(
2191                    &CheckValidation::StatusRange {
2192                        min: 200,
2193                        max_exclusive: 500,
2194                    },
2195                    404,
2196                    &headers,
2197                    "",
2198                )
2199                .0
2200        );
2201        assert!(
2202            !executor
2203                .validate_response(
2204                    &CheckValidation::StatusRange {
2205                        min: 200,
2206                        max_exclusive: 500,
2207                    },
2208                    500,
2209                    &headers,
2210                    "",
2211                )
2212                .0
2213        );
2214    }
2215
2216    #[test]
2217    fn test_validate_exact_status() {
2218        let config = ConformanceConfig {
2219            target_url: "http://localhost:3000".to_string(),
2220            ..Default::default()
2221        };
2222        let executor = NativeConformanceExecutor::new(config).unwrap();
2223        let headers = HashMap::new();
2224
2225        assert!(
2226            executor
2227                .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2228                .0
2229        );
2230        assert!(
2231            !executor
2232                .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2233                .0
2234        );
2235    }
2236
2237    #[test]
2238    fn test_validate_schema() {
2239        let config = ConformanceConfig {
2240            target_url: "http://localhost:3000".to_string(),
2241            ..Default::default()
2242        };
2243        let executor = NativeConformanceExecutor::new(config).unwrap();
2244        let headers = HashMap::new();
2245
2246        let schema = serde_json::json!({
2247            "type": "object",
2248            "properties": {
2249                "name": {"type": "string"},
2250                "age": {"type": "integer"}
2251            },
2252            "required": ["name"]
2253        });
2254
2255        let (passed, violations) = executor.validate_response(
2256            &CheckValidation::SchemaValidation {
2257                status_min: 200,
2258                status_max: 300,
2259                schema: schema.clone(),
2260            },
2261            200,
2262            &headers,
2263            r#"{"name": "test", "age": 25}"#,
2264        );
2265        assert!(passed);
2266        assert!(violations.is_empty());
2267
2268        // Missing required field
2269        let (passed, violations) = executor.validate_response(
2270            &CheckValidation::SchemaValidation {
2271                status_min: 200,
2272                status_max: 300,
2273                schema: schema.clone(),
2274            },
2275            200,
2276            &headers,
2277            r#"{"age": 25}"#,
2278        );
2279        assert!(!passed);
2280        assert!(!violations.is_empty());
2281        assert_eq!(violations[0].violation_type, "Required");
2282    }
2283
2284    #[test]
2285    fn test_validate_custom() {
2286        let config = ConformanceConfig {
2287            target_url: "http://localhost:3000".to_string(),
2288            ..Default::default()
2289        };
2290        let executor = NativeConformanceExecutor::new(config).unwrap();
2291        let mut headers = HashMap::new();
2292        headers.insert("content-type".to_string(), "application/json".to_string());
2293
2294        assert!(
2295            executor
2296                .validate_response(
2297                    &CheckValidation::Custom {
2298                        expected_status: 200,
2299                        expected_headers: vec![(
2300                            "content-type".to_string(),
2301                            "application/json".to_string(),
2302                        )],
2303                        expected_body_fields: vec![("name".to_string(), "string".to_string())],
2304                    },
2305                    200,
2306                    &headers,
2307                    r#"{"name": "test"}"#,
2308                )
2309                .0
2310        );
2311
2312        // Wrong status
2313        assert!(
2314            !executor
2315                .validate_response(
2316                    &CheckValidation::Custom {
2317                        expected_status: 200,
2318                        expected_headers: vec![],
2319                        expected_body_fields: vec![],
2320                    },
2321                    404,
2322                    &headers,
2323                    "",
2324                )
2325                .0
2326        );
2327    }
2328
2329    #[test]
2330    fn test_aggregate_results() {
2331        let results = vec![
2332            CheckResult {
2333                name: "check1".to_string(),
2334                passed: true,
2335                failure_detail: None,
2336                captured: None,
2337            },
2338            CheckResult {
2339                name: "check2".to_string(),
2340                passed: false,
2341                captured: None,
2342                failure_detail: Some(FailureDetail {
2343                    check: "check2".to_string(),
2344                    request: FailureRequest {
2345                        method: "GET".to_string(),
2346                        url: "http://example.com".to_string(),
2347                        headers: HashMap::new(),
2348                        body: String::new(),
2349                    },
2350                    response: FailureResponse {
2351                        status: 500,
2352                        headers: HashMap::new(),
2353                        body: "error".to_string(),
2354                    },
2355                    expected: "status >= 200 && status < 500".to_string(),
2356                    schema_violations: Vec::new(),
2357                }),
2358            },
2359        ];
2360
2361        let report = NativeConformanceExecutor::aggregate(results);
2362        let raw = report.raw_check_results();
2363        assert_eq!(raw.get("check1"), Some(&(1, 0)));
2364        assert_eq!(raw.get("check2"), Some(&(0, 1)));
2365    }
2366
2367    #[test]
2368    fn test_custom_check_building() {
2369        let config = ConformanceConfig {
2370            target_url: "http://localhost:3000".to_string(),
2371            ..Default::default()
2372        };
2373        let mut executor = NativeConformanceExecutor::new(config).unwrap();
2374
2375        let custom = CustomCheck {
2376            name: "custom:test-get".to_string(),
2377            path: "/api/test".to_string(),
2378            method: "GET".to_string(),
2379            expected_status: 200,
2380            body: None,
2381            expected_headers: HashMap::new(),
2382            expected_body_fields: vec![],
2383            headers: HashMap::new(),
2384            upload: None,
2385            uploads: vec![],
2386            extract: crate::conformance::custom::ExtractRules::default(),
2387            repeat: crate::conformance::custom::Repeat::default(),
2388        };
2389
2390        executor.add_custom_check(&custom);
2391        assert_eq!(executor.check_count(), 1);
2392        assert_eq!(executor.checks[0].name, "custom:test-get");
2393    }
2394
2395    #[test]
2396    fn test_openapi_schema_to_json_schema_object() {
2397        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2398
2399        let schema = Schema {
2400            schema_data: SchemaData::default(),
2401            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2402                required: vec!["name".to_string()],
2403                ..Default::default()
2404            })),
2405        };
2406
2407        let json = openapi_schema_to_json_schema(&schema);
2408        assert_eq!(json["type"], "object");
2409        assert_eq!(json["required"][0], "name");
2410    }
2411}