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