Skip to main content

mockforge_bench/conformance/
executor.rs

1//! Native Rust conformance test executor
2//!
3//! Replaces the k6-based execution path with direct HTTP requests via reqwest,
4//! producing the same `ConformanceReport` output. This removes the k6 binary
5//! dependency and enables API/SDK integration.
6
7use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18/// A field-level schema validation violation
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21    /// JSON path to the field that failed validation (e.g., "/name", "/items/0/age")
22    pub field_path: String,
23    /// Type of violation (e.g., "type", "required", "additionalProperties")
24    pub violation_type: String,
25    /// What the schema expected
26    pub expected: String,
27    /// What was actually found
28    pub actual: String,
29}
30
31/// A single conformance check to execute
32#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34    /// Check name (e.g., "param:path:string" or "param:path:string:/users/{id}")
35    pub name: String,
36    /// HTTP method
37    pub method: Method,
38    /// Relative path (appended to base_url)
39    pub path: String,
40    /// Request headers
41    pub headers: Vec<(String, String)>,
42    /// Optional request body
43    pub body: Option<CheckBody>,
44    /// How to validate the response
45    pub validation: CheckValidation,
46}
47
48/// Request body variants
49#[derive(Debug, Clone)]
50pub enum CheckBody {
51    /// JSON body
52    Json(serde_json::Value),
53    /// Form-urlencoded body
54    FormUrlencoded(Vec<(String, String)>),
55    /// Raw string body with content type
56    Raw {
57        content: String,
58        content_type: String,
59    },
60    /// Round 38 (#79) — multipart/form-data with file attachments.
61    /// Bytes are read from disk at request time so a YAML can name a
62    /// 50 MiB `.docx` without inflating the config. Use one entry per
63    /// part; the executor builds a single `reqwest::multipart::Form`
64    /// containing all of them so multi-file uploads land in one
65    /// request.
66    Multipart { parts: Vec<MultipartPart> },
67}
68
69/// Round 38 (#79) — one part of a multipart/form-data request body.
70#[derive(Debug, Clone)]
71pub struct MultipartPart {
72    /// Bytes that go on the wire as the part body.
73    pub bytes: Vec<u8>,
74    /// `Content-Type` for this part (e.g. `image/jpeg`,
75    /// `application/octet-stream`, `application/json`).
76    pub content_type: String,
77    /// Multipart form field name (the key on the receiving server).
78    pub field_name: String,
79    /// Filename announced in this part's `Content-Disposition` header.
80    pub filename: String,
81}
82
83/// Round 38 (#79) — chain metadata stored alongside a custom
84/// `ConformanceCheck`. Tells the executor how to substitute captured
85/// values into the request, how to capture values from the response,
86/// and how to repeat the check.
87#[derive(Debug, Clone, Default)]
88struct ChainMeta {
89    /// What to capture from the response on success. Empty when the
90    /// check declared no `extract` rules.
91    extract: super::custom::ExtractRules,
92    /// How many times to fire this check within one outer
93    /// `chain_iterations` pass, sequentially or in parallel.
94    repeat: super::custom::Repeat,
95}
96
97/// Round 38 (#79) — values captured during a chain run, keyed by the
98/// name the YAML asked for them under. `cookies` is kept separate so
99/// `${cookie:session}` and `${var:session}` substitute different
100/// things even if they happen to share a name.
101#[derive(Debug, Default, Clone)]
102struct ChainContext {
103    vars: HashMap<String, String>,
104    cookies: HashMap<String, String>,
105}
106
107impl ChainContext {
108    /// Substitute every `${var:NAME}`, `${cookie:NAME}`, and
109    /// `${header:NAME}` token in `text` with the corresponding
110    /// captured value. Unknown tokens are left in place verbatim so a
111    /// missing capture is obvious in the request log rather than
112    /// silently sending an empty string.
113    fn substitute(&self, text: &str) -> String {
114        let mut out = String::with_capacity(text.len());
115        let mut rest = text;
116        while let Some(start) = rest.find("${") {
117            out.push_str(&rest[..start]);
118            let after = &rest[start + 2..];
119            if let Some(end) = after.find('}') {
120                let token = &after[..end];
121                let replaced = if let Some(name) = token.strip_prefix("var:") {
122                    self.vars.get(name).cloned()
123                } else if let Some(name) = token.strip_prefix("cookie:") {
124                    self.cookies.get(name).cloned()
125                } else {
126                    // Round 38 — `${header:...}` aliases `${var:...}` for
127                    // captures recorded under the `headers` extraction
128                    // block. Same map, just a more readable token shape.
129                    token.strip_prefix("header:").and_then(|name| self.vars.get(name).cloned())
130                };
131                if let Some(value) = replaced {
132                    out.push_str(&value);
133                } else {
134                    // Preserve the original `${...}` so a missing
135                    // capture is visible in failure detail / logs.
136                    out.push_str("${");
137                    out.push_str(token);
138                    out.push('}');
139                }
140                rest = &after[end + 1..];
141            } else {
142                out.push_str("${");
143                rest = after;
144                break;
145            }
146        }
147        out.push_str(rest);
148        out
149    }
150}
151
152/// How to validate a conformance check response
153#[derive(Debug, Clone)]
154pub enum CheckValidation {
155    /// status >= min && status < max_exclusive
156    StatusRange { min: u16, max_exclusive: u16 },
157    /// status === code
158    ExactStatus(u16),
159    /// Schema validation: status in range + JSON body matches schema
160    SchemaValidation {
161        status_min: u16,
162        status_max: u16,
163        schema: serde_json::Value,
164    },
165    /// Custom: exact status + optional header regex + optional body field type checks
166    Custom {
167        expected_status: u16,
168        expected_headers: Vec<(String, String)>,
169        expected_body_fields: Vec<(String, String)>,
170    },
171}
172
173/// Progress event for SSE streaming
174#[derive(Debug, Clone, serde::Serialize)]
175#[serde(tag = "type")]
176pub enum ConformanceProgress {
177    /// Test run started
178    #[serde(rename = "started")]
179    Started { total_checks: usize },
180    /// A single check completed
181    #[serde(rename = "check_completed")]
182    CheckCompleted {
183        name: String,
184        passed: bool,
185        checks_done: usize,
186    },
187    /// All checks finished
188    #[serde(rename = "finished")]
189    Finished,
190    /// An error occurred
191    #[serde(rename = "error")]
192    Error { message: String },
193}
194
195/// Result of executing a single conformance check
196#[derive(Debug)]
197struct CheckResult {
198    name: String,
199    passed: bool,
200    failure_detail: Option<FailureDetail>,
201    /// Captured request/response data for --export-requests (always populated
202    /// when export_requests is enabled, regardless of pass/fail).
203    captured: Option<CapturedExchange>,
204}
205
206/// A captured HTTP exchange for the --export-requests feature
207#[derive(Debug, serde::Serialize)]
208struct CapturedExchange {
209    method: String,
210    url: String,
211    request_headers: HashMap<String, String>,
212    request_body: String,
213    response_status: u16,
214    response_headers: HashMap<String, String>,
215    response_body: String,
216}
217
218/// Native conformance executor using reqwest
219pub struct NativeConformanceExecutor {
220    config: ConformanceConfig,
221    client: Client,
222    checks: Vec<ConformanceCheck>,
223    /// Round 38 (#79) — per-check chain metadata. Keyed by the index
224    /// in `checks`. Entries are only present for custom checks that
225    /// declared a non-default `extract` block or `repeat` config in
226    /// the YAML.
227    chain_meta: HashMap<usize, ChainMeta>,
228    /// Round 38 (#79) — how many times to repeat the entire chain
229    /// of custom checks. Built-in spec checks always run once. Reset
230    /// to a fresh `ChainContext` at the start of each iteration so
231    /// captures from iteration K do not leak into K+1.
232    chain_iterations: u32,
233}
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: 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                // Round 45 (#79) — Srikanth on 0.3.189: showed a 13MB
940                // multipart upload getting cut mid-PEM at the 2000-byte
941                // cap. Bump to 64KB so a single PEM cert + headers fits
942                // inline and we still get the boundary chain for visual
943                // confirmation. Append a marker reporting the original
944                // size so a downstream consumer can tell a truncation
945                // from a full payload.
946                request_body: if req_body_str.len() > 65536 {
947                    format!(
948                        "{}\n<truncated at 65536 bytes; full body was {} bytes>",
949                        &req_body_str[..65536],
950                        req_body_str.len()
951                    )
952                } else {
953                    req_body_str
954                },
955                response_status: status,
956                response_headers: resp_headers.clone(),
957                response_body: if resp_body.len() > 65536 {
958                    format!(
959                        "{}\n<truncated at 65536 bytes; full body was {} bytes>",
960                        &resp_body[..65536],
961                        resp_body.len()
962                    )
963                } else {
964                    resp_body.clone()
965                },
966            })
967        } else {
968            None
969        };
970
971        let failure_detail = if !passed {
972            Some(FailureDetail {
973                check: check.name.clone(),
974                request: FailureRequest {
975                    method: check.method.to_string(),
976                    url,
977                    headers: check.headers.iter().cloned().collect(),
978                    body: match &check.body {
979                        Some(CheckBody::Json(v)) => v.to_string(),
980                        Some(CheckBody::FormUrlencoded(f)) => f
981                            .iter()
982                            .map(|(k, v)| format!("{}={}", k, v))
983                            .collect::<Vec<_>>()
984                            .join("&"),
985                        Some(CheckBody::Raw { content, .. }) => content.clone(),
986                        // Round 38 — same brief summary used by the
987                        // export path; keeps failure detail readable
988                        // without dumping multi-MB upload bytes.
989                        Some(CheckBody::Multipart { parts }) => {
990                            format!("<{} multipart file(s)>", parts.len())
991                        }
992                        None => String::new(),
993                    },
994                },
995                response: FailureResponse {
996                    status,
997                    headers: resp_headers,
998                    body: if resp_body.len() > 500 {
999                        format!("{}...", &resp_body[..500])
1000                    } else {
1001                        resp_body
1002                    },
1003                },
1004                expected: Self::describe_validation(&check.validation),
1005                schema_violations,
1006            })
1007        } else {
1008            None
1009        };
1010
1011        CheckResult {
1012            name: check.name.clone(),
1013            passed,
1014            failure_detail,
1015            captured,
1016        }
1017    }
1018
1019    /// Round 38 (#79) — execute a custom check with chain semantics:
1020    /// substitute captured values from `ctx` into the request, run
1021    /// the configured repeat (sequential or parallel), and pour any
1022    /// `extract`-matched response data back into `ctx` for the next
1023    /// check to use. The first response's data is the one that
1024    /// becomes visible to extract; under `parallel` repeat the
1025    /// captures from the racing requests would be ambiguous so we
1026    /// pull only from the first to finish.
1027    ///
1028    /// Returns a vector because a `repeat.count > 1` produces N
1029    /// `CheckResult`s, all of which need to flow through the aggregate.
1030    async fn execute_chain_check(
1031        &self,
1032        check: &ConformanceCheck,
1033        meta: &ChainMeta,
1034        ctx: &mut ChainContext,
1035    ) -> Vec<CheckResult> {
1036        let substituted = apply_chain_context(check, ctx);
1037        let count = meta.repeat.count.max(1);
1038        let results = match meta.repeat.mode {
1039            super::custom::RepeatMode::Sequential => {
1040                let mut out = Vec::with_capacity(count as usize);
1041                for _ in 0..count {
1042                    out.push(self.execute_check(&substituted).await);
1043                }
1044                out
1045            }
1046            super::custom::RepeatMode::Parallel => {
1047                let futs = (0..count).map(|_| self.execute_check(&substituted));
1048                futures::future::join_all(futs).await
1049            }
1050        };
1051
1052        // Extract from the first response. Under sequential repeat
1053        // this is the first request fired; under parallel it's the
1054        // first slot in the result array, which join_all preserves
1055        // by input order (not completion order), so the extraction
1056        // is at least deterministic.
1057        if !meta.extract.is_empty() {
1058            if let Some(first) = results.first() {
1059                if let Some(captured) = &first.captured {
1060                    extract_into_context(
1061                        &meta.extract,
1062                        &captured.response_headers,
1063                        &captured.response_body,
1064                        ctx,
1065                    );
1066                }
1067            }
1068        }
1069        results
1070    }
1071
1072    /// Validate a response against the check's validation rules.
1073    ///
1074    /// Returns `(passed, schema_violations)` where `schema_violations` contains
1075    /// field-level details when a `SchemaValidation` check fails.
1076    fn validate_response(
1077        &self,
1078        validation: &CheckValidation,
1079        status: u16,
1080        headers: &HashMap<String, String>,
1081        body: &str,
1082    ) -> (bool, Vec<SchemaViolation>) {
1083        match validation {
1084            CheckValidation::StatusRange { min, max_exclusive } => {
1085                (status >= *min && status < *max_exclusive, Vec::new())
1086            }
1087            CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1088            CheckValidation::SchemaValidation {
1089                status_min,
1090                status_max,
1091                schema,
1092            } => {
1093                if status < *status_min || status >= *status_max {
1094                    return (false, Vec::new());
1095                }
1096                // Parse body as JSON and validate against schema
1097                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1098                    return (
1099                        false,
1100                        vec![SchemaViolation {
1101                            field_path: "/".to_string(),
1102                            violation_type: "parse_error".to_string(),
1103                            expected: "valid JSON".to_string(),
1104                            actual: "non-JSON response body".to_string(),
1105                        }],
1106                    );
1107                };
1108                match jsonschema::validator_for(schema) {
1109                    Ok(validator) => {
1110                        let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1111                        if errors.is_empty() {
1112                            (true, Vec::new())
1113                        } else {
1114                            let violations = errors
1115                                .iter()
1116                                .map(|err| {
1117                                    let field_path = err.instance_path.to_string();
1118                                    let field_path = if field_path.is_empty() {
1119                                        "/".to_string()
1120                                    } else {
1121                                        field_path
1122                                    };
1123                                    SchemaViolation {
1124                                        field_path,
1125                                        violation_type: format!("{:?}", err.kind)
1126                                            .split('(')
1127                                            .next()
1128                                            .unwrap_or("unknown")
1129                                            .split('{')
1130                                            .next()
1131                                            .unwrap_or("unknown")
1132                                            .split(' ')
1133                                            .next()
1134                                            .unwrap_or("unknown")
1135                                            .trim()
1136                                            .to_string(),
1137                                        expected: {
1138                                            // Extract human-readable expected value from the error.
1139                                            // The schema_path is like "/properties/field/type" —
1140                                            // extract the last meaningful segment instead.
1141                                            let schema_str = format!("{}", err.schema_path);
1142                                            match &err.kind {
1143                                                jsonschema::error::ValidationErrorKind::Type { kind } => {
1144                                                    format!("type: {:?}", kind)
1145                                                }
1146                                                jsonschema::error::ValidationErrorKind::Required { property } => {
1147                                                    format!("required field: {}", property)
1148                                                }
1149                                                _ => {
1150                                                    // For other kinds, use last path segment
1151                                                    schema_str
1152                                                        .rsplit('/')
1153                                                        .next()
1154                                                        .unwrap_or(&schema_str)
1155                                                        .to_string()
1156                                                }
1157                                            }
1158                                        },
1159                                        actual: format!("{}", err),
1160                                    }
1161                                })
1162                                .collect();
1163                            (false, violations)
1164                        }
1165                    }
1166                    Err(_) => {
1167                        // Schema compilation failed — fall back to is_valid behavior
1168                        (
1169                            false,
1170                            vec![SchemaViolation {
1171                                field_path: "/".to_string(),
1172                                violation_type: "schema_compile_error".to_string(),
1173                                expected: "valid JSON schema".to_string(),
1174                                actual: "schema failed to compile".to_string(),
1175                            }],
1176                        )
1177                    }
1178                }
1179            }
1180            CheckValidation::Custom {
1181                expected_status,
1182                expected_headers,
1183                expected_body_fields,
1184            } => {
1185                if status != *expected_status {
1186                    return (false, Vec::new());
1187                }
1188                // Check headers with regex
1189                for (header_name, pattern) in expected_headers {
1190                    let header_val = headers
1191                        .get(header_name)
1192                        .or_else(|| headers.get(&header_name.to_lowercase()))
1193                        .map(|s| s.as_str())
1194                        .unwrap_or("");
1195                    if let Ok(re) = regex::Regex::new(pattern) {
1196                        if !re.is_match(header_val) {
1197                            return (false, Vec::new());
1198                        }
1199                    }
1200                }
1201                // Check body field types
1202                if !expected_body_fields.is_empty() {
1203                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1204                        return (false, Vec::new());
1205                    };
1206                    for (field_name, field_type) in expected_body_fields {
1207                        let field = &body_value[field_name];
1208                        let ok = match field_type.as_str() {
1209                            "string" => field.is_string(),
1210                            "integer" => field.is_i64() || field.is_u64(),
1211                            "number" => field.is_number(),
1212                            "boolean" => field.is_boolean(),
1213                            "array" => field.is_array(),
1214                            "object" => field.is_object(),
1215                            _ => !field.is_null(),
1216                        };
1217                        if !ok {
1218                            return (false, Vec::new());
1219                        }
1220                    }
1221                }
1222                (true, Vec::new())
1223            }
1224        }
1225    }
1226
1227    /// Human-readable validation description for failure reports
1228    fn describe_validation(validation: &CheckValidation) -> String {
1229        match validation {
1230            CheckValidation::StatusRange { min, max_exclusive } => {
1231                format!("status >= {} && status < {}", min, max_exclusive)
1232            }
1233            CheckValidation::ExactStatus(code) => format!("status === {}", code),
1234            CheckValidation::SchemaValidation {
1235                status_min,
1236                status_max,
1237                ..
1238            } => {
1239                format!("status >= {} && status < {} + schema validation", status_min, status_max)
1240            }
1241            CheckValidation::Custom {
1242                expected_status, ..
1243            } => {
1244                format!("status === {}", expected_status)
1245            }
1246        }
1247    }
1248
1249    /// Aggregate check results into a `ConformanceReport`
1250    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1251        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1252        let mut failure_details = Vec::new();
1253
1254        for result in results {
1255            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1256            if result.passed {
1257                entry.0 += 1;
1258            } else {
1259                entry.1 += 1;
1260            }
1261            if let Some(detail) = result.failure_detail {
1262                failure_details.push(detail);
1263            }
1264        }
1265
1266        ConformanceReport::from_results(check_results, failure_details)
1267    }
1268
1269    // --- Helper methods ---
1270
1271    /// Build a spec-driven check from an annotated operation and feature
1272    fn build_spec_check(
1273        &self,
1274        check_name: &str,
1275        op: &AnnotatedOperation,
1276        feature: &ConformanceFeature,
1277    ) -> ConformanceCheck {
1278        // Build URL path with parameters substituted
1279        let mut url_path = op.path.clone();
1280        for (name, value) in &op.path_params {
1281            url_path = url_path.replace(&format!("{{{}}}", name), value);
1282        }
1283        // Append query params
1284        if !op.query_params.is_empty() {
1285            let qs: Vec<String> =
1286                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1287            url_path = format!("{}?{}", url_path, qs.join("&"));
1288        }
1289
1290        // Build effective headers
1291        let mut effective_headers = self.effective_headers(&op.header_params);
1292
1293        // For non-default response codes, add mock server header
1294        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1295            let code = match feature {
1296                ConformanceFeature::Response400 => "400",
1297                ConformanceFeature::Response404 => "404",
1298                _ => unreachable!(),
1299            };
1300            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1301        }
1302
1303        // Inject auth headers for security checks or secured endpoints
1304        let needs_auth = matches!(
1305            feature,
1306            ConformanceFeature::SecurityBearer
1307                | ConformanceFeature::SecurityBasic
1308                | ConformanceFeature::SecurityApiKey
1309        ) || !op.security_schemes.is_empty();
1310
1311        if needs_auth {
1312            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1313        }
1314
1315        // Determine method
1316        let method = match op.method.as_str() {
1317            "GET" => Method::GET,
1318            "POST" => Method::POST,
1319            "PUT" => Method::PUT,
1320            "PATCH" => Method::PATCH,
1321            "DELETE" => Method::DELETE,
1322            "HEAD" => Method::HEAD,
1323            "OPTIONS" => Method::OPTIONS,
1324            _ => Method::GET,
1325        };
1326
1327        // Determine body
1328        let body = match method {
1329            Method::POST | Method::PUT | Method::PATCH => {
1330                if let Some(sample) = &op.sample_body {
1331                    // Add Content-Type if not present
1332                    let content_type =
1333                        op.request_body_content_type.as_deref().unwrap_or("application/json");
1334                    if !effective_headers
1335                        .iter()
1336                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1337                    {
1338                        effective_headers
1339                            .push(("Content-Type".to_string(), content_type.to_string()));
1340                    }
1341                    match content_type {
1342                        "application/x-www-form-urlencoded" => {
1343                            // Parse as form fields
1344                            let fields: Vec<(String, String)> = serde_json::from_str::<
1345                                serde_json::Value,
1346                            >(
1347                                sample
1348                            )
1349                            .ok()
1350                            .and_then(|v| {
1351                                v.as_object().map(|obj| {
1352                                    obj.iter()
1353                                        .map(|(k, v)| {
1354                                            (k.clone(), v.as_str().unwrap_or("").to_string())
1355                                        })
1356                                        .collect()
1357                                })
1358                            })
1359                            .unwrap_or_default();
1360                            Some(CheckBody::FormUrlencoded(fields))
1361                        }
1362                        _ => {
1363                            // Try JSON, fall back to raw
1364                            match serde_json::from_str::<serde_json::Value>(sample) {
1365                                Ok(v) => Some(CheckBody::Json(v)),
1366                                Err(_) => Some(CheckBody::Raw {
1367                                    content: sample.clone(),
1368                                    content_type: content_type.to_string(),
1369                                }),
1370                            }
1371                        }
1372                    }
1373                } else {
1374                    None
1375                }
1376            }
1377            _ => None,
1378        };
1379
1380        // Determine validation
1381        let validation = self.determine_validation(feature, op);
1382
1383        ConformanceCheck {
1384            name: check_name.to_string(),
1385            method,
1386            path: url_path,
1387            headers: effective_headers,
1388            body,
1389            validation,
1390        }
1391    }
1392
1393    /// Determine validation strategy based on the conformance feature
1394    fn determine_validation(
1395        &self,
1396        feature: &ConformanceFeature,
1397        op: &AnnotatedOperation,
1398    ) -> CheckValidation {
1399        match feature {
1400            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1401            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1402            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1403            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1404            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1405            ConformanceFeature::SecurityBearer
1406            | ConformanceFeature::SecurityBasic
1407            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1408                min: 200,
1409                max_exclusive: 400,
1410            },
1411            ConformanceFeature::ResponseValidation => {
1412                if let Some(schema) = &op.response_schema {
1413                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
1414                    let schema_json = openapi_schema_to_json_schema(schema);
1415                    CheckValidation::SchemaValidation {
1416                        status_min: 200,
1417                        status_max: 500,
1418                        schema: schema_json,
1419                    }
1420                } else {
1421                    CheckValidation::StatusRange {
1422                        min: 200,
1423                        max_exclusive: 500,
1424                    }
1425                }
1426            }
1427            _ => CheckValidation::StatusRange {
1428                min: 200,
1429                max_exclusive: 500,
1430            },
1431        }
1432    }
1433
1434    /// Add a simple GET reference check with default status range validation
1435    fn add_ref_get(&mut self, name: &str, path: &str) {
1436        self.checks.push(ConformanceCheck {
1437            name: name.to_string(),
1438            method: Method::GET,
1439            path: path.to_string(),
1440            headers: self.custom_headers_only(),
1441            body: None,
1442            validation: CheckValidation::StatusRange {
1443                min: 200,
1444                max_exclusive: 500,
1445            },
1446        });
1447    }
1448
1449    /// Merge spec-derived headers with custom headers (custom overrides spec)
1450    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1451        let mut headers = Vec::new();
1452        for (k, v) in spec_headers {
1453            // Skip if custom headers override this one
1454            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1455                continue;
1456            }
1457            headers.push((k.clone(), v.clone()));
1458        }
1459        // Append custom headers
1460        headers.extend(self.config.custom_headers.clone());
1461        headers
1462    }
1463
1464    /// Merge provided headers with custom headers
1465    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1466        for (k, v) in &self.config.custom_headers {
1467            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1468                headers.push((k.clone(), v.clone()));
1469            }
1470        }
1471        headers
1472    }
1473
1474    /// Return only custom headers (for checks that don't have spec-derived headers)
1475    fn custom_headers_only(&self) -> Vec<(String, String)> {
1476        self.config.custom_headers.clone()
1477    }
1478
1479    /// Inject security headers based on resolved security schemes.
1480    /// If the user provides a Cookie header via --conformance-header, skip automatic
1481    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1482    fn inject_security_headers(
1483        &self,
1484        schemes: &[SecuritySchemeInfo],
1485        headers: &mut Vec<(String, String)>,
1486    ) {
1487        // If user provides Cookie header, they're using session-based auth — skip auto auth
1488        let has_cookie_auth =
1489            self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1490        let mut to_add: Vec<(String, String)> = Vec::new();
1491
1492        for scheme in schemes {
1493            match scheme {
1494                SecuritySchemeInfo::Bearer => {
1495                    if !has_cookie_auth
1496                        && !Self::header_present(
1497                            "Authorization",
1498                            headers,
1499                            &self.config.custom_headers,
1500                        )
1501                    {
1502                        to_add.push((
1503                            "Authorization".to_string(),
1504                            "Bearer mockforge-conformance-test-token".to_string(),
1505                        ));
1506                    }
1507                }
1508                SecuritySchemeInfo::Basic => {
1509                    if !has_cookie_auth
1510                        && !Self::header_present(
1511                            "Authorization",
1512                            headers,
1513                            &self.config.custom_headers,
1514                        )
1515                    {
1516                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1517                        use base64::Engine;
1518                        let encoded =
1519                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1520                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1521                    }
1522                }
1523                SecuritySchemeInfo::ApiKey { location, name } => match location {
1524                    ApiKeyLocation::Header => {
1525                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1526                            let key = self
1527                                .config
1528                                .api_key
1529                                .as_deref()
1530                                .unwrap_or("mockforge-conformance-test-key");
1531                            to_add.push((name.clone(), key.to_string()));
1532                        }
1533                    }
1534                    ApiKeyLocation::Cookie => {
1535                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1536                            to_add.push((
1537                                "Cookie".to_string(),
1538                                format!("{}=mockforge-conformance-test-session", name),
1539                            ));
1540                        }
1541                    }
1542                    ApiKeyLocation::Query => {
1543                        // Handled in URL, not headers
1544                    }
1545                },
1546            }
1547        }
1548
1549        headers.extend(to_add);
1550    }
1551
1552    /// Check if a header name is present in either the existing headers or custom headers
1553    fn header_present(
1554        name: &str,
1555        headers: &[(String, String)],
1556        custom_headers: &[(String, String)],
1557    ) -> bool {
1558        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1559            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1560    }
1561
1562    /// Add a custom check from YAML config
1563    fn add_custom_check(&mut self, check: &CustomCheck) {
1564        let method = match check.method.to_uppercase().as_str() {
1565            "GET" => Method::GET,
1566            "POST" => Method::POST,
1567            "PUT" => Method::PUT,
1568            "PATCH" => Method::PATCH,
1569            "DELETE" => Method::DELETE,
1570            "HEAD" => Method::HEAD,
1571            "OPTIONS" => Method::OPTIONS,
1572            _ => Method::GET,
1573        };
1574
1575        // Build headers
1576        let mut headers: Vec<(String, String)> =
1577            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1578        // Add global custom headers (check-specific take priority)
1579        for (k, v) in &self.config.custom_headers {
1580            if !check.headers.contains_key(k) {
1581                headers.push((k.clone(), v.clone()));
1582            }
1583        }
1584        // Add Content-Type for JSON body if not present
1585        if check.body.is_some()
1586            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1587        {
1588            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1589        }
1590
1591        // Body. Round 38 (#79) — `body` wins over `upload` /
1592        // `uploads` when both are set; the YAML is a misconfiguration
1593        // and we warn rather than silently picking one. When `body`
1594        // is absent and `upload` / `uploads` is set, every file is
1595        // read off disk at construction time and folded into a
1596        // `CheckBody::Multipart`. The file read is *eager* (not at
1597        // request time) so the executor's send path stays purely
1598        // synchronous to the network and a missing file is surfaced
1599        // here, not mid-run.
1600        let upload_specs: Vec<&super::custom::UploadFile> =
1601            check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1602        let body = if check.body.is_some() {
1603            if !upload_specs.is_empty() {
1604                eprintln!(
1605                    "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1606                    check.name
1607                );
1608            }
1609            check.body.as_ref().and_then(|b| {
1610                serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1611            })
1612        } else if !upload_specs.is_empty() {
1613            let mut parts = Vec::with_capacity(upload_specs.len());
1614            for spec in upload_specs {
1615                match std::fs::read(&spec.path) {
1616                    Ok(bytes) => {
1617                        let filename = spec.filename.clone().unwrap_or_else(|| {
1618                            std::path::Path::new(&spec.path)
1619                                .file_name()
1620                                .and_then(|n| n.to_str())
1621                                .unwrap_or("upload.bin")
1622                                .to_string()
1623                        });
1624                        parts.push(MultipartPart {
1625                            bytes,
1626                            content_type: spec.content_type.clone(),
1627                            field_name: spec.field_name.clone(),
1628                            filename,
1629                        });
1630                    }
1631                    Err(e) => {
1632                        eprintln!(
1633                            "warning: custom check '{}' could not read upload '{}': {}",
1634                            check.name, spec.path, e
1635                        );
1636                    }
1637                }
1638            }
1639            if parts.is_empty() {
1640                None
1641            } else {
1642                Some(CheckBody::Multipart { parts })
1643            }
1644        } else {
1645            None
1646        };
1647
1648        // Build expected headers for validation
1649        let expected_headers: Vec<(String, String)> =
1650            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1651
1652        // Build expected body fields
1653        let expected_body_fields: Vec<(String, String)> = check
1654            .expected_body_fields
1655            .iter()
1656            .map(|f| (f.name.clone(), f.field_type.clone()))
1657            .collect();
1658
1659        // Round 38 (#79) — register chain metadata when the YAML
1660        // asked for capture / replay. Only non-default extract or
1661        // repeat blocks reach the chain map so the steady-state
1662        // (single-shot custom check) still skips the chain path.
1663        let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1664        let next_index = self.checks.len();
1665        if needs_chain {
1666            self.chain_meta.insert(
1667                next_index,
1668                ChainMeta {
1669                    extract: check.extract.clone(),
1670                    repeat: check.repeat.clone(),
1671                },
1672            );
1673        }
1674
1675        // Primary status check
1676        self.checks.push(ConformanceCheck {
1677            name: check.name.clone(),
1678            method,
1679            path: check.path.clone(),
1680            headers,
1681            body,
1682            validation: CheckValidation::Custom {
1683                expected_status: check.expected_status,
1684                expected_headers,
1685                expected_body_fields,
1686            },
1687        });
1688    }
1689}
1690
1691/// Round 38 (#79) — return a clone of `check` with every
1692/// `${var:...}` / `${cookie:...}` / `${header:...}` token in `path`,
1693/// header values, and string bodies replaced by the corresponding
1694/// captured value. Free function (not `&self`) so unit tests can
1695/// drive it directly. JSON bodies are substituted by walking each
1696/// string leaf; non-string JSON values are left untouched.
1697fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1698    let path = ctx.substitute(&check.path);
1699    let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1700    let body = check.body.as_ref().map(|b| match b {
1701        CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1702        CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1703            fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1704        ),
1705        CheckBody::Raw {
1706            content,
1707            content_type,
1708        } => CheckBody::Raw {
1709            content: ctx.substitute(content),
1710            content_type: content_type.clone(),
1711        },
1712        // Multipart bytes are not text and are not template-targets;
1713        // pass-through unchanged so binary uploads are byte-identical
1714        // across iterations.
1715        CheckBody::Multipart { parts } => CheckBody::Multipart {
1716            parts: parts.clone(),
1717        },
1718    });
1719    ConformanceCheck {
1720        name: check.name.clone(),
1721        method: check.method.clone(),
1722        path,
1723        headers,
1724        body,
1725        validation: check.validation.clone(),
1726    }
1727}
1728
1729fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1730    use serde_json::Value;
1731    match value {
1732        Value::String(s) => Value::String(ctx.substitute(s)),
1733        Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1734        Value::Object(obj) => Value::Object(
1735            obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1736        ),
1737        other => other.clone(),
1738    }
1739}
1740
1741/// Round 38 (#79) — read captured cookies / headers / body fields off
1742/// a response and store them in the chain context for subsequent
1743/// requests to reference. Unknown extractions (e.g. a cookie name the
1744/// server didn't set) are silently skipped so a partially-met
1745/// extract block doesn't fail the whole chain.
1746fn extract_into_context(
1747    rules: &super::custom::ExtractRules,
1748    response_headers: &HashMap<String, String>,
1749    response_body: &str,
1750    ctx: &mut ChainContext,
1751) {
1752    // Cookies: every Set-Cookie header is parsed for `name=value`
1753    // (the cookie's own attributes after the value are dropped).
1754    // Multi-Set-Cookie responses (different `Set-Cookie` values
1755    // each with the same header name) are coalesced into one
1756    // comma-separated string when reqwest collects the headers,
1757    // so we split on commas and try each candidate.
1758    for cookie_name in &rules.cookies {
1759        if let Some(raw) = response_headers
1760            .iter()
1761            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1762            .map(|(_, v)| v)
1763        {
1764            // Each Set-Cookie entry looks like `name=value; attr=...`.
1765            // Multiple cookies in one header are comma-separated.
1766            for entry in raw.split(',') {
1767                let head = entry.split(';').next().unwrap_or(entry).trim();
1768                if let Some((name, value)) = head.split_once('=') {
1769                    if name.trim().eq_ignore_ascii_case(cookie_name) {
1770                        ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1771                        break;
1772                    }
1773                }
1774            }
1775        }
1776    }
1777    // Headers: case-insensitive lookup.
1778    for (var_name, header_name) in &rules.headers {
1779        if let Some((_, value)) =
1780            response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1781        {
1782            ctx.vars.insert(var_name.clone(), value.clone());
1783        }
1784    }
1785    // Body fields via simple dotted lookup. Empty / non-JSON bodies
1786    // simply contribute nothing rather than failing the chain.
1787    if !rules.body_fields.is_empty() {
1788        if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1789            for (var_name, field_path) in &rules.body_fields {
1790                if let Some(value) = lookup_json_path(&json, field_path) {
1791                    let stringified = match value {
1792                        serde_json::Value::String(s) => s.clone(),
1793                        other => other.to_string(),
1794                    };
1795                    ctx.vars.insert(var_name.clone(), stringified);
1796                }
1797            }
1798        }
1799    }
1800}
1801
1802fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1803    let mut current = value;
1804    for segment in path.split('.') {
1805        current = match current {
1806            serde_json::Value::Object(obj) => obj.get(segment)?,
1807            _ => return None,
1808        };
1809    }
1810    Some(current)
1811}
1812
1813/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1814/// suitable for use with the `jsonschema` crate.
1815fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1816    use openapiv3::{SchemaKind, Type};
1817
1818    match &schema.schema_kind {
1819        SchemaKind::Type(Type::Object(obj)) => {
1820            let mut props = serde_json::Map::new();
1821            for (name, prop_ref) in &obj.properties {
1822                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1823                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1824                }
1825            }
1826            let mut schema_obj = serde_json::json!({
1827                "type": "object",
1828                "properties": props,
1829            });
1830            if !obj.required.is_empty() {
1831                schema_obj["required"] = serde_json::Value::Array(
1832                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1833                );
1834            }
1835            schema_obj
1836        }
1837        SchemaKind::Type(Type::Array(arr)) => {
1838            let mut schema_obj = serde_json::json!({"type": "array"});
1839            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1840                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1841            }
1842            schema_obj
1843        }
1844        SchemaKind::Type(Type::String(s)) => {
1845            let mut obj = serde_json::json!({"type": "string"});
1846            if let Some(min) = s.min_length {
1847                obj["minLength"] = serde_json::json!(min);
1848            }
1849            if let Some(max) = s.max_length {
1850                obj["maxLength"] = serde_json::json!(max);
1851            }
1852            if let Some(pattern) = &s.pattern {
1853                obj["pattern"] = serde_json::json!(pattern);
1854            }
1855            if !s.enumeration.is_empty() {
1856                obj["enum"] = serde_json::Value::Array(
1857                    s.enumeration
1858                        .iter()
1859                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1860                        .collect(),
1861                );
1862            }
1863            obj
1864        }
1865        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1866        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1867        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1868        _ => serde_json::json!({}),
1869    }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874    use super::*;
1875
1876    /// Round 38 (#79) — substitution replaces `${var:...}`,
1877    /// `${cookie:...}`, and `${header:...}` tokens with the matching
1878    /// captured value. Unknown tokens are preserved verbatim so a
1879    /// missing capture is visible in the request log.
1880    #[test]
1881    fn chain_context_substitutes_all_token_kinds() {
1882        let mut ctx = ChainContext::default();
1883        ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1884        ctx.vars.insert("trace".to_string(), "xyz".to_string());
1885        ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1886        assert_eq!(ctx.substitute("plain"), "plain");
1887        assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1888        assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1889        // header: aliases var: so the same map is used.
1890        assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1891        // Unknown name preserved verbatim (no silent empty string).
1892        assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1893    }
1894
1895    /// Round 38 — extraction pulls cookies, headers, and body fields
1896    /// off a response and stores them in the chain context under the
1897    /// caller-named keys.
1898    #[test]
1899    fn extract_into_context_captures_cookies_headers_and_body_fields() {
1900        let mut headers = HashMap::new();
1901        headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
1902        headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
1903        let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
1904        let mut rules = super::super::custom::ExtractRules::default();
1905        rules.cookies.push("session".to_string());
1906        rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1907        rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
1908        rules.body_fields.insert("user_id".to_string(), "id".to_string());
1909        let mut ctx = ChainContext::default();
1910        extract_into_context(&rules, &headers, body, &mut ctx);
1911        assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
1912        assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
1913        assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
1914        // Numeric body field stringifies to "42".
1915        assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
1916    }
1917
1918    /// Round 38 — a missing cookie name doesn't poison subsequent
1919    /// extractions; the cookie pull silently no-ops and the header /
1920    /// body extractions still happen.
1921    #[test]
1922    fn extract_into_context_skips_missing_captures_gracefully() {
1923        let mut headers = HashMap::new();
1924        headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
1925        let body = r#"{"id":1}"#;
1926        let mut rules = super::super::custom::ExtractRules::default();
1927        rules.cookies.push("never-set".to_string());
1928        rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1929        let mut ctx = ChainContext::default();
1930        extract_into_context(&rules, &headers, body, &mut ctx);
1931        assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
1932        assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
1933    }
1934
1935    /// Round 38 — substitution into a ConformanceCheck flows through
1936    /// path, headers, raw bodies, JSON bodies, and form-urlencoded
1937    /// fields. Multipart bodies are pass-through (binary uploads
1938    /// shouldn't be touched by string templating).
1939    #[test]
1940    fn apply_chain_context_substitutes_path_headers_and_body() {
1941        let mut ctx = ChainContext::default();
1942        ctx.vars.insert("user".to_string(), "alice".to_string());
1943        ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
1944        let check = ConformanceCheck {
1945            name: "custom:t".into(),
1946            method: Method::POST,
1947            path: "/users/${var:user}".into(),
1948            headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
1949            body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
1950            validation: CheckValidation::ExactStatus(200),
1951        };
1952        let substituted = apply_chain_context(&check, &ctx);
1953        assert_eq!(substituted.path, "/users/alice");
1954        assert_eq!(substituted.headers[0].1, "sid=deadbeef");
1955        match substituted.body {
1956            Some(CheckBody::Json(v)) => {
1957                assert_eq!(v["by"], "alice");
1958                assert_eq!(v["ts"], 1);
1959            }
1960            _ => panic!("expected json body"),
1961        }
1962    }
1963
1964    #[test]
1965    fn test_reference_check_count() {
1966        let config = ConformanceConfig {
1967            target_url: "http://localhost:3000".to_string(),
1968            ..Default::default()
1969        };
1970        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1971        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1972        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1973        assert_eq!(executor.check_count(), 47);
1974    }
1975
1976    #[test]
1977    fn with_custom_checks_from_config_appends() {
1978        // Construct an inline CustomConformanceConfig and verify both
1979        // that the checks are appended on top of reference-checks
1980        // mode and that the filter regex (when present) drops
1981        // non-matching entries.
1982        let custom_yaml = r#"
1983custom_checks:
1984  - name: "custom:health"
1985    path: /health
1986    method: GET
1987    expected_status: 200
1988  - name: "custom:create"
1989    path: /widgets
1990    method: POST
1991    expected_status: 201
1992"#;
1993        let parsed: CustomConformanceConfig =
1994            serde_yaml::from_str(custom_yaml).expect("YAML parses");
1995        assert_eq!(parsed.custom_checks.len(), 2);
1996
1997        let base = ConformanceConfig {
1998            target_url: "http://localhost:3000".to_string(),
1999            ..Default::default()
2000        };
2001        let executor = NativeConformanceExecutor::new(base)
2002            .unwrap()
2003            .with_reference_checks()
2004            .with_custom_checks_from_config(parsed)
2005            .expect("custom checks load");
2006        // 47 reference checks + 2 custom = 49.
2007        assert_eq!(executor.check_count(), 49);
2008    }
2009
2010    #[test]
2011    fn with_custom_checks_from_config_respects_filter() {
2012        // custom_filter is regex-based; only matching entries should
2013        // make it onto the executor.
2014        let custom_yaml = r#"
2015custom_checks:
2016  - name: "custom:health"
2017    path: /health
2018    method: GET
2019    expected_status: 200
2020  - name: "custom:create-widget"
2021    path: /widgets
2022    method: POST
2023    expected_status: 201
2024"#;
2025        let parsed: CustomConformanceConfig =
2026            serde_yaml::from_str(custom_yaml).expect("YAML parses");
2027
2028        let base = ConformanceConfig {
2029            target_url: "http://localhost:3000".to_string(),
2030            // Reference checks would add 47; turn them off so the
2031            // count is purely the custom set after filtering.
2032            categories: Some(vec!["no_such_category".to_string()]),
2033            custom_filter: Some("health".to_string()),
2034            ..Default::default()
2035        };
2036        let executor = NativeConformanceExecutor::new(base)
2037            .unwrap()
2038            .with_reference_checks()
2039            .with_custom_checks_from_config(parsed)
2040            .expect("custom checks load");
2041        // categories filter drops all reference checks; custom_filter
2042        // keeps the one entry whose name matches /health/.
2043        assert_eq!(executor.check_count(), 1);
2044    }
2045
2046    #[test]
2047    fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2048        let parsed: CustomConformanceConfig =
2049            serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2050        let base = ConformanceConfig {
2051            target_url: "http://localhost:3000".to_string(),
2052            custom_filter: Some("[unclosed".to_string()),
2053            ..Default::default()
2054        };
2055        let result = NativeConformanceExecutor::new(base)
2056            .unwrap()
2057            .with_reference_checks()
2058            .with_custom_checks_from_config(parsed);
2059        assert!(result.is_err(), "bad regex should bubble up as BenchError");
2060    }
2061
2062    #[test]
2063    fn test_reference_checks_with_category_filter() {
2064        let config = ConformanceConfig {
2065            target_url: "http://localhost:3000".to_string(),
2066            categories: Some(vec!["Parameters".to_string()]),
2067            ..Default::default()
2068        };
2069        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2070        assert_eq!(executor.check_count(), 7);
2071    }
2072
2073    #[test]
2074    fn test_validate_status_range() {
2075        let config = ConformanceConfig {
2076            target_url: "http://localhost:3000".to_string(),
2077            ..Default::default()
2078        };
2079        let executor = NativeConformanceExecutor::new(config).unwrap();
2080        let headers = HashMap::new();
2081
2082        assert!(
2083            executor
2084                .validate_response(
2085                    &CheckValidation::StatusRange {
2086                        min: 200,
2087                        max_exclusive: 500,
2088                    },
2089                    200,
2090                    &headers,
2091                    "",
2092                )
2093                .0
2094        );
2095        assert!(
2096            executor
2097                .validate_response(
2098                    &CheckValidation::StatusRange {
2099                        min: 200,
2100                        max_exclusive: 500,
2101                    },
2102                    404,
2103                    &headers,
2104                    "",
2105                )
2106                .0
2107        );
2108        assert!(
2109            !executor
2110                .validate_response(
2111                    &CheckValidation::StatusRange {
2112                        min: 200,
2113                        max_exclusive: 500,
2114                    },
2115                    500,
2116                    &headers,
2117                    "",
2118                )
2119                .0
2120        );
2121    }
2122
2123    #[test]
2124    fn test_validate_exact_status() {
2125        let config = ConformanceConfig {
2126            target_url: "http://localhost:3000".to_string(),
2127            ..Default::default()
2128        };
2129        let executor = NativeConformanceExecutor::new(config).unwrap();
2130        let headers = HashMap::new();
2131
2132        assert!(
2133            executor
2134                .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2135                .0
2136        );
2137        assert!(
2138            !executor
2139                .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2140                .0
2141        );
2142    }
2143
2144    #[test]
2145    fn test_validate_schema() {
2146        let config = ConformanceConfig {
2147            target_url: "http://localhost:3000".to_string(),
2148            ..Default::default()
2149        };
2150        let executor = NativeConformanceExecutor::new(config).unwrap();
2151        let headers = HashMap::new();
2152
2153        let schema = serde_json::json!({
2154            "type": "object",
2155            "properties": {
2156                "name": {"type": "string"},
2157                "age": {"type": "integer"}
2158            },
2159            "required": ["name"]
2160        });
2161
2162        let (passed, violations) = executor.validate_response(
2163            &CheckValidation::SchemaValidation {
2164                status_min: 200,
2165                status_max: 300,
2166                schema: schema.clone(),
2167            },
2168            200,
2169            &headers,
2170            r#"{"name": "test", "age": 25}"#,
2171        );
2172        assert!(passed);
2173        assert!(violations.is_empty());
2174
2175        // Missing required field
2176        let (passed, violations) = executor.validate_response(
2177            &CheckValidation::SchemaValidation {
2178                status_min: 200,
2179                status_max: 300,
2180                schema: schema.clone(),
2181            },
2182            200,
2183            &headers,
2184            r#"{"age": 25}"#,
2185        );
2186        assert!(!passed);
2187        assert!(!violations.is_empty());
2188        assert_eq!(violations[0].violation_type, "Required");
2189    }
2190
2191    #[test]
2192    fn test_validate_custom() {
2193        let config = ConformanceConfig {
2194            target_url: "http://localhost:3000".to_string(),
2195            ..Default::default()
2196        };
2197        let executor = NativeConformanceExecutor::new(config).unwrap();
2198        let mut headers = HashMap::new();
2199        headers.insert("content-type".to_string(), "application/json".to_string());
2200
2201        assert!(
2202            executor
2203                .validate_response(
2204                    &CheckValidation::Custom {
2205                        expected_status: 200,
2206                        expected_headers: vec![(
2207                            "content-type".to_string(),
2208                            "application/json".to_string(),
2209                        )],
2210                        expected_body_fields: vec![("name".to_string(), "string".to_string())],
2211                    },
2212                    200,
2213                    &headers,
2214                    r#"{"name": "test"}"#,
2215                )
2216                .0
2217        );
2218
2219        // Wrong status
2220        assert!(
2221            !executor
2222                .validate_response(
2223                    &CheckValidation::Custom {
2224                        expected_status: 200,
2225                        expected_headers: vec![],
2226                        expected_body_fields: vec![],
2227                    },
2228                    404,
2229                    &headers,
2230                    "",
2231                )
2232                .0
2233        );
2234    }
2235
2236    #[test]
2237    fn test_aggregate_results() {
2238        let results = vec![
2239            CheckResult {
2240                name: "check1".to_string(),
2241                passed: true,
2242                failure_detail: None,
2243                captured: None,
2244            },
2245            CheckResult {
2246                name: "check2".to_string(),
2247                passed: false,
2248                captured: None,
2249                failure_detail: Some(FailureDetail {
2250                    check: "check2".to_string(),
2251                    request: FailureRequest {
2252                        method: "GET".to_string(),
2253                        url: "http://example.com".to_string(),
2254                        headers: HashMap::new(),
2255                        body: String::new(),
2256                    },
2257                    response: FailureResponse {
2258                        status: 500,
2259                        headers: HashMap::new(),
2260                        body: "error".to_string(),
2261                    },
2262                    expected: "status >= 200 && status < 500".to_string(),
2263                    schema_violations: Vec::new(),
2264                }),
2265            },
2266        ];
2267
2268        let report = NativeConformanceExecutor::aggregate(results);
2269        let raw = report.raw_check_results();
2270        assert_eq!(raw.get("check1"), Some(&(1, 0)));
2271        assert_eq!(raw.get("check2"), Some(&(0, 1)));
2272    }
2273
2274    #[test]
2275    fn test_custom_check_building() {
2276        let config = ConformanceConfig {
2277            target_url: "http://localhost:3000".to_string(),
2278            ..Default::default()
2279        };
2280        let mut executor = NativeConformanceExecutor::new(config).unwrap();
2281
2282        let custom = CustomCheck {
2283            name: "custom:test-get".to_string(),
2284            path: "/api/test".to_string(),
2285            method: "GET".to_string(),
2286            expected_status: 200,
2287            body: None,
2288            expected_headers: HashMap::new(),
2289            expected_body_fields: vec![],
2290            headers: HashMap::new(),
2291            upload: None,
2292            uploads: vec![],
2293            extract: crate::conformance::custom::ExtractRules::default(),
2294            repeat: crate::conformance::custom::Repeat::default(),
2295        };
2296
2297        executor.add_custom_check(&custom);
2298        assert_eq!(executor.check_count(), 1);
2299        assert_eq!(executor.checks[0].name, "custom:test-get");
2300    }
2301
2302    #[test]
2303    fn test_openapi_schema_to_json_schema_object() {
2304        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2305
2306        let schema = Schema {
2307            schema_data: SchemaData::default(),
2308            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2309                required: vec!["name".to_string()],
2310                ..Default::default()
2311            })),
2312        };
2313
2314        let json = openapi_schema_to_json_schema(&schema);
2315        assert_eq!(json["type"], "object");
2316        assert_eq!(json["required"][0], "name");
2317    }
2318}