Skip to main content

mockforge_bench/
command.rs

1//! Bench command implementation
2
3use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11    MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21    SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24    topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_openapi::multi_spec::{
30    load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37/// Parse a list of `Key:Value` header strings into a `HashMap`.
38///
39/// Each element is one header in `Key:Value` form, supplied via a repeated
40/// `--headers` flag (`--headers "A:1" --headers "B:2"`). One header per flag
41/// means header VALUES may freely contain commas (#761) -- e.g. a Cookie value
42/// like `expires=Thu, 01 Jan 2099` is preserved intact, because we no longer
43/// split on commas. Empty elements are skipped so a stray flag is harmless.
44pub fn parse_header_string(inputs: &[String]) -> Result<HashMap<String, String>> {
45    let mut headers = HashMap::new();
46
47    for pair in inputs {
48        let pair = pair.trim();
49        if pair.is_empty() {
50            continue;
51        }
52        let parts: Vec<&str> = pair.splitn(2, ':').collect();
53        if parts.len() != 2 {
54            return Err(BenchError::Other(format!(
55                "Invalid header format: '{}'. Expected 'Key:Value'",
56                pair
57            )));
58        }
59        headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
60    }
61
62    Ok(headers)
63}
64
65/// Bench command configuration
66pub struct BenchCommand {
67    /// OpenAPI spec file(s) - can specify multiple
68    pub spec: Vec<PathBuf>,
69    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
70    pub spec_dir: Option<PathBuf>,
71    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
72    pub merge_conflicts: String,
73    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
74    pub spec_mode: String,
75    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
76    pub dependency_config: Option<PathBuf>,
77    pub target: String,
78    /// API base path prefix (e.g., "/api" or "/v2/api")
79    /// If None, extracts from OpenAPI spec's servers URL
80    pub base_path: Option<String>,
81    pub duration: String,
82    pub vus: u32,
83    /// Target requests-per-second. When `Some(n)`, the generated k6 script
84    /// switches to `constant-arrival-rate` executor at `n` RPS with `vus`
85    /// pre-allocated. When `None`, uses the legacy `ramping-vus` executor
86    /// where RPS is implicit (VUs × 1 req/sec from the script's `sleep(1)`).
87    /// Issue #79 — Srikanth's round-3 reply.
88    pub target_rps: Option<u32>,
89    /// When true, every k6 request opens a new TCP/TLS connection
90    /// (`noConnectionReuse: true` and `--no-vu-connection-reuse`). Lets users
91    /// drive a high connections-per-second rate to exercise connection-limit
92    /// chaos and observe TCP-level fault injection. Issue #79.
93    pub no_keep_alive: bool,
94    pub scenario: String,
95    pub operations: Option<String>,
96    /// Exclude operations from testing (comma-separated)
97    ///
98    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
99    pub exclude_operations: Option<String>,
100    pub auth: Option<String>,
101    /// Additional headers, one `Key:Value` per entry (repeated `--headers` flag).
102    /// One header per entry so values may contain commas (#761).
103    pub headers: Vec<String>,
104    pub output: PathBuf,
105    pub generate_only: bool,
106    pub script_output: Option<PathBuf>,
107    pub threshold_percentile: String,
108    pub threshold_ms: u64,
109    pub max_error_rate: f64,
110    pub verbose: bool,
111    pub skip_tls_verify: bool,
112    /// When true, set `Transfer-Encoding: chunked` on every k6 request body so
113    /// the server experiences chunked-encoded traffic. See
114    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats — k6's Go
115    /// transport may still send Content-Length in some cases.
116    pub chunked_request_bodies: bool,
117    /// Optional file containing multiple targets
118    pub targets_file: Option<PathBuf>,
119    /// Maximum number of parallel executions (for multi-target mode)
120    pub max_concurrency: Option<u32>,
121    /// Results format: "per-target", "aggregated", or "both"
122    pub results_format: String,
123    /// Optional file containing parameter value overrides (JSON or YAML)
124    ///
125    /// Allows users to provide custom values for path parameters, query parameters,
126    /// headers, and request bodies instead of auto-generated placeholder values.
127    pub params_file: Option<PathBuf>,
128
129    // === CRUD Flow Options ===
130    /// Enable CRUD flow mode
131    pub crud_flow: bool,
132    /// Custom CRUD flow configuration file
133    pub flow_config: Option<PathBuf>,
134    /// Fields to extract from responses
135    pub extract_fields: Option<String>,
136
137    // === Parallel Execution Options ===
138    /// Number of resources to create in parallel
139    pub parallel_create: Option<u32>,
140
141    // === Data-Driven Testing Options ===
142    /// Test data file (CSV or JSON)
143    pub data_file: Option<PathBuf>,
144    /// Data distribution strategy
145    pub data_distribution: String,
146    /// Data column to field mappings
147    pub data_mappings: Option<String>,
148    /// Enable per-URI control mode (each row specifies method, uri, body, etc.)
149    pub per_uri_control: bool,
150
151    // === Invalid Data Testing Options ===
152    /// Percentage of requests with invalid data
153    pub error_rate: Option<f64>,
154    /// Types of invalid data to generate
155    pub error_types: Option<String>,
156
157    // === Security Testing Options ===
158    /// Enable security testing
159    pub security_test: bool,
160    /// Custom security payloads file
161    pub security_payloads: Option<PathBuf>,
162    /// Security test categories
163    pub security_categories: Option<String>,
164    /// Fields to target for security injection
165    pub security_target_fields: Option<String>,
166
167    // === WAFBench Integration ===
168    /// WAFBench test directory or glob pattern for loading CRS attack patterns
169    pub wafbench_dir: Option<String>,
170    /// Cycle through ALL WAFBench payloads instead of random sampling
171    pub wafbench_cycle_all: bool,
172
173    // === OpenAPI 3.0.0 Conformance Testing ===
174    /// Enable conformance testing mode
175    pub conformance: bool,
176    /// API key for conformance security tests
177    pub conformance_api_key: Option<String>,
178    /// Basic auth credentials for conformance security tests (user:pass)
179    pub conformance_basic_auth: Option<String>,
180    /// Conformance report output file
181    pub conformance_report: PathBuf,
182    /// Conformance categories to test (comma-separated, e.g. "parameters,security")
183    pub conformance_categories: Option<String>,
184    /// Conformance report format: "json" or "sarif"
185    pub conformance_report_format: String,
186    /// Custom headers to inject into every conformance request (for authentication).
187    /// Each entry is "Header-Name: value" format.
188    pub conformance_headers: Vec<String>,
189    /// When true, test ALL operations for method/response/body categories
190    /// instead of just one representative per feature check.
191    pub conformance_all_operations: bool,
192    /// Optional YAML file with custom conformance checks
193    pub conformance_custom: Option<PathBuf>,
194    /// Delay in milliseconds between consecutive conformance requests.
195    /// Useful when testing against rate-limited APIs.
196    pub conformance_delay_ms: u64,
197    /// Use k6 for conformance test execution instead of the native Rust executor
198    pub use_k6: bool,
199    /// Regex filter for custom conformance checks — only checks whose name or
200    /// path matches the pattern are included. Example: "wafcrs|ssl" to test
201    /// only checks with "wafcrs" or "ssl" in the name/path.
202    pub conformance_custom_filter: Option<String>,
203    /// When true, export all request/response pairs to
204    /// `conformance-requests.json` in the output directory.
205    pub export_requests: bool,
206    /// When true, validate each request against the OpenAPI spec and report
207    /// violations to `conformance-request-violations.json`.
208    pub validate_requests: bool,
209    /// Issue #79 round 13 (4) — when true, replace the standard
210    /// conformance run with a positive + per-category negative
211    /// self-test driver. Verifies that the server actually rejects
212    /// the negatives with 4xx (i.e. its validator is wired correctly).
213    /// Useful to confirm the round-13 (3) validator-bypass fix took
214    /// effect against the user's spec.
215    pub conformance_self_test: bool,
216    /// Round 23 (c-iii) — when true, capture every self-test probe's
217    /// full request/response to `conformance-self-test-requests.jsonl`.
218    /// No effect outside `--conformance-self-test`.
219    pub conformance_self_test_capture: bool,
220    /// Round 25 (21.3 / a2 / a3) — when true, validate every probe's
221    /// response body against the spec's response schema for the actual
222    /// status returned. Requires `--conformance-self-test-capture`
223    /// because validation reads the captured body. No effect outside
224    /// `--conformance-self-test`.
225    pub validate_response_schemas: bool,
226    /// Round 47 (#79) — repeat the full self-test probe matrix this
227    /// many times in sequence. Defaults to 1. Combines with
228    /// `--conformance-delay` to keep a steady probe rate for network-
229    /// failure simulation.
230    pub conformance_self_test_iterations: u32,
231    /// Round 47 (#79) — alternative to iterations: keep firing the
232    /// probe matrix until this duration elapses. Overrides
233    /// `conformance_self_test_iterations` when present (iterations
234    /// becomes the floor — at least one matrix is always fired).
235    pub conformance_self_test_duration: Option<String>,
236
237    /// Round 18.5 — local source IPs to bind self-test requests to.
238    /// Each entry must be a valid `IpAddr` and already assigned to
239    /// an interface on the host. Operations round-robin through the
240    /// pool. Empty → one default client.
241    pub source_ips: Vec<String>,
242    /// Round 18.5 — fake source IPs to advertise via forwarded-IP
243    /// headers (rotated per operation). Used for GEODB testing
244    /// where the destination reads the IP from a header.
245    pub geo_source_ips: Vec<String>,
246    /// Round 18.5 — which forwarded-IP header(s) to populate when
247    /// `geo_source_ips` is non-empty. Empty → default 3-header set
248    /// (X-Forwarded-For, True-Client-IP, CF-Connecting-IP).
249    pub geo_source_headers: Vec<String>,
250
251    /// Round 21.1 — cap the HTML conformance report's missed-negative
252    /// drill-down at N rows. `Some(0)` means no cap; `None` keeps the
253    /// default of 200. The JSON report always carries the full set
254    /// regardless of this knob — it only controls what the HTML drill
255    /// view shows so a 50 000-violation run doesn't produce a 5 MB
256    /// browser-choking HTML file by default.
257    pub report_missed_cap: Option<u32>,
258
259    /// Round 57 (#79) — when true, run k6 load with
260    /// `K6_DISCARD_RESPONSE_BODIES=true` so it does not buffer response bodies
261    /// in memory. Exposes the r56 env var as a first-class flag for scale /
262    /// stress runs. Only affects the plain (status-only) load paths; the
263    /// conformance / self-test / CRUD-extraction paths that read the body
264    /// ignore it. Multi-target load already discards by default (r56).
265    pub discard_response_bodies: bool,
266
267    /// Round 61 (#79) — k6 DNS resolution policy (`preferIPv6`, `onlyIPv6`,
268    /// `preferIPv4`, `onlyIPv4`, `any`). `None` → k6 default (`preferIPv4`).
269    /// Passed to k6 as `--dns "policy=<value>"`. Lets a GEODB IPv6 test pin
270    /// hostname targets to their AAAA record while keeping the hostname on the
271    /// wire (Srikanth's WAF routes by Host/SNI, so the target must be a name).
272    pub dns_policy: Option<String>,
273
274    // === OWASP API Security Top 10 Testing ===
275    /// Enable OWASP API Security Top 10 testing mode
276    pub owasp_api_top10: bool,
277    /// OWASP API categories to test (comma-separated)
278    pub owasp_categories: Option<String>,
279    /// Authorization header name for OWASP auth tests
280    pub owasp_auth_header: String,
281    /// Valid authorization token for OWASP baseline requests
282    pub owasp_auth_token: Option<String>,
283    /// File containing admin/privileged paths to test
284    pub owasp_admin_paths: Option<PathBuf>,
285    /// Fields containing resource IDs for BOLA testing
286    pub owasp_id_fields: Option<String>,
287    /// OWASP report output file
288    pub owasp_report: Option<PathBuf>,
289    /// OWASP report format (json, sarif)
290    pub owasp_report_format: String,
291    /// Number of iterations per VU for OWASP tests (default: 1)
292    pub owasp_iterations: u32,
293}
294
295/// Round 18.5 / 19 — parse a list of CLI IP strings. Each entry may be:
296/// - a single IPv4/IPv6 (`10.0.0.5` / `2001:db8::1`)
297/// - a comma-separated list (`10.0.0.5,10.0.0.6,2001:db8::1`)
298/// - a CIDR range (`10.0.0.0/29` expands to 8 hosts;
299///   `2001:db8::/126` expands to 4 IPv6 hosts)
300///
301/// CIDR ranges are capped at `MAX_CIDR_EXPANSION` (256) host
302/// addresses to avoid OOM'ing on `/8` typos. The cap is generous
303/// for GEODB testing (you want 20–100 IPs, not 10M) and the warning
304/// names the cap so it's debuggable.
305///
306/// Malformed entries log a warning and are dropped; the bench
307/// continues with whatever resolved cleanly.
308fn parse_ip_list(raw: &[String], flag_name: &str) -> Vec<std::net::IpAddr> {
309    use std::net::IpAddr;
310    const MAX_CIDR_EXPANSION: usize = 256;
311    let mut out = Vec::new();
312    for entry in raw {
313        for piece in entry.split(',') {
314            let s = piece.trim();
315            if s.is_empty() {
316                continue;
317            }
318            // CIDR form: `ip/prefix`
319            if let Some((addr_part, prefix_part)) = s.split_once('/') {
320                let prefix: u32 = match prefix_part.parse() {
321                    Ok(p) => p,
322                    Err(e) => {
323                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR prefix: {e}");
324                        continue;
325                    }
326                };
327                let net_addr: IpAddr = match addr_part.parse() {
328                    Ok(a) => a,
329                    Err(e) => {
330                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR address: {e}");
331                        continue;
332                    }
333                };
334                expand_cidr(net_addr, prefix, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
335                continue;
336            }
337            // Round 22.4 — range form: `start-end` (Srikanth (h)).
338            // Lets users specify non-power-of-2 ranges without
339            // finding a clean prefix. IPv4 only for now (the most
340            // common case); IPv6 ranges with `:` collide with the
341            // address literal so they'd need a different separator.
342            if let Some((start_str, end_str)) = s.split_once('-') {
343                let start_s = start_str.trim();
344                let end_s = end_str.trim();
345                // Reject ambiguous IPv6 ranges (contain `:`) so we
346                // don't accidentally parse `2001:db8::1-2001:db8::5`
347                // as a half address.
348                if start_s.contains(':') || end_s.contains(':') {
349                    tracing::warn!(target: "mockforge::bench", "--{flag_name} '{s}': IPv6 range syntax not supported (use CIDR like 2001:db8::/126 instead)");
350                    continue;
351                }
352                let start: IpAddr = match start_s.parse() {
353                    Ok(a) => a,
354                    Err(e) => {
355                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range start: {e}");
356                        continue;
357                    }
358                };
359                let end: IpAddr = match end_s.parse() {
360                    Ok(a) => a,
361                    Err(e) => {
362                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range end: {e}");
363                        continue;
364                    }
365                };
366                expand_range(start, end, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
367                continue;
368            }
369            // Plain IP form
370            match s.parse::<IpAddr>() {
371                Ok(ip) => out.push(ip),
372                Err(e) => {
373                    tracing::warn!(target: "mockforge::bench", "ignoring malformed --{flag_name} value '{s}': {e}");
374                }
375            }
376        }
377    }
378    out
379}
380
381/// Round 22.4 — expand an inclusive `start-end` IPv4 range to host
382/// addresses, capped at `cap`. Returns silently on a backwards or
383/// mixed-family range with a warning.
384fn expand_range(
385    start: std::net::IpAddr,
386    end: std::net::IpAddr,
387    cap: usize,
388    flag_name: &str,
389    raw: &str,
390    out: &mut Vec<std::net::IpAddr>,
391) {
392    use std::net::{IpAddr, Ipv4Addr};
393    let (start_v4, end_v4) = match (start, end) {
394        (IpAddr::V4(a), IpAddr::V4(b)) => (a, b),
395        _ => {
396            tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range start/end must both be IPv4");
397            return;
398        }
399    };
400    let start_u32 = u32::from(start_v4);
401    let end_u32 = u32::from(end_v4);
402    if end_u32 < start_u32 {
403        tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range end {end_v4} is before start {start_v4}");
404        return;
405    }
406    let total = (end_u32 - start_u32).saturating_add(1) as usize;
407    let take = total.min(cap);
408    if total > cap {
409        tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range has {total} addresses, capping at {cap}");
410    }
411    for i in 0..take as u32 {
412        out.push(IpAddr::V4(Ipv4Addr::from(start_u32 + i)));
413    }
414}
415
416/// Expand a CIDR (IPv4 or IPv6) into individual host IPs, appending
417/// to `out`. Capped at `cap` to prevent runaway expansion on a `/8`
418/// typo. When the cap kicks in we log a warning and skip the rest.
419fn expand_cidr(
420    net: std::net::IpAddr,
421    prefix: u32,
422    cap: usize,
423    flag_name: &str,
424    raw: &str,
425    out: &mut Vec<std::net::IpAddr>,
426) {
427    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
428    match net {
429        IpAddr::V4(ipv4) => {
430            if prefix > 32 {
431                tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv4 prefix must be <= 32");
432                return;
433            }
434            let total: u64 = 1u64 << (32 - prefix);
435            let take = total.min(cap as u64) as u32;
436            if total > cap as u64 {
437                tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': CIDR has {total} addresses, capping at {cap}");
438            }
439            let mask: u32 = if prefix == 0 {
440                0
441            } else {
442                !0u32 << (32 - prefix)
443            };
444            let net_u32 = u32::from(ipv4) & mask;
445            for i in 0..take {
446                out.push(IpAddr::V4(Ipv4Addr::from(net_u32.wrapping_add(i))));
447            }
448        }
449        IpAddr::V6(ipv6) => {
450            if prefix > 128 {
451                tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv6 prefix must be <= 128");
452                return;
453            }
454            // Total addresses = 2^(128-prefix). Cap at u128::MAX
455            // conceptually but since `cap` is small (256) we just
456            // iterate up to cap.
457            let mask: u128 = if prefix == 0 {
458                0
459            } else {
460                !0u128 << (128 - prefix)
461            };
462            let net_u128 = u128::from(ipv6) & mask;
463            let remaining_bits = 128 - prefix;
464            // Compute total carefully — for prefix=0 this is 2^128
465            // which overflows; we just clamp via take.
466            let total_capped = if remaining_bits >= 64 {
467                cap as u128
468            } else {
469                (1u128 << remaining_bits).min(cap as u128)
470            };
471            if remaining_bits < 128 && (1u128 << remaining_bits) > cap as u128 {
472                tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': IPv6 CIDR exceeds {cap} addresses, capping");
473            }
474            for i in 0..total_capped {
475                out.push(IpAddr::V6(Ipv6Addr::from(net_u128.wrapping_add(i))));
476            }
477        }
478    }
479}
480
481impl BenchCommand {
482    /// Load and merge specs from --spec files and --spec-dir
483    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
484        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
485
486        // Load specs from --spec flags
487        if !self.spec.is_empty() {
488            let specs = load_specs_from_files(self.spec.clone())
489                .await
490                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
491            all_specs.extend(specs);
492        }
493
494        // Load specs from --spec-dir if provided
495        if let Some(spec_dir) = &self.spec_dir {
496            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
497                BenchError::Other(format!("Failed to load specs from directory: {}", e))
498            })?;
499            all_specs.extend(dir_specs);
500        }
501
502        if all_specs.is_empty() {
503            return Err(BenchError::Other(
504                "No spec files provided. Use --spec or --spec-dir.".to_string(),
505            ));
506        }
507
508        // If only one spec, return it directly (extract just the OpenApiSpec)
509        if all_specs.len() == 1 {
510            // Safe to unwrap because we just checked len() == 1
511            return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
512        }
513
514        // Merge multiple specs
515        let conflict_strategy = match self.merge_conflicts.as_str() {
516            "first" => ConflictStrategy::First,
517            "last" => ConflictStrategy::Last,
518            _ => ConflictStrategy::Error,
519        };
520
521        merge_specs(all_specs, conflict_strategy)
522            .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
523    }
524
525    /// Get a display name for the spec(s)
526    fn get_spec_display_name(&self) -> String {
527        if self.spec.len() == 1 {
528            self.spec[0].to_string_lossy().to_string()
529        } else if !self.spec.is_empty() {
530            format!("{} spec files", self.spec.len())
531        } else if let Some(dir) = &self.spec_dir {
532            format!("specs from {}", dir.display())
533        } else {
534            "no specs".to_string()
535        }
536    }
537
538    /// Round 29 — capacity advisory. Counts the targets-file entries
539    /// (or 1 for single-target), multiplies by VUs and configured RPS,
540    /// and prints a warning when the product exceeds rule-of-thumb
541    /// limits a single client can handle. The detailed sizing table
542    /// lives in `book/src/reference/bench-capacity-sizing.md`; this
543    /// just nudges the user toward it before they hang their VM.
544    fn advise_capacity(&self) {
545        let target_count = self
546            .targets_file
547            .as_ref()
548            .and_then(|p| std::fs::read_to_string(p).ok())
549            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
550            .and_then(|v| v.as_array().map(|a| a.len()))
551            .unwrap_or(1);
552        let vus = self.vus.max(1);
553        let rps_total = self.target_rps.unwrap_or(0) as usize * target_count.max(1);
554        // 50 targets × 5 VUs = 250 is roughly where Srikanth's 15GB
555        // client started hanging on a 10 MB spec. Surface a warning
556        // well before that.
557        let load_product = target_count * vus as usize;
558        if load_product >= 150 {
559            let est_ram_gb =
560                (vus as usize * 50) / 1024 + (target_count * 10 * 2) / 1024 + target_count / 2;
561            let est_cores = ((vus as usize) / 50).max(2);
562            TerminalReporter::print_warning(&format!(
563                "Capacity advisory: targets={target_count}, VUs={vus}, RPS-total≈{rps_total}. \
564                 Single-client estimate: ~{est_cores} CPU cores, ~{est_ram_gb} GB RAM. \
565                 If your machine is below that, expect OOM hangs partway through the run. \
566                 See https://docs.mockforge.dev/reference/bench-capacity-sizing.html \
567                 for the sizing table and sharding guide."
568            ));
569        }
570    }
571
572    /// Execute the bench command
573    pub async fn execute(&self) -> Result<()> {
574        // Round 23 — Srikanth flagged that k6 _does_ support per-VU source
575        // IPs via `--local-ips` (the round-22 warning that said otherwise
576        // was wrong). The k6 path now forwards `--source-ip` straight to
577        // `k6 run --local-ips`, so the only case worth flagging is the
578        // self-test+k6 combo: self-test returns before k6 ever launches,
579        // so `--use-k6` on that command is a no-op.
580        if self.conformance_self_test && self.use_k6 {
581            TerminalReporter::print_warning(
582                "--use-k6 has no effect with --conformance-self-test: the self-test driver runs and returns before k6 is invoked. Drop one or the other depending on whether you want the spec-driven self-test or a k6 bench run.",
583            );
584        }
585
586        // Round 29 — Srikanth's "5 VUs against 50 targets hung the VM"
587        // report. Print an up-front capacity advisory so the user knows
588        // BEFORE the run starts that their config exceeds what one
589        // client can comfortably handle. Pure heuristic; rules of
590        // thumb live in book/src/reference/bench-capacity-sizing.md.
591        self.advise_capacity();
592
593        // Check if we're in multi-target mode
594        if let Some(targets_file) = &self.targets_file {
595            // Round 48 (#79) — Srikanth on 0.3.192: --conformance-self-test
596            // with --targets-file silently ignored both the self-test
597            // driver AND the round-47 iterations/duration knobs because
598            // the dispatch above returned into `execute_multi_target_conformance`
599            // which runs the regular k6 conformance flow. Now route to
600            // a dedicated multi-target self-test path that runs the
601            // self-test driver against EACH target with the same
602            // iteration/duration loop the single-target path got in r47.
603            if self.conformance && self.conformance_self_test {
604                return self.execute_multi_target_self_test(targets_file).await;
605            }
606            if self.conformance {
607                return self.execute_multi_target_conformance(targets_file).await;
608            }
609            return self.execute_multi_target(targets_file).await;
610        }
611
612        // Check if we're in sequential spec mode (for dependency handling)
613        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
614            return self.execute_sequential_specs().await;
615        }
616
617        // Single target mode (existing behavior)
618        // Print header
619        TerminalReporter::print_header(
620            &self.get_spec_display_name(),
621            &self.target,
622            0, // Will be updated later
623            &self.scenario,
624            Self::parse_duration(&self.duration)?,
625        );
626
627        // Validate k6 installation
628        if !K6Executor::is_k6_installed() {
629            TerminalReporter::print_error("k6 is not installed");
630            TerminalReporter::print_warning(
631                "Install k6 from: https://k6.io/docs/get-started/installation/",
632            );
633            return Err(BenchError::K6NotFound);
634        }
635
636        // Check for conformance testing mode (before spec loading — conformance doesn't need a user spec)
637        if self.conformance {
638            return self.execute_conformance_test().await;
639        }
640
641        // Load and parse spec(s)
642        TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
643        let merged_spec = self.load_and_merge_specs().await?;
644        let parser = SpecParser::from_spec(merged_spec);
645        if self.spec.len() > 1 || self.spec_dir.is_some() {
646            TerminalReporter::print_success(&format!(
647                "Loaded and merged {} specification(s)",
648                self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
649            ));
650        } else {
651            TerminalReporter::print_success("Specification loaded");
652        }
653
654        // Check for mock server integration
655        let mock_config = self.build_mock_config().await;
656        if mock_config.is_mock_server {
657            TerminalReporter::print_progress("Mock server integration enabled");
658        }
659
660        // Check for CRUD flow mode
661        if self.crud_flow {
662            return self.execute_crud_flow(&parser).await;
663        }
664
665        // Check for OWASP API Top 10 testing mode
666        if self.owasp_api_top10 {
667            return self.execute_owasp_test(&parser).await;
668        }
669
670        // Get operations
671        TerminalReporter::print_progress("Extracting API operations...");
672        let mut operations = if let Some(filter) = &self.operations {
673            parser.filter_operations(filter)?
674        } else {
675            parser.get_operations()
676        };
677
678        // Apply exclusions if provided
679        if let Some(exclude) = &self.exclude_operations {
680            let before_count = operations.len();
681            operations = parser.exclude_operations(operations, exclude)?;
682            let excluded_count = before_count - operations.len();
683            if excluded_count > 0 {
684                TerminalReporter::print_progress(&format!(
685                    "Excluded {} operations matching '{}'",
686                    excluded_count, exclude
687                ));
688            }
689        }
690
691        if operations.is_empty() {
692            return Err(BenchError::Other("No operations found in spec".to_string()));
693        }
694
695        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
696
697        // Load parameter overrides if provided
698        let param_overrides = if let Some(params_file) = &self.params_file {
699            TerminalReporter::print_progress("Loading parameter overrides...");
700            let overrides = ParameterOverrides::from_file(params_file)?;
701            TerminalReporter::print_success(&format!(
702                "Loaded parameter overrides ({} operation-specific, {} defaults)",
703                overrides.operations.len(),
704                if overrides.defaults.is_empty() { 0 } else { 1 }
705            ));
706            Some(overrides)
707        } else {
708            None
709        };
710
711        // Generate request templates
712        TerminalReporter::print_progress("Generating request templates...");
713        let templates: Vec<_> = operations
714            .iter()
715            .map(|op| {
716                let op_overrides = param_overrides.as_ref().map(|po| {
717                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
718                });
719                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
720            })
721            .collect::<Result<Vec<_>>>()?;
722        TerminalReporter::print_success("Request templates generated");
723
724        // Parse headers
725        let custom_headers = self.parse_headers()?;
726
727        // Resolve base path (CLI option takes priority over spec's servers URL)
728        let base_path = self.resolve_base_path(&parser);
729        if let Some(ref bp) = base_path {
730            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
731        }
732
733        // Generate k6 script
734        TerminalReporter::print_progress("Generating k6 load test script...");
735        let scenario =
736            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
737
738        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
739
740        // Issue #79 round 6 follow-up — Srikanth reported k6 emitting
741        // "Insufficient VUs, reached 5 active VUs and cannot initialize more"
742        // when running `--rps 100 --vus 5`. With the `constant-arrival-rate`
743        // executor, k6 needs roughly `rps × avg_request_seconds` VUs to keep
744        // up; if `--vus` is too low it can't sustain the rate. Warn pre-flight
745        // so users know to bump `--vus` rather than chase the warning.
746        //
747        // Round 8 (#79): the static 100ms heuristic was wrong for fast targets
748        // (~2ms latency). Probe the actual target first to measure baseline
749        // latency, then derive a more accurate sizing recommendation. Fall
750        // back to the 100ms heuristic only when the probe can't reach the
751        // target (auth-gated endpoints, strict WAFs, etc).
752        //
753        // Round 9 (#79): factor in operation count. k6's constant-arrival-rate
754        // counts ITERATIONS, not requests — and every iteration runs all N
755        // operations sequentially, so required VUs scale with N. Srikanth's
756        // 12-op spec at --rps 100 with 15ms latency needs ~19 VUs, not 3.
757        let num_ops = operations.len() as u32;
758        if let Some(rps) = self.target_rps {
759            let probe =
760                crate::preflight::probe_target_latency(&self.target, 3, self.skip_tls_verify).await;
761
762            let (required_vus, basis) = match probe {
763                Some(p) => (
764                    p.required_vus(rps, num_ops),
765                    format!("avg {:.1}ms (measured)", p.avg_latency.as_secs_f64() * 1000.0),
766                ),
767                None => {
768                    // Static fallback: ~100ms heuristic × num_ops per iteration.
769                    let fallback = (rps as u64)
770                        .saturating_mul(num_ops.max(1) as u64)
771                        .div_ceil(10)
772                        .min(u32::MAX as u64) as u32;
773                    (fallback, "~100ms (default — probe failed)".to_string())
774                }
775            };
776
777            if self.vus < required_vus {
778                // Round 10 (#79): Srikanth's 11422-op spec at --rps 100 produced
779                // a recommendation of ~10,740 VUs, which is absurd in practice.
780                // When the recommendation goes super-linear, the real fix is to
781                // reduce the workload (use --operations filter), not bump VUs.
782                // Cap the suggestion at 1000 and steer the user toward filtering.
783                const VU_RECOMMENDATION_CAP: u32 = 1000;
784                let recommendation = required_vus.max(self.vus + 1);
785                if recommendation > VU_RECOMMENDATION_CAP {
786                    TerminalReporter::print_warning(&format!(
787                        "Workload is very large: --rps {} × {} ops/iteration × {} \
788                         baseline ⇒ ~{} VUs needed end-to-end, far beyond what's \
789                         practical to drive. Two ways to fix:\n  1. Reduce \
790                         operations per iteration with `--operations 'pattern,…'` \
791                         (or `--exclude-operations`) to focus the bench on a \
792                         representative subset.\n  2. Drop `--rps` and use \
793                         `--vus {}` alone — closed-model load runs as fast as \
794                         the VU pool allows, bounded by latency, with no per-\
795                         iteration deadline. Expect 1-iteration coverage of ~{} \
796                         operations in {}s.",
797                        rps,
798                        num_ops,
799                        basis,
800                        recommendation,
801                        self.vus.max(5),
802                        num_ops,
803                        Self::parse_duration(&self.duration).unwrap_or(0),
804                    ));
805                } else {
806                    TerminalReporter::print_warning(&format!(
807                        "--vus {} may be insufficient for --rps {} × {} ops/iteration \
808                         (baseline latency {}). k6's constant-arrival-rate counts ITERATIONS \
809                         and each runs every operation in the spec — required ≈ rps × ops × \
810                         latency_secs VUs. Bump --vus to ~{} if you see \"Insufficient VUs\" \
811                         warnings.",
812                        self.vus, rps, num_ops, basis, recommendation,
813                    ));
814                }
815            } else if probe.is_some() {
816                TerminalReporter::print_progress(&format!(
817                    "Pre-flight probe: target latency {}, {} ops/iteration — --vus {} \
818                     is sufficient for --rps {}",
819                    basis, num_ops, self.vus, rps,
820                ));
821            }
822        }
823
824        let k6_config = K6Config {
825            target_url: self.target.clone(),
826            base_path,
827            scenario,
828            duration_secs: Self::parse_duration(&self.duration)?,
829            max_vus: self.vus,
830            threshold_percentile: self.threshold_percentile.clone(),
831            threshold_ms: self.threshold_ms,
832            max_error_rate: self.max_error_rate,
833            auth_header: self.auth.clone(),
834            custom_headers,
835            skip_tls_verify: self.skip_tls_verify,
836            security_testing_enabled,
837            chunked_request_bodies: self.chunked_request_bodies,
838            target_rps: self.target_rps,
839            no_keep_alive: self.no_keep_alive,
840            // Round 22.3 — wire `--geo-source-ip` / `--geo-source-header`
841            // through to the k6 generator so the rendered script
842            // rotates the forwarded-IP headers per iteration. Pre-fix
843            // these were Vec::new() and the script never set the
844            // headers in bench mode.
845            geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
846                .into_iter()
847                .map(|ip| ip.to_string())
848                .collect(),
849            geo_source_headers: if self.geo_source_headers.is_empty()
850                && !self.geo_source_ips.is_empty()
851            {
852                crate::conformance::self_test::default_geo_source_headers()
853            } else {
854                self.geo_source_headers.clone()
855            },
856        };
857
858        let generator = K6ScriptGenerator::new(k6_config, templates);
859        let mut script = generator.generate()?;
860        TerminalReporter::print_success("k6 script generated");
861
862        // Check if any advanced features are enabled
863        let has_advanced_features = self.data_file.is_some()
864            || self.error_rate.is_some()
865            || self.security_test
866            || self.parallel_create.is_some()
867            || self.wafbench_dir.is_some();
868
869        // Enhance script with advanced features
870        if has_advanced_features {
871            script = self.generate_enhanced_script(&script)?;
872        }
873
874        // Add mock server integration code
875        if mock_config.is_mock_server {
876            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
877            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
878            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
879
880            // Insert mock server code after imports
881            if let Some(import_end) = script.find("export const options") {
882                script.insert_str(
883                    import_end,
884                    &format!(
885                        "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
886                        helper_code, setup_code, teardown_code
887                    ),
888                );
889            }
890        }
891
892        // Validate the generated script
893        TerminalReporter::print_progress("Validating k6 script...");
894        let validation_errors = K6ScriptGenerator::validate_script(&script);
895        if !validation_errors.is_empty() {
896            TerminalReporter::print_error("Script validation failed");
897            for error in &validation_errors {
898                eprintln!("  {}", error);
899            }
900            return Err(BenchError::Other(format!(
901                "Generated k6 script has {} validation error(s). Please check the output above.",
902                validation_errors.len()
903            )));
904        }
905        TerminalReporter::print_success("Script validation passed");
906
907        // Write script to file
908        let script_path = if let Some(output) = &self.script_output {
909            output.clone()
910        } else {
911            self.output.join("k6-script.js")
912        };
913
914        if let Some(parent) = script_path.parent() {
915            std::fs::create_dir_all(parent)?;
916        }
917        std::fs::write(&script_path, &script)?;
918        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
919
920        // If generate-only mode, exit here
921        if self.generate_only {
922            println!("\nScript generated successfully. Run it with:");
923            println!("  k6 run {}", script_path.display());
924            return Ok(());
925        }
926
927        // Execute k6
928        TerminalReporter::print_progress("Executing load test...");
929        // Round 57 (#79) — `--discard-response-bodies` opts a single-target
930        // load run into K6_DISCARD_RESPONSE_BODIES. Safe here: this is the
931        // plain load path (status/latency only), not conformance/extraction.
932        let executor = K6Executor::new()?
933            .with_local_ips(self.source_ips.join(","))
934            .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
935            .with_discard_response_bodies(self.discard_response_bodies);
936
937        std::fs::create_dir_all(&self.output)?;
938
939        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
940
941        // Print results
942        let duration_secs = Self::parse_duration(&self.duration)?;
943        TerminalReporter::print_summary_full(
944            &results,
945            duration_secs,
946            self.no_keep_alive,
947            Some(num_ops),
948        );
949
950        println!("\nResults saved to: {}", self.output.display());
951
952        Ok(())
953    }
954
955    /// Execute multi-target bench testing
956    async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
957        TerminalReporter::print_progress("Parsing targets file...");
958        let targets = parse_targets_file(targets_file)?;
959        let num_targets = targets.len();
960        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
961
962        if targets.is_empty() {
963            return Err(BenchError::Other("No targets found in file".to_string()));
964        }
965
966        // Determine max concurrency
967        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
968        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
969
970        // Print header for multi-target mode
971        TerminalReporter::print_header(
972            &self.get_spec_display_name(),
973            &format!("{} targets", num_targets),
974            0,
975            &self.scenario,
976            Self::parse_duration(&self.duration)?,
977        );
978
979        // Create parallel executor
980        let executor = ParallelExecutor::new(
981            BenchCommand {
982                // Clone all fields except targets_file (we don't need it in the executor)
983                spec: self.spec.clone(),
984                spec_dir: self.spec_dir.clone(),
985                merge_conflicts: self.merge_conflicts.clone(),
986                spec_mode: self.spec_mode.clone(),
987                dependency_config: self.dependency_config.clone(),
988                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
989                base_path: self.base_path.clone(),
990                duration: self.duration.clone(),
991                vus: self.vus,
992                target_rps: self.target_rps,
993                no_keep_alive: self.no_keep_alive,
994                scenario: self.scenario.clone(),
995                operations: self.operations.clone(),
996                exclude_operations: self.exclude_operations.clone(),
997                auth: self.auth.clone(),
998                headers: self.headers.clone(),
999                output: self.output.clone(),
1000                generate_only: self.generate_only,
1001                script_output: self.script_output.clone(),
1002                threshold_percentile: self.threshold_percentile.clone(),
1003                threshold_ms: self.threshold_ms,
1004                max_error_rate: self.max_error_rate,
1005                verbose: self.verbose,
1006                skip_tls_verify: self.skip_tls_verify,
1007                chunked_request_bodies: self.chunked_request_bodies,
1008                targets_file: None,
1009                max_concurrency: None,
1010                results_format: self.results_format.clone(),
1011                params_file: self.params_file.clone(),
1012                crud_flow: self.crud_flow,
1013                flow_config: self.flow_config.clone(),
1014                extract_fields: self.extract_fields.clone(),
1015                parallel_create: self.parallel_create,
1016                data_file: self.data_file.clone(),
1017                data_distribution: self.data_distribution.clone(),
1018                data_mappings: self.data_mappings.clone(),
1019                per_uri_control: self.per_uri_control,
1020                error_rate: self.error_rate,
1021                error_types: self.error_types.clone(),
1022                security_test: self.security_test,
1023                security_payloads: self.security_payloads.clone(),
1024                security_categories: self.security_categories.clone(),
1025                security_target_fields: self.security_target_fields.clone(),
1026                wafbench_dir: self.wafbench_dir.clone(),
1027                wafbench_cycle_all: self.wafbench_cycle_all,
1028                owasp_api_top10: self.owasp_api_top10,
1029                owasp_categories: self.owasp_categories.clone(),
1030                owasp_auth_header: self.owasp_auth_header.clone(),
1031                owasp_auth_token: self.owasp_auth_token.clone(),
1032                owasp_admin_paths: self.owasp_admin_paths.clone(),
1033                owasp_id_fields: self.owasp_id_fields.clone(),
1034                owasp_report: self.owasp_report.clone(),
1035                owasp_report_format: self.owasp_report_format.clone(),
1036                owasp_iterations: self.owasp_iterations,
1037                conformance: false,
1038                conformance_api_key: None,
1039                conformance_basic_auth: None,
1040                conformance_report: PathBuf::from("conformance-report.json"),
1041                conformance_categories: None,
1042                conformance_report_format: "json".to_string(),
1043                conformance_headers: vec![],
1044                conformance_all_operations: false,
1045                conformance_custom: None,
1046                conformance_delay_ms: 0,
1047                use_k6: false,
1048                conformance_custom_filter: None,
1049                export_requests: false,
1050                validate_requests: false,
1051                conformance_self_test: false,
1052                conformance_self_test_capture: false,
1053                conformance_self_test_iterations: 1,
1054                conformance_self_test_duration: None,
1055                validate_response_schemas: false,
1056                // Issue #79 r54 (Srikanth on 0.3.200): these were hardcoded to
1057                // empty, so `--source-ip` / `--geo-source-ip` were silently
1058                // dropped in multi-target (`--targets-file`) mode. Carry them
1059                // through to the ParallelExecutor so k6 gets `--local-ips`.
1060                source_ips: self.source_ips.clone(),
1061                geo_source_ips: self.geo_source_ips.clone(),
1062                geo_source_headers: self.geo_source_headers.clone(),
1063                report_missed_cap: None,
1064                // Round 57 (#79) — carry the flag through; the multi-target
1065                // plain-load path already discards by default (r56), so this
1066                // keeps the per-target command faithful to the parent.
1067                discard_response_bodies: self.discard_response_bodies,
1068                // Round 61 (#79) — carry the DNS policy so per-target k6 runs
1069                // resolve hostnames with the same IPv6/IPv4 preference.
1070                dns_policy: self.dns_policy.clone(),
1071            },
1072            targets,
1073            max_concurrency,
1074        );
1075
1076        // Execute all targets
1077        let start_time = std::time::Instant::now();
1078        let aggregated_results = executor.execute_all().await?;
1079        let elapsed = start_time.elapsed();
1080
1081        // Organize and report results
1082        self.report_multi_target_results(&aggregated_results, elapsed)?;
1083
1084        Ok(())
1085    }
1086
1087    /// Report results for multi-target execution
1088    fn report_multi_target_results(
1089        &self,
1090        results: &AggregatedResults,
1091        elapsed: std::time::Duration,
1092    ) -> Result<()> {
1093        // Print summary
1094        TerminalReporter::print_multi_target_summary(results);
1095
1096        // Print elapsed time
1097        let total_secs = elapsed.as_secs();
1098        let hours = total_secs / 3600;
1099        let minutes = (total_secs % 3600) / 60;
1100        let seconds = total_secs % 60;
1101        if hours > 0 {
1102            println!("\n  Total Elapsed Time:   {}h {}m {}s", hours, minutes, seconds);
1103        } else if minutes > 0 {
1104            println!("\n  Total Elapsed Time:   {}m {}s", minutes, seconds);
1105        } else {
1106            println!("\n  Total Elapsed Time:   {}s", seconds);
1107        }
1108
1109        // Save aggregated summary if requested
1110        if self.results_format == "aggregated" || self.results_format == "both" {
1111            let summary_path = self.output.join("aggregated_summary.json");
1112            let summary_json = serde_json::json!({
1113                "total_elapsed_seconds": elapsed.as_secs(),
1114                "total_targets": results.total_targets,
1115                "successful_targets": results.successful_targets,
1116                "failed_targets": results.failed_targets,
1117                "aggregated_metrics": {
1118                    "total_requests": results.aggregated_metrics.total_requests,
1119                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
1120                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
1121                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
1122                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
1123                    "error_rate": results.aggregated_metrics.error_rate,
1124                    "total_rps": results.aggregated_metrics.total_rps,
1125                    "avg_rps": results.aggregated_metrics.avg_rps,
1126                    "total_vus_max": results.aggregated_metrics.total_vus_max,
1127                },
1128                "target_results": results.target_results.iter().map(|r| {
1129                    serde_json::json!({
1130                        "target_url": r.target_url,
1131                        "target_index": r.target_index,
1132                        "success": r.success,
1133                        "error": r.error,
1134                        "total_requests": r.results.total_requests,
1135                        "failed_requests": r.results.failed_requests,
1136                        "avg_duration_ms": r.results.avg_duration_ms,
1137                        "min_duration_ms": r.results.min_duration_ms,
1138                        "med_duration_ms": r.results.med_duration_ms,
1139                        "p90_duration_ms": r.results.p90_duration_ms,
1140                        "p95_duration_ms": r.results.p95_duration_ms,
1141                        "p99_duration_ms": r.results.p99_duration_ms,
1142                        "max_duration_ms": r.results.max_duration_ms,
1143                        "rps": r.results.rps,
1144                        "vus_max": r.results.vus_max,
1145                        "output_dir": r.output_dir.to_string_lossy(),
1146                    })
1147                }).collect::<Vec<_>>(),
1148            });
1149
1150            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
1151            TerminalReporter::print_success(&format!(
1152                "Aggregated summary saved to: {}",
1153                summary_path.display()
1154            ));
1155        }
1156
1157        // Write CSV with all per-target results for easy parsing
1158        let csv_path = self.output.join("all_targets.csv");
1159        let mut csv = String::from(
1160            "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
1161        );
1162        for r in &results.target_results {
1163            csv.push_str(&format!(
1164                "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
1165                r.target_url,
1166                r.success,
1167                r.results.total_requests,
1168                r.results.failed_requests,
1169                r.results.rps,
1170                r.results.vus_max,
1171                r.results.min_duration_ms,
1172                r.results.avg_duration_ms,
1173                r.results.med_duration_ms,
1174                r.results.p90_duration_ms,
1175                r.results.p95_duration_ms,
1176                r.results.p99_duration_ms,
1177                r.results.max_duration_ms,
1178                r.error.as_deref().unwrap_or(""),
1179            ));
1180        }
1181        let _ = std::fs::write(&csv_path, &csv);
1182
1183        println!("\nResults saved to: {}", self.output.display());
1184        println!("  - Per-target results: {}", self.output.join("target_*").display());
1185        println!("  - All targets CSV:    {}", csv_path.display());
1186        if self.results_format == "aggregated" || self.results_format == "both" {
1187            println!(
1188                "  - Aggregated summary: {}",
1189                self.output.join("aggregated_summary.json").display()
1190            );
1191        }
1192
1193        Ok(())
1194    }
1195
1196    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
1197    pub fn parse_duration(duration: &str) -> Result<u64> {
1198        let duration = duration.trim();
1199
1200        if let Some(secs) = duration.strip_suffix('s') {
1201            secs.parse::<u64>()
1202                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1203        } else if let Some(mins) = duration.strip_suffix('m') {
1204            mins.parse::<u64>()
1205                .map(|m| m * 60)
1206                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1207        } else if let Some(hours) = duration.strip_suffix('h') {
1208            hours
1209                .parse::<u64>()
1210                .map(|h| h * 3600)
1211                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1212        } else {
1213            // Try parsing as seconds without suffix
1214            duration
1215                .parse::<u64>()
1216                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1217        }
1218    }
1219
1220    /// Parse headers from the repeated `--headers "Key:Value"` flags.
1221    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
1222        let mut headers = parse_header_string(&self.headers)?;
1223
1224        // Round 47 (#79) — Srikanth on 0.3.191 tried
1225        // `mockforge bench --conformance-basic-auth user:pass --spec ...`
1226        // (no `--conformance` flag) and the auth never landed because
1227        // the bench path only reads `self.headers`. Fold the
1228        // conformance auth shortcuts (basic, api-key, the round-46
1229        // --auth-bearer collected in `conformance_headers`) into the
1230        // shared header map so the SAME auth flags work whether the
1231        // run is plain bench, conformance, or self-test. De-dup
1232        // case-insensitively on header name; explicit
1233        // `--header "Authorization: ..."` keeps priority.
1234        let already_has = |hs: &HashMap<String, String>, name: &str| -> bool {
1235            hs.keys().any(|k| k.eq_ignore_ascii_case(name))
1236        };
1237
1238        if !already_has(&headers, "Authorization") {
1239            if let Some(b) = self.conformance_basic_auth.as_ref().filter(|s| !s.is_empty()) {
1240                use base64::Engine as _;
1241                let encoded = base64::engine::general_purpose::STANDARD.encode(b.as_bytes());
1242                headers.insert("Authorization".to_string(), format!("Basic {}", encoded));
1243            }
1244        }
1245
1246        // `--conformance-headers "Name: value"` was already conformance-
1247        // only; reuse the same flag for bench. Round 46's --auth-bearer
1248        // injects `Authorization: Bearer ...` into this list at CLI
1249        // dispatch time, so this single pass also picks up the bearer
1250        // token for plain bench.
1251        for line in &self.conformance_headers {
1252            let Some((name, value)) = line.split_once(':') else {
1253                continue;
1254            };
1255            let name = name.trim();
1256            let value = value.trim();
1257            if name.is_empty() || already_has(&headers, name) {
1258                continue;
1259            }
1260            headers.insert(name.to_string(), value.to_string());
1261        }
1262
1263        // `--conformance-api-key` is conformance-test-specific (probe
1264        // pattern over multiple header names). Forwarding it to plain
1265        // bench as a simple header would mislead a user expecting the
1266        // probe behaviour, so we leave that path conformance-only and
1267        // surface a one-line note when it's set without `--conformance`.
1268        if !self.conformance && self.conformance_api_key.is_some() {
1269            TerminalReporter::print_warning(
1270                "--conformance-api-key only fires under --conformance. For plain bench use --header 'X-API-Key: ...'.",
1271            );
1272        }
1273
1274        Ok(headers)
1275    }
1276
1277    fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
1278        let extracted_path = output_dir.join("extracted_values.json");
1279        if !extracted_path.exists() {
1280            return Ok(ExtractedValues::new());
1281        }
1282
1283        let content = std::fs::read_to_string(&extracted_path)
1284            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1285        let parsed: serde_json::Value = serde_json::from_str(&content)
1286            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1287
1288        let mut extracted = ExtractedValues::new();
1289        if let Some(values) = parsed.as_object() {
1290            for (key, value) in values {
1291                extracted.set(key.clone(), value.clone());
1292            }
1293        }
1294
1295        Ok(extracted)
1296    }
1297
1298    /// Resolve the effective base path for API endpoints
1299    ///
1300    /// Priority:
1301    /// 1. CLI --base-path option (if provided, even if empty string)
1302    /// 2. Base path extracted from OpenAPI spec's servers URL
1303    /// 3. None (no base path)
1304    ///
1305    /// An empty string from CLI explicitly disables base path.
1306    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
1307        // CLI option takes priority (including empty string to disable)
1308        if let Some(cli_base_path) = &self.base_path {
1309            if cli_base_path.is_empty() {
1310                // Empty string explicitly means "no base path"
1311                return None;
1312            }
1313            return Some(cli_base_path.clone());
1314        }
1315
1316        // Fall back to spec's base path
1317        parser.get_base_path()
1318    }
1319
1320    /// Build mock server integration configuration
1321    async fn build_mock_config(&self) -> MockIntegrationConfig {
1322        // Check if target looks like a mock server
1323        if MockServerDetector::looks_like_mock_server(&self.target) {
1324            // Try to detect if it's actually a MockForge server
1325            if let Ok(info) = MockServerDetector::detect(&self.target).await {
1326                if info.is_mockforge {
1327                    TerminalReporter::print_success(&format!(
1328                        "Detected MockForge server (version: {})",
1329                        info.version.as_deref().unwrap_or("unknown")
1330                    ));
1331                    return MockIntegrationConfig::mock_server();
1332                }
1333            }
1334        }
1335        MockIntegrationConfig::real_api()
1336    }
1337
1338    /// Build CRUD flow configuration
1339    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
1340        if !self.crud_flow {
1341            return None;
1342        }
1343
1344        // If flow_config file is provided, load it
1345        if let Some(config_path) = &self.flow_config {
1346            match CrudFlowConfig::from_file(config_path) {
1347                Ok(config) => return Some(config),
1348                Err(e) => {
1349                    TerminalReporter::print_warning(&format!(
1350                        "Failed to load flow config: {}. Using auto-detection.",
1351                        e
1352                    ));
1353                }
1354            }
1355        }
1356
1357        // Parse extract fields
1358        let extract_fields = self
1359            .extract_fields
1360            .as_ref()
1361            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
1362            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
1363
1364        Some(CrudFlowConfig {
1365            flows: Vec::new(), // Will be auto-detected
1366            default_extract_fields: extract_fields,
1367        })
1368    }
1369
1370    /// Build data-driven testing configuration
1371    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
1372        let data_file = self.data_file.as_ref()?;
1373
1374        let distribution = DataDistribution::from_str(&self.data_distribution)
1375            .unwrap_or(DataDistribution::UniquePerVu);
1376
1377        let mappings = self
1378            .data_mappings
1379            .as_ref()
1380            .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
1381            .unwrap_or_default();
1382
1383        Some(DataDrivenConfig {
1384            file_path: data_file.to_string_lossy().to_string(),
1385            distribution,
1386            mappings,
1387            csv_has_header: true,
1388            per_uri_control: self.per_uri_control,
1389            per_uri_columns: crate::data_driven::PerUriColumns::default(),
1390        })
1391    }
1392
1393    /// Build invalid data testing configuration
1394    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
1395        let error_rate = self.error_rate?;
1396
1397        let error_types = self
1398            .error_types
1399            .as_ref()
1400            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
1401            .unwrap_or_default();
1402
1403        Some(InvalidDataConfig {
1404            error_rate,
1405            error_types,
1406            target_fields: Vec::new(),
1407        })
1408    }
1409
1410    /// Build security testing configuration
1411    fn build_security_config(&self) -> Option<SecurityTestConfig> {
1412        if !self.security_test {
1413            return None;
1414        }
1415
1416        let categories = self
1417            .security_categories
1418            .as_ref()
1419            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
1420            .unwrap_or_else(|| {
1421                let mut default = HashSet::new();
1422                default.insert(SecurityCategory::SqlInjection);
1423                default.insert(SecurityCategory::Xss);
1424                default
1425            });
1426
1427        let target_fields = self
1428            .security_target_fields
1429            .as_ref()
1430            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
1431            .unwrap_or_default();
1432
1433        let custom_payloads_file =
1434            self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
1435
1436        Some(SecurityTestConfig {
1437            enabled: true,
1438            categories,
1439            target_fields,
1440            custom_payloads_file,
1441            include_high_risk: false,
1442        })
1443    }
1444
1445    /// Build parallel execution configuration
1446    fn build_parallel_config(&self) -> Option<ParallelConfig> {
1447        let count = self.parallel_create?;
1448
1449        Some(ParallelConfig::new(count))
1450    }
1451
1452    /// Load WAFBench payloads from the specified directory or pattern
1453    fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
1454        let Some(ref wafbench_dir) = self.wafbench_dir else {
1455            return Vec::new();
1456        };
1457
1458        let mut loader = WafBenchLoader::new();
1459
1460        if let Err(e) = loader.load_from_pattern(wafbench_dir) {
1461            TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
1462            return Vec::new();
1463        }
1464
1465        let stats = loader.stats();
1466
1467        if stats.files_processed == 0 {
1468            TerminalReporter::print_warning(&format!(
1469                "No WAFBench YAML files found matching '{}'",
1470                wafbench_dir
1471            ));
1472            // Also report any parse errors that may explain why no files were processed
1473            if !stats.parse_errors.is_empty() {
1474                TerminalReporter::print_warning("Some files were found but failed to parse:");
1475                for error in &stats.parse_errors {
1476                    TerminalReporter::print_warning(&format!("  - {}", error));
1477                }
1478            }
1479            return Vec::new();
1480        }
1481
1482        TerminalReporter::print_progress(&format!(
1483            "Loaded {} WAFBench files, {} test cases, {} payloads",
1484            stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
1485        ));
1486
1487        // Print category breakdown
1488        for (category, count) in &stats.by_category {
1489            TerminalReporter::print_progress(&format!("  - {}: {} tests", category, count));
1490        }
1491
1492        // Report any parse errors
1493        for error in &stats.parse_errors {
1494            TerminalReporter::print_warning(&format!("  Parse error: {}", error));
1495        }
1496
1497        loader.to_security_payloads()
1498    }
1499
1500    /// Generate enhanced k6 script with advanced features
1501    pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
1502        let mut enhanced_script = base_script.to_string();
1503        let mut additional_code = String::new();
1504
1505        // Add data-driven testing code
1506        if let Some(config) = self.build_data_driven_config() {
1507            TerminalReporter::print_progress("Adding data-driven testing support...");
1508            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
1509            additional_code.push('\n');
1510            TerminalReporter::print_success("Data-driven testing enabled");
1511        }
1512
1513        // Add invalid data generation code
1514        if let Some(config) = self.build_invalid_data_config() {
1515            TerminalReporter::print_progress("Adding invalid data testing support...");
1516            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
1517            additional_code.push('\n');
1518            additional_code
1519                .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
1520            additional_code.push('\n');
1521            additional_code
1522                .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
1523            additional_code.push('\n');
1524            TerminalReporter::print_success(&format!(
1525                "Invalid data testing enabled ({}% error rate)",
1526                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1527            ));
1528        }
1529
1530        // Add security testing code
1531        let security_config = self.build_security_config();
1532        let wafbench_payloads = self.load_wafbench_payloads();
1533        let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
1534
1535        if security_config.is_some() || !wafbench_payloads.is_empty() {
1536            TerminalReporter::print_progress("Adding security testing support...");
1537
1538            // Combine built-in payloads with WAFBench payloads
1539            let mut payload_list: Vec<SecurityPayload> = Vec::new();
1540
1541            if let Some(ref config) = security_config {
1542                payload_list.extend(SecurityPayloads::get_payloads(config));
1543            }
1544
1545            // Add WAFBench payloads
1546            if !wafbench_payloads.is_empty() {
1547                TerminalReporter::print_progress(&format!(
1548                    "Loading {} WAFBench attack patterns...",
1549                    wafbench_payloads.len()
1550                ));
1551                payload_list.extend(wafbench_payloads);
1552            }
1553
1554            let target_fields =
1555                security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1556
1557            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1558                &payload_list,
1559                self.wafbench_cycle_all,
1560            ));
1561            additional_code.push('\n');
1562            additional_code
1563                .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1564            additional_code.push('\n');
1565            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1566            additional_code.push('\n');
1567
1568            let mode = if self.wafbench_cycle_all {
1569                "cycle-all"
1570            } else {
1571                "random"
1572            };
1573            TerminalReporter::print_success(&format!(
1574                "Security testing enabled ({} payloads, {} mode)",
1575                payload_list.len(),
1576                mode
1577            ));
1578        } else if security_requested {
1579            // User requested security testing (e.g., --wafbench-dir) but no payloads were loaded.
1580            // The template has security_testing_enabled=true so it renders calling code.
1581            // We must inject stub definitions to avoid undefined function references.
1582            TerminalReporter::print_warning(
1583                "Security testing was requested but no payloads were loaded. \
1584                 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1585            );
1586            additional_code
1587                .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1588            additional_code.push('\n');
1589            additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1590            additional_code.push('\n');
1591        }
1592
1593        // Add parallel execution code
1594        if let Some(config) = self.build_parallel_config() {
1595            TerminalReporter::print_progress("Adding parallel execution support...");
1596            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1597            additional_code.push('\n');
1598            TerminalReporter::print_success(&format!(
1599                "Parallel execution enabled (count: {})",
1600                config.count
1601            ));
1602        }
1603
1604        // Insert additional code after the imports section
1605        if !additional_code.is_empty() {
1606            // Find the end of the import section
1607            if let Some(import_end) = enhanced_script.find("export const options") {
1608                enhanced_script.insert_str(
1609                    import_end,
1610                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1611                );
1612            }
1613        }
1614
1615        Ok(enhanced_script)
1616    }
1617
1618    /// Execute specs sequentially with dependency ordering and value passing
1619    async fn execute_sequential_specs(&self) -> Result<()> {
1620        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1621
1622        // Load all specs (without merging)
1623        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1624
1625        if !self.spec.is_empty() {
1626            let specs = load_specs_from_files(self.spec.clone())
1627                .await
1628                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1629            all_specs.extend(specs);
1630        }
1631
1632        if let Some(spec_dir) = &self.spec_dir {
1633            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1634                BenchError::Other(format!("Failed to load specs from directory: {}", e))
1635            })?;
1636            all_specs.extend(dir_specs);
1637        }
1638
1639        if all_specs.is_empty() {
1640            return Err(BenchError::Other(
1641                "No spec files found for sequential execution".to_string(),
1642            ));
1643        }
1644
1645        TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1646
1647        // Load dependency config or auto-detect
1648        let execution_order = if let Some(config_path) = &self.dependency_config {
1649            TerminalReporter::print_progress("Loading dependency configuration...");
1650            let config = SpecDependencyConfig::from_file(config_path)?;
1651
1652            if !config.disable_auto_detect && config.execution_order.is_empty() {
1653                // Auto-detect if config doesn't specify order
1654                self.detect_and_sort_specs(&all_specs)?
1655            } else {
1656                // Use configured order
1657                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1658            }
1659        } else {
1660            // Auto-detect dependencies
1661            self.detect_and_sort_specs(&all_specs)?
1662        };
1663
1664        TerminalReporter::print_success(&format!(
1665            "Execution order: {}",
1666            execution_order
1667                .iter()
1668                .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1669                .collect::<Vec<_>>()
1670                .join(" → ")
1671        ));
1672
1673        // Execute each spec in order
1674        let mut extracted_values = ExtractedValues::new();
1675        let total_specs = execution_order.len();
1676
1677        for (index, spec_path) in execution_order.iter().enumerate() {
1678            let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1679
1680            TerminalReporter::print_progress(&format!(
1681                "[{}/{}] Executing spec: {}",
1682                index + 1,
1683                total_specs,
1684                spec_name
1685            ));
1686
1687            // Find the spec in our loaded specs (match by full path or filename)
1688            let spec = all_specs
1689                .iter()
1690                .find(|(p, _)| {
1691                    p == spec_path
1692                        || p.file_name() == spec_path.file_name()
1693                        || p.file_name() == Some(spec_path.as_os_str())
1694                })
1695                .map(|(_, s)| s.clone())
1696                .ok_or_else(|| {
1697                    BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1698                })?;
1699
1700            // Execute this spec with any extracted values from previous specs
1701            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1702
1703            // Merge extracted values for the next spec
1704            extracted_values.merge(&new_values);
1705
1706            TerminalReporter::print_success(&format!(
1707                "[{}/{}] Completed: {} (extracted {} values)",
1708                index + 1,
1709                total_specs,
1710                spec_name,
1711                new_values.values.len()
1712            ));
1713        }
1714
1715        TerminalReporter::print_success(&format!(
1716            "Sequential execution complete: {} specs executed",
1717            total_specs
1718        ));
1719
1720        Ok(())
1721    }
1722
1723    /// Detect dependencies and return topologically sorted spec paths
1724    fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1725        TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1726
1727        let mut detector = DependencyDetector::new();
1728        let dependencies = detector.detect_dependencies(specs);
1729
1730        if dependencies.is_empty() {
1731            TerminalReporter::print_progress("No dependencies detected, using file order");
1732            return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1733        }
1734
1735        TerminalReporter::print_progress(&format!(
1736            "Detected {} cross-spec dependencies",
1737            dependencies.len()
1738        ));
1739
1740        for dep in &dependencies {
1741            TerminalReporter::print_progress(&format!(
1742                "  {} → {} (via field '{}')",
1743                dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1744                dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1745                dep.field_name
1746            ));
1747        }
1748
1749        topological_sort(specs, &dependencies)
1750    }
1751
1752    /// Execute a single spec and extract values for dependent specs
1753    async fn execute_single_spec(
1754        &self,
1755        spec: &OpenApiSpec,
1756        spec_name: &str,
1757        _external_values: &ExtractedValues,
1758    ) -> Result<ExtractedValues> {
1759        let parser = SpecParser::from_spec(spec.clone());
1760
1761        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
1762        if self.crud_flow {
1763            // Execute CRUD flow and extract values
1764            self.execute_crud_flow_with_extraction(&parser, spec_name).await
1765        } else {
1766            // Execute standard benchmark (no value extraction in non-CRUD mode)
1767            self.execute_standard_spec(&parser, spec_name).await?;
1768            Ok(ExtractedValues::new())
1769        }
1770    }
1771
1772    /// Execute CRUD flow with value extraction for sequential mode
1773    async fn execute_crud_flow_with_extraction(
1774        &self,
1775        parser: &SpecParser,
1776        spec_name: &str,
1777    ) -> Result<ExtractedValues> {
1778        let operations = parser.get_operations();
1779        let flows = CrudFlowDetector::detect_flows(&operations);
1780
1781        if flows.is_empty() {
1782            TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1783            return Ok(ExtractedValues::new());
1784        }
1785
1786        TerminalReporter::print_progress(&format!(
1787            "  {} CRUD flow(s) in {}",
1788            flows.len(),
1789            spec_name
1790        ));
1791
1792        // Generate and execute the CRUD flow script
1793        let mut handlebars = handlebars::Handlebars::new();
1794        // Register json helper for serializing arrays/objects in templates
1795        handlebars.register_helper(
1796            "json",
1797            Box::new(
1798                |h: &handlebars::Helper,
1799                 _: &handlebars::Handlebars,
1800                 _: &handlebars::Context,
1801                 _: &mut handlebars::RenderContext,
1802                 out: &mut dyn handlebars::Output|
1803                 -> handlebars::HelperResult {
1804                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1805                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1806                    Ok(())
1807                },
1808            ),
1809        );
1810        let template = include_str!("templates/k6_crud_flow.hbs");
1811        let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1812
1813        let custom_headers = self.parse_headers()?;
1814        let config = self.build_crud_flow_config().unwrap_or_default();
1815
1816        // Load parameter overrides if provided (for body configurations)
1817        let param_overrides = if let Some(params_file) = &self.params_file {
1818            let overrides = ParameterOverrides::from_file(params_file)?;
1819            Some(overrides)
1820        } else {
1821            None
1822        };
1823
1824        // Generate stages from scenario
1825        let duration_secs = Self::parse_duration(&self.duration)?;
1826        let scenario =
1827            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1828        let stages = scenario.generate_stages(duration_secs, self.vus);
1829
1830        // Resolve base path (CLI option takes priority over spec's servers URL)
1831        let api_base_path = self.resolve_base_path(parser);
1832
1833        // Build headers JSON string for the template
1834        let mut all_headers = custom_headers.clone();
1835        if let Some(auth) = &self.auth {
1836            all_headers.insert("Authorization".to_string(), auth.clone());
1837        }
1838        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1839
1840        // Track all dynamic placeholders across all operations
1841        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1842
1843        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1844            // Use the metric-name sanitizer (caps at 112 chars + hash suffix)
1845            // so deeply nested flow names don't blow past k6's 128-char limit
1846            // when concatenated with `_step{i}_latency`. See issue #79.
1847            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1848            serde_json::json!({
1849                "name": sanitized_name.clone(),
1850                "display_name": f.name,
1851                "base_path": f.base_path,
1852                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1853                    // Parse operation to get method and path
1854                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1855                    let method_raw = if !parts.is_empty() {
1856                        parts[0].to_uppercase()
1857                    } else {
1858                        "GET".to_string()
1859                    };
1860                    let method = if !parts.is_empty() {
1861                        let m = parts[0].to_lowercase();
1862                        // k6 uses 'del' for DELETE
1863                        if m == "delete" { "del".to_string() } else { m }
1864                    } else {
1865                        "get".to_string()
1866                    };
1867                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1868                    // Prepend API base path if configured
1869                    let path = if let Some(ref bp) = api_base_path {
1870                        format!("{}{}", bp, raw_path)
1871                    } else {
1872                        raw_path.to_string()
1873                    };
1874                    let is_get_or_head = method == "get" || method == "head";
1875                    // POST, PUT, PATCH typically have bodies
1876                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1877
1878                    // Look up body from params file if available
1879                    let body_value = if has_body {
1880                        param_overrides.as_ref()
1881                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1882                            .and_then(|oo| oo.body)
1883                            .unwrap_or_else(|| serde_json::json!({}))
1884                    } else {
1885                        serde_json::json!({})
1886                    };
1887
1888                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1889                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1890
1891                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1892                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1893                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1894
1895                    serde_json::json!({
1896                        "operation": s.operation,
1897                        "method": method,
1898                        "path": path,
1899                        "extract": s.extract,
1900                        "use_values": s.use_values,
1901                        "use_body": s.use_body,
1902                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1903                        "inject_attacks": s.inject_attacks,
1904                        "attack_types": s.attack_types,
1905                        "description": s.description,
1906                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1907                        "is_get_or_head": is_get_or_head,
1908                        "has_body": has_body,
1909                        "body": processed_body.value,
1910                        "body_is_dynamic": body_is_dynamic,
1911                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1912                    })
1913                }).collect::<Vec<_>>(),
1914            })
1915        }).collect();
1916
1917        // Collect all placeholders from all steps
1918        for flow_data in &flows_data {
1919            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1920                for step in steps {
1921                    if let Some(placeholders_arr) =
1922                        step.get("_placeholders").and_then(|p| p.as_array())
1923                    {
1924                        for p_str in placeholders_arr {
1925                            if let Some(p_name) = p_str.as_str() {
1926                                match p_name {
1927                                    "VU" => {
1928                                        all_placeholders.insert(DynamicPlaceholder::VU);
1929                                    }
1930                                    "Iteration" => {
1931                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1932                                    }
1933                                    "Timestamp" => {
1934                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1935                                    }
1936                                    "UUID" => {
1937                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1938                                    }
1939                                    "Random" => {
1940                                        all_placeholders.insert(DynamicPlaceholder::Random);
1941                                    }
1942                                    "Counter" => {
1943                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1944                                    }
1945                                    "Date" => {
1946                                        all_placeholders.insert(DynamicPlaceholder::Date);
1947                                    }
1948                                    "VuIter" => {
1949                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1950                                    }
1951                                    _ => {}
1952                                }
1953                            }
1954                        }
1955                    }
1956                }
1957            }
1958        }
1959
1960        // Get required imports and globals based on placeholders used
1961        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1962        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1963
1964        // Check if security testing is enabled
1965        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1966
1967        let data = serde_json::json!({
1968            "base_url": self.target,
1969            "flows": flows_data,
1970            "extract_fields": config.default_extract_fields,
1971            "duration_secs": duration_secs,
1972            "max_vus": self.vus,
1973            "auth_header": self.auth,
1974            "custom_headers": custom_headers,
1975            "skip_tls_verify": self.skip_tls_verify,
1976            // Add missing template fields
1977            "stages": stages.iter().map(|s| serde_json::json!({
1978                "duration": s.duration,
1979                "target": s.target,
1980            })).collect::<Vec<_>>(),
1981            "threshold_percentile": self.threshold_percentile,
1982            "threshold_ms": self.threshold_ms,
1983            "max_error_rate": self.max_error_rate,
1984            "headers": headers_json,
1985            "dynamic_imports": required_imports,
1986            "dynamic_globals": required_globals,
1987            "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
1988            // Security testing settings
1989            "security_testing_enabled": security_testing_enabled,
1990            "has_custom_headers": !custom_headers.is_empty(),
1991        });
1992
1993        let mut script = handlebars
1994            .render_template(template, &data)
1995            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1996
1997        // Enhance script with security testing support if enabled
1998        if security_testing_enabled {
1999            script = self.generate_enhanced_script(&script)?;
2000        }
2001
2002        // Write and execute script
2003        let script_path =
2004            self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
2005
2006        std::fs::create_dir_all(self.output.clone())?;
2007        std::fs::write(&script_path, &script)?;
2008
2009        if !self.generate_only {
2010            let executor = K6Executor::new()?
2011                .with_local_ips(self.source_ips.join(","))
2012                .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2013            std::fs::create_dir_all(&output_dir)?;
2014
2015            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2016
2017            let extracted = Self::parse_extracted_values(&output_dir)?;
2018            TerminalReporter::print_progress(&format!(
2019                "  Extracted {} value(s) from {}",
2020                extracted.values.len(),
2021                spec_name
2022            ));
2023            return Ok(extracted);
2024        }
2025
2026        Ok(ExtractedValues::new())
2027    }
2028
2029    /// Execute standard (non-CRUD) spec benchmark
2030    async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
2031        let mut operations = if let Some(filter) = &self.operations {
2032            parser.filter_operations(filter)?
2033        } else {
2034            parser.get_operations()
2035        };
2036
2037        if let Some(exclude) = &self.exclude_operations {
2038            operations = parser.exclude_operations(operations, exclude)?;
2039        }
2040
2041        if operations.is_empty() {
2042            TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
2043            return Ok(());
2044        }
2045
2046        TerminalReporter::print_progress(&format!(
2047            "  {} operations in {}",
2048            operations.len(),
2049            spec_name
2050        ));
2051
2052        // Generate request templates
2053        let templates: Vec<_> = operations
2054            .iter()
2055            .map(RequestGenerator::generate_template)
2056            .collect::<Result<Vec<_>>>()?;
2057
2058        // Parse headers
2059        let custom_headers = self.parse_headers()?;
2060
2061        // Resolve base path
2062        let base_path = self.resolve_base_path(parser);
2063
2064        // Generate k6 script
2065        let scenario =
2066            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2067
2068        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
2069
2070        let k6_config = K6Config {
2071            target_url: self.target.clone(),
2072            base_path,
2073            scenario,
2074            duration_secs: Self::parse_duration(&self.duration)?,
2075            max_vus: self.vus,
2076            threshold_percentile: self.threshold_percentile.clone(),
2077            threshold_ms: self.threshold_ms,
2078            max_error_rate: self.max_error_rate,
2079            auth_header: self.auth.clone(),
2080            custom_headers,
2081            skip_tls_verify: self.skip_tls_verify,
2082            security_testing_enabled,
2083            chunked_request_bodies: self.chunked_request_bodies,
2084            target_rps: self.target_rps,
2085            no_keep_alive: self.no_keep_alive,
2086            // Round 22.3 — see other K6Config site above.
2087            geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
2088                .into_iter()
2089                .map(|ip| ip.to_string())
2090                .collect(),
2091            geo_source_headers: if self.geo_source_headers.is_empty()
2092                && !self.geo_source_ips.is_empty()
2093            {
2094                crate::conformance::self_test::default_geo_source_headers()
2095            } else {
2096                self.geo_source_headers.clone()
2097            },
2098        };
2099
2100        let generator = K6ScriptGenerator::new(k6_config, templates);
2101        let mut script = generator.generate()?;
2102
2103        // Enhance script with advanced features (security testing, etc.)
2104        let has_advanced_features = self.data_file.is_some()
2105            || self.error_rate.is_some()
2106            || self.security_test
2107            || self.parallel_create.is_some()
2108            || self.wafbench_dir.is_some();
2109
2110        if has_advanced_features {
2111            script = self.generate_enhanced_script(&script)?;
2112        }
2113
2114        // Write and execute script
2115        let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
2116
2117        std::fs::create_dir_all(self.output.clone())?;
2118        std::fs::write(&script_path, &script)?;
2119
2120        if !self.generate_only {
2121            // Round 57 (#79) — honour `--discard-response-bodies` on the
2122            // standard (status-only) load path too.
2123            let executor = K6Executor::new()?
2124                .with_local_ips(self.source_ips.join(","))
2125                .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
2126                .with_discard_response_bodies(self.discard_response_bodies);
2127            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
2128            std::fs::create_dir_all(&output_dir)?;
2129
2130            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2131        }
2132
2133        Ok(())
2134    }
2135
2136    /// Execute CRUD flow testing mode
2137    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
2138        // Check if a custom flow config is provided
2139        let config = self.build_crud_flow_config().unwrap_or_default();
2140
2141        // Use flows from config if provided, otherwise auto-detect
2142        let flows = if !config.flows.is_empty() {
2143            TerminalReporter::print_progress("Using custom flow configuration...");
2144            config.flows.clone()
2145        } else {
2146            TerminalReporter::print_progress("Detecting CRUD operations...");
2147            let operations = parser.get_operations();
2148            CrudFlowDetector::detect_flows(&operations)
2149        };
2150
2151        if flows.is_empty() {
2152            return Err(BenchError::Other(
2153                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
2154            ));
2155        }
2156
2157        if config.flows.is_empty() {
2158            TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
2159        } else {
2160            TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
2161        }
2162
2163        for flow in &flows {
2164            TerminalReporter::print_progress(&format!(
2165                "  - {}: {} steps",
2166                flow.name,
2167                flow.steps.len()
2168            ));
2169        }
2170
2171        // Generate CRUD flow script
2172        let mut handlebars = handlebars::Handlebars::new();
2173        // Register json helper for serializing arrays/objects in templates
2174        handlebars.register_helper(
2175            "json",
2176            Box::new(
2177                |h: &handlebars::Helper,
2178                 _: &handlebars::Handlebars,
2179                 _: &handlebars::Context,
2180                 _: &mut handlebars::RenderContext,
2181                 out: &mut dyn handlebars::Output|
2182                 -> handlebars::HelperResult {
2183                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
2184                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
2185                    Ok(())
2186                },
2187            ),
2188        );
2189        let template = include_str!("templates/k6_crud_flow.hbs");
2190
2191        let custom_headers = self.parse_headers()?;
2192
2193        // Load parameter overrides if provided (for body configurations)
2194        let param_overrides = if let Some(params_file) = &self.params_file {
2195            TerminalReporter::print_progress("Loading parameter overrides...");
2196            let overrides = ParameterOverrides::from_file(params_file)?;
2197            TerminalReporter::print_success(&format!(
2198                "Loaded parameter overrides ({} operation-specific, {} defaults)",
2199                overrides.operations.len(),
2200                if overrides.defaults.is_empty() { 0 } else { 1 }
2201            ));
2202            Some(overrides)
2203        } else {
2204            None
2205        };
2206
2207        // Generate stages from scenario
2208        let duration_secs = Self::parse_duration(&self.duration)?;
2209        let scenario =
2210            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2211        let stages = scenario.generate_stages(duration_secs, self.vus);
2212
2213        // Resolve base path (CLI option takes priority over spec's servers URL)
2214        let api_base_path = self.resolve_base_path(parser);
2215        if let Some(ref bp) = api_base_path {
2216            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
2217        }
2218
2219        // Build headers JSON string for the template
2220        let mut all_headers = custom_headers.clone();
2221        if let Some(auth) = &self.auth {
2222            all_headers.insert("Authorization".to_string(), auth.clone());
2223        }
2224        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
2225
2226        // Track all dynamic placeholders across all operations
2227        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
2228
2229        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
2230            // Sanitize flow name for use as JavaScript variable and k6 metric names.
2231            // Use the metric-name sanitizer (caps at 112 chars + hash suffix) so
2232            // deeply nested flow names don't blow past k6's 128-char limit when
2233            // concatenated with `_step{i}_latency`. See issue #79.
2234            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
2235            serde_json::json!({
2236                "name": sanitized_name.clone(),  // Use sanitized name for variable names
2237                "display_name": f.name,          // Keep original for comments/display
2238                "base_path": f.base_path,
2239                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
2240                    // Parse operation to get method and path
2241                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
2242                    let method_raw = if !parts.is_empty() {
2243                        parts[0].to_uppercase()
2244                    } else {
2245                        "GET".to_string()
2246                    };
2247                    let method = if !parts.is_empty() {
2248                        let m = parts[0].to_lowercase();
2249                        // k6 uses 'del' for DELETE
2250                        if m == "delete" { "del".to_string() } else { m }
2251                    } else {
2252                        "get".to_string()
2253                    };
2254                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
2255                    // Prepend API base path if configured
2256                    let path = if let Some(ref bp) = api_base_path {
2257                        format!("{}{}", bp, raw_path)
2258                    } else {
2259                        raw_path.to_string()
2260                    };
2261                    let is_get_or_head = method == "get" || method == "head";
2262                    // POST, PUT, PATCH typically have bodies
2263                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
2264
2265                    // Look up body from params file if available (use raw_path for matching)
2266                    let body_value = if has_body {
2267                        param_overrides.as_ref()
2268                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
2269                            .and_then(|oo| oo.body)
2270                            .unwrap_or_else(|| serde_json::json!({}))
2271                    } else {
2272                        serde_json::json!({})
2273                    };
2274
2275                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
2276                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
2277                    // Note: all_placeholders is captured by the closure but we can't mutate it directly
2278                    // We'll collect placeholders separately below
2279
2280                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
2281                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
2282                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
2283
2284                    serde_json::json!({
2285                        "operation": s.operation,
2286                        "method": method,
2287                        "path": path,
2288                        "extract": s.extract,
2289                        "use_values": s.use_values,
2290                        "use_body": s.use_body,
2291                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
2292                        "inject_attacks": s.inject_attacks,
2293                        "attack_types": s.attack_types,
2294                        "description": s.description,
2295                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
2296                        "is_get_or_head": is_get_or_head,
2297                        "has_body": has_body,
2298                        "body": processed_body.value,
2299                        "body_is_dynamic": body_is_dynamic,
2300                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
2301                    })
2302                }).collect::<Vec<_>>(),
2303            })
2304        }).collect();
2305
2306        // Collect all placeholders from all steps
2307        for flow_data in &flows_data {
2308            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
2309                for step in steps {
2310                    if let Some(placeholders_arr) =
2311                        step.get("_placeholders").and_then(|p| p.as_array())
2312                    {
2313                        for p_str in placeholders_arr {
2314                            if let Some(p_name) = p_str.as_str() {
2315                                // Parse placeholder from debug string
2316                                match p_name {
2317                                    "VU" => {
2318                                        all_placeholders.insert(DynamicPlaceholder::VU);
2319                                    }
2320                                    "Iteration" => {
2321                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
2322                                    }
2323                                    "Timestamp" => {
2324                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
2325                                    }
2326                                    "UUID" => {
2327                                        all_placeholders.insert(DynamicPlaceholder::UUID);
2328                                    }
2329                                    "Random" => {
2330                                        all_placeholders.insert(DynamicPlaceholder::Random);
2331                                    }
2332                                    "Counter" => {
2333                                        all_placeholders.insert(DynamicPlaceholder::Counter);
2334                                    }
2335                                    "Date" => {
2336                                        all_placeholders.insert(DynamicPlaceholder::Date);
2337                                    }
2338                                    "VuIter" => {
2339                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
2340                                    }
2341                                    _ => {}
2342                                }
2343                            }
2344                        }
2345                    }
2346                }
2347            }
2348        }
2349
2350        // Get required imports and globals based on placeholders used
2351        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
2352        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
2353
2354        // Build invalid data config if error injection is enabled
2355        let invalid_data_config = self.build_invalid_data_config();
2356        let error_injection_enabled = invalid_data_config.is_some();
2357        let error_rate = self.error_rate.unwrap_or(0.0);
2358        let error_types: Vec<String> = invalid_data_config
2359            .as_ref()
2360            .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
2361            .unwrap_or_default();
2362
2363        if error_injection_enabled {
2364            TerminalReporter::print_progress(&format!(
2365                "Error injection enabled ({}% rate)",
2366                (error_rate * 100.0) as u32
2367            ));
2368        }
2369
2370        // Check if security testing is enabled
2371        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
2372
2373        let data = serde_json::json!({
2374            "base_url": self.target,
2375            "flows": flows_data,
2376            "extract_fields": config.default_extract_fields,
2377            "duration_secs": duration_secs,
2378            "max_vus": self.vus,
2379            "auth_header": self.auth,
2380            "custom_headers": custom_headers,
2381            "skip_tls_verify": self.skip_tls_verify,
2382            // Add missing template fields
2383            "stages": stages.iter().map(|s| serde_json::json!({
2384                "duration": s.duration,
2385                "target": s.target,
2386            })).collect::<Vec<_>>(),
2387            "threshold_percentile": self.threshold_percentile,
2388            "threshold_ms": self.threshold_ms,
2389            "max_error_rate": self.max_error_rate,
2390            "headers": headers_json,
2391            "dynamic_imports": required_imports,
2392            "dynamic_globals": required_globals,
2393            "extracted_values_output_path": self
2394                .output
2395                .join("crud_flow_extracted_values.json")
2396                .to_string_lossy(),
2397            // Error injection settings
2398            "error_injection_enabled": error_injection_enabled,
2399            "error_rate": error_rate,
2400            "error_types": error_types,
2401            // Security testing settings
2402            "security_testing_enabled": security_testing_enabled,
2403            "has_custom_headers": !custom_headers.is_empty(),
2404        });
2405
2406        let mut script = handlebars
2407            .render_template(template, &data)
2408            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
2409
2410        // Enhance script with security testing support if enabled
2411        if security_testing_enabled {
2412            script = self.generate_enhanced_script(&script)?;
2413        }
2414
2415        // Validate the generated CRUD flow script
2416        TerminalReporter::print_progress("Validating CRUD flow script...");
2417        let validation_errors = K6ScriptGenerator::validate_script(&script);
2418        if !validation_errors.is_empty() {
2419            TerminalReporter::print_error("CRUD flow script validation failed");
2420            for error in &validation_errors {
2421                eprintln!("  {}", error);
2422            }
2423            return Err(BenchError::Other(format!(
2424                "CRUD flow script validation failed with {} error(s)",
2425                validation_errors.len()
2426            )));
2427        }
2428
2429        TerminalReporter::print_success("CRUD flow script generated");
2430
2431        // Write and execute script
2432        let script_path = if let Some(output) = &self.script_output {
2433            output.clone()
2434        } else {
2435            self.output.join("k6-crud-flow-script.js")
2436        };
2437
2438        if let Some(parent) = script_path.parent() {
2439            std::fs::create_dir_all(parent)?;
2440        }
2441        std::fs::write(&script_path, &script)?;
2442        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2443
2444        if self.generate_only {
2445            println!("\nScript generated successfully. Run it with:");
2446            println!("  k6 run {}", script_path.display());
2447            return Ok(());
2448        }
2449
2450        // Execute k6
2451        TerminalReporter::print_progress("Executing CRUD flow test...");
2452        let executor = K6Executor::new()?
2453            .with_local_ips(self.source_ips.join(","))
2454            .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2455        std::fs::create_dir_all(&self.output)?;
2456
2457        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2458
2459        let duration_secs = Self::parse_duration(&self.duration)?;
2460        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
2461
2462        Ok(())
2463    }
2464
2465    /// Execute OpenAPI 3.0.0 conformance testing mode
2466    async fn execute_conformance_test(&self) -> Result<()> {
2467        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2468        use crate::conformance::report::ConformanceReport;
2469        use crate::conformance::spec::ConformanceFeature;
2470
2471        TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
2472
2473        // Conformance testing is a functional correctness check (1 VU, 1 iteration).
2474        // --vus and -d flags are always ignored in this mode.
2475        TerminalReporter::print_progress(
2476            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
2477        );
2478
2479        // Parse category filter
2480        let categories = self.conformance_categories.as_ref().map(|cats_str| {
2481            cats_str
2482                .split(',')
2483                .filter_map(|s| {
2484                    let trimmed = s.trim();
2485                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2486                        Some(canonical.to_string())
2487                    } else {
2488                        TerminalReporter::print_warning(&format!(
2489                            "Unknown conformance category: '{}'. Valid categories: {}",
2490                            trimmed,
2491                            ConformanceFeature::cli_category_names()
2492                                .iter()
2493                                .map(|(cli, _)| *cli)
2494                                .collect::<Vec<_>>()
2495                                .join(", ")
2496                        ));
2497                        None
2498                    }
2499                })
2500                .collect::<Vec<String>>()
2501        });
2502
2503        // Parse custom headers from "Key: Value" format
2504        let custom_headers: Vec<(String, String)> = self
2505            .conformance_headers
2506            .iter()
2507            .filter_map(|h| {
2508                let (name, value) = h.split_once(':')?;
2509                Some((name.trim().to_string(), value.trim().to_string()))
2510            })
2511            .collect();
2512
2513        if !custom_headers.is_empty() {
2514            TerminalReporter::print_progress(&format!(
2515                "Using {} custom header(s) for authentication",
2516                custom_headers.len()
2517            ));
2518        }
2519
2520        if self.conformance_delay_ms > 0 {
2521            TerminalReporter::print_progress(&format!(
2522                "Using {}ms delay between conformance requests",
2523                self.conformance_delay_ms
2524            ));
2525        }
2526
2527        // Ensure output dir exists so canonicalize works for the report path
2528        std::fs::create_dir_all(&self.output)?;
2529
2530        let config = ConformanceConfig {
2531            target_url: self.target.clone(),
2532            api_key: self.conformance_api_key.clone(),
2533            basic_auth: self.conformance_basic_auth.clone(),
2534            skip_tls_verify: self.skip_tls_verify,
2535            categories,
2536            base_path: self.base_path.clone(),
2537            custom_headers,
2538            output_dir: Some(self.output.clone()),
2539            all_operations: self.conformance_all_operations,
2540            custom_checks_file: self.conformance_custom.clone(),
2541            request_delay_ms: self.conformance_delay_ms,
2542            custom_filter: self.conformance_custom_filter.clone(),
2543            export_requests: self.export_requests,
2544            validate_requests: self.validate_requests,
2545        };
2546
2547        // Branch: spec-driven mode vs reference mode
2548        // Annotate operations if spec is provided (used by both native and k6 paths)
2549        // Round 18.1 — resolve the spec's base path so the self-test
2550        // path can prepend it to every URL. Pre-fix, self-test
2551        // ignored `--base-path /api` and hit the bare spec path,
2552        // returning 404 for every request on specs whose server is
2553        // proxied behind a base prefix.
2554        let mut resolved_base_path: Option<String> = None;
2555        let annotated_ops = if !self.spec.is_empty() {
2556            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2557            let parser = SpecParser::from_file(&self.spec[0]).await?;
2558            resolved_base_path = self.resolve_base_path(&parser);
2559
2560            // Issue #79 round 12 — Srikanth ran `--conformance --operations "GET,POST"`
2561            // and saw DELETE/PATCH exercised anyway. Conformance silently ignored
2562            // the filter. Apply it (and `--exclude-operations`) the same way the
2563            // regular bench path does so users can scope the run.
2564            let mut operations = if let Some(filter) = &self.operations {
2565                parser.filter_operations(filter)?
2566            } else {
2567                parser.get_operations()
2568            };
2569            if let Some(exclude) = &self.exclude_operations {
2570                let before_count = operations.len();
2571                operations = parser.exclude_operations(operations, exclude)?;
2572                let excluded_count = before_count - operations.len();
2573                if excluded_count > 0 {
2574                    TerminalReporter::print_progress(&format!(
2575                        "Excluded {} operations matching '{}'",
2576                        excluded_count, exclude
2577                    ));
2578                }
2579            }
2580
2581            let annotated =
2582                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2583                    &operations,
2584                    parser.spec(),
2585                );
2586            TerminalReporter::print_success(&format!(
2587                "Analyzed {} operations, found {} feature annotations",
2588                operations.len(),
2589                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2590            ));
2591            Some(annotated)
2592        } else {
2593            None
2594        };
2595
2596        // Issue #79 round 13 (4) — `--conformance-self-test` replaces
2597        // the standard conformance run with a positive + per-category
2598        // negative driver that verifies the server actually rejects
2599        // bad requests with 4xx. Wires the spec-annotated operations
2600        // through `conformance::self_test::run_self_test` and prints
2601        // the resulting pass/fail matrix.
2602        if self.conformance_self_test {
2603            let Some(ops) = annotated_ops else {
2604                TerminalReporter::print_error(
2605                    "--conformance-self-test requires --spec; no operations to test",
2606                );
2607                return Ok(());
2608            };
2609            let cfg = crate::conformance::self_test::SelfTestConfig {
2610                target_url: self.target.clone(),
2611                skip_tls_verify: self.skip_tls_verify,
2612                timeout: std::time::Duration::from_secs(30),
2613                // `custom_headers` was already moved into the
2614                // `ConformanceConfig` above; re-derive from `self` so
2615                // we don't borrow it twice.
2616                extra_headers: self
2617                    .conformance_headers
2618                    .iter()
2619                    .filter_map(|h| {
2620                        let (n, v) = h.split_once(':')?;
2621                        Some((n.trim().to_string(), v.trim().to_string()))
2622                    })
2623                    .collect(),
2624                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
2625                // Round 18.1 — honour `--base-path` (or the spec's
2626                // own first server prefix) so a deployment served
2627                // under a path-prefix doesn't 404 every positive.
2628                base_path: resolved_base_path.clone(),
2629                // Round 18.5 — GEODB multi-source-IP. Parse CLI IP
2630                // lists (malformed entries log a warning and are
2631                // dropped). Empty lists keep pre-18.5 behaviour.
2632                source_ips: parse_ip_list(&self.source_ips, "source-ip"),
2633                geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
2634                geo_source_headers: if self.geo_source_headers.is_empty() {
2635                    crate::conformance::self_test::default_geo_source_headers()
2636                } else {
2637                    self.geo_source_headers.clone()
2638                },
2639                // Round 23 (c-iii) — opt-in request/response capture.
2640                // Constructed here so the sink Arc outlives the run and
2641                // we can drain it for the JSONL write below.
2642                capture: if self.conformance_self_test_capture
2643                    || self.validate_response_schemas
2644                    || self.validate_requests
2645                {
2646                    // Schema validation reads the captured response
2647                    // body, so opt the user into capture implicitly
2648                    // when they ask for validation. The on-disk
2649                    // JSONL/HTML files only get written if the user
2650                    // also passed `--conformance-self-test-capture`.
2651                    // Round 56 (#79) — `--validate-requests` reads the
2652                    // captured *request* the same way, so enable capture
2653                    // for it too. Without this, `--validate-requests` on
2654                    // a self-test run had no in-memory requests to walk
2655                    // and silently wrote no violations file.
2656                    Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
2657                } else {
2658                    None
2659                },
2660                validate_response_schemas: self.validate_response_schemas,
2661                // Round 33 (#823) — basename of the spec, stamped on
2662                // every capture entry so the per-endpoint summary can
2663                // attribute rows back to the right spec on multi-spec
2664                // runs. Falls back to the full path string if the
2665                // basename can't be derived.
2666                spec_label: self.spec.first().map(|p| {
2667                    p.file_name()
2668                        .map(|s| s.to_string_lossy().into_owned())
2669                        .unwrap_or_else(|| p.to_string_lossy().into_owned())
2670                }),
2671                // Round 47 (#79) — always allocate the network-events
2672                // sink for self-test so the file is always written
2673                // (empty array when nothing failed — the cleanest
2674                // possible signal that connectivity stayed up). Caller
2675                // pays one Arc clone per probe, which is in the noise
2676                // next to the HTTP round-trip.
2677                network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
2678                current_iteration: 1,
2679            };
2680            let capture_sink = cfg.capture.clone();
2681            let network_events_sink = cfg.network_events.clone();
2682            TerminalReporter::print_progress(&format!(
2683                "Self-test mode: driving {} operations with positive + per-category negative cases",
2684                ops.len()
2685            ));
2686            // Round 47 (#79) — repeat the matrix per --conformance-
2687            // self-test-iterations / --conformance-self-test-duration.
2688            // Duration wins when both are set; iterations becomes the
2689            // floor so the matrix always runs at least the configured
2690            // number of times. Reports from each iteration are merged
2691            // by the per-category counter sum on the report itself.
2692            let target_iterations = self.conformance_self_test_iterations.max(1);
2693            let duration_budget = self
2694                .conformance_self_test_duration
2695                .as_ref()
2696                .map(|s| Self::parse_duration(s))
2697                .transpose()?
2698                .map(std::time::Duration::from_secs);
2699            let start = std::time::Instant::now();
2700            // Round 49 (#79) — Srikanth on 0.3.193: a 5m budget ran
2701            // 5:46 because the loop only checked the deadline AFTER a
2702            // full iteration completed. Pass the absolute deadline
2703            // into `run_self_test_with_deadline` so the runner can
2704            // break out mid-iteration the moment the budget elapses.
2705            // Iterations bound stays inclusive (so a duration-only run
2706            // doesn't loop forever on a fast spec) but stops EARLY when
2707            // the deadline hits first.
2708            let deadline = duration_budget.map(|d| start + d);
2709            // Round 49 — stamp `current_iteration` on cfg before each
2710            // pass so CaseCapture's `iteration` field carries the
2711            // loop counter (1-indexed).
2712            let mut cfg = cfg;
2713            cfg.current_iteration = 1;
2714            let mut report =
2715                crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
2716                    .await
2717                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2718            let mut iter_done: u32 = 1;
2719            loop {
2720                let by_iter = iter_done >= target_iterations;
2721                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
2722                if by_iter && by_dur {
2723                    break;
2724                }
2725                cfg.current_iteration = iter_done.saturating_add(1);
2726                let next = crate::conformance::self_test::run_self_test_with_deadline(
2727                    &ops, &cfg, deadline,
2728                )
2729                .await
2730                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2731                report.merge_iteration(next);
2732                iter_done = iter_done.saturating_add(1);
2733            }
2734            if iter_done > 1 {
2735                TerminalReporter::print_progress(&format!(
2736                    "Self-test repeated {} iteration(s) ({:.1?} elapsed)",
2737                    iter_done,
2738                    start.elapsed(),
2739                ));
2740            }
2741            // Round 23 (c-iii) — drain the capture sink into a JSONL
2742            // file next to the JSON/HTML report. One CaseCapture per
2743            // line so the file is grep-able / streamable. Round 24
2744            // (d) — also emit a self-contained HTML viewer at
2745            // `conformance-self-test-requests.html` for users who
2746            // want to browse the capture without piping through `jq`.
2747            // Round 32 (#79 / Srikanth) — derive the per-endpoint
2748            // traffic summary from the same in-memory capture sink so
2749            // we don't re-parse the JSONL from disk later.
2750            let per_endpoint_summary: Vec<
2751                crate::conformance::per_endpoint_summary::PerEndpointSummary,
2752            >;
2753            if let Some(sink) = capture_sink {
2754                if let Ok(guard) = sink.lock() {
2755                    let jsonl_path = self.output.join("conformance-self-test-requests.jsonl");
2756                    let mut lines = String::with_capacity(guard.len() * 256);
2757                    for entry in guard.iter() {
2758                        if let Ok(line) = serde_json::to_string(entry) {
2759                            lines.push_str(&line);
2760                            lines.push('\n');
2761                        }
2762                    }
2763                    let _ = std::fs::write(&jsonl_path, lines);
2764                    let html_path = self.output.join("conformance-self-test-requests.html");
2765                    let html =
2766                        crate::conformance::capture_html::render_capture_html(guard.as_slice());
2767                    let _ = std::fs::write(&html_path, html);
2768
2769                    // Round 32 — per-endpoint summary derived once from
2770                    // the same slice. Written as a JSON sidecar for
2771                    // automation and spliced into the HTML report below.
2772                    per_endpoint_summary =
2773                        crate::conformance::per_endpoint_summary::build_summary(guard.as_slice());
2774                    let summary_path = self.output.join("conformance-per-endpoint.json");
2775                    if let Ok(json) = serde_json::to_string_pretty(&per_endpoint_summary) {
2776                        let _ = std::fs::write(&summary_path, json);
2777                        TerminalReporter::print_progress(&format!(
2778                            "Self-test request/response capture written to {} ({} entries) + {} + {}",
2779                            jsonl_path.display(),
2780                            guard.len(),
2781                            html_path.display(),
2782                            summary_path.display(),
2783                        ));
2784                    } else {
2785                        TerminalReporter::print_progress(&format!(
2786                            "Self-test request/response capture written to {} ({} entries) + {}",
2787                            jsonl_path.display(),
2788                            guard.len(),
2789                            html_path.display(),
2790                        ));
2791                    }
2792                } else {
2793                    per_endpoint_summary = Vec::new();
2794                }
2795            } else {
2796                per_endpoint_summary = Vec::new();
2797            }
2798            TerminalReporter::print_progress(&report.render_summary());
2799            // Round 47 (#79) — drain the self-test wire-level
2800            // network-events sink into `conformance-network-events.json`
2801            // so the user has the same grep-able file the native
2802            // executor's r46 path produces. Empty array when nothing
2803            // failed (the cleanest signal that connectivity stayed up
2804            // throughout the run).
2805            if let Some(sink) = network_events_sink {
2806                if let Ok(guard) = sink.lock() {
2807                    let path = self.output.join("conformance-network-events.json");
2808                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
2809                        let _ = std::fs::write(&path, json);
2810                        if guard.is_empty() {
2811                            TerminalReporter::print_progress(
2812                                "No wire-level network failures during self-test (file written empty)",
2813                            );
2814                        } else {
2815                            TerminalReporter::print_warning(&format!(
2816                                "Recorded {} wire-level network event(s) to {}",
2817                                guard.len(),
2818                                path.display()
2819                            ));
2820                        }
2821                    }
2822                }
2823            }
2824            // Persist the JSON report alongside the regular conformance
2825            // report so it's grep-able next to the buffer dump from the
2826            // admin endpoint.
2827            let json_path = self.output.join("conformance-self-test.json");
2828            if let Ok(json) = serde_json::to_string_pretty(&report) {
2829                let _ = std::fs::write(&json_path, json);
2830                TerminalReporter::print_progress(&format!(
2831                    "Self-test report written to {}",
2832                    json_path.display()
2833                ));
2834            }
2835            // Round 58 (#79) — write the "definite issues" sidecar so the
2836            // unambiguous problems are grep-able / automatable without eyeballing
2837            // the caught/missed rollup.
2838            let issues = report.definite_issues();
2839            let issues_path = self.output.join("conformance-definite-issues.json");
2840            if let Ok(json) = serde_json::to_string_pretty(&issues) {
2841                if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
2842                    TerminalReporter::print_warning(&format!(
2843                        "{} definite issue(s) — see {}",
2844                        issues.len(),
2845                        issues_path.display()
2846                    ));
2847                }
2848            }
2849            // Round 59 (#79) — owasp injection payloads the target accepted, so
2850            // a WAF tester can grep which URLs let which payloads through.
2851            let owasp_accepted = report.owasp_accepted_probes();
2852            if !owasp_accepted.is_empty() {
2853                let owasp_path = self.output.join("conformance-owasp-accepted.json");
2854                if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
2855                    if std::fs::write(&owasp_path, json).is_ok() {
2856                        TerminalReporter::print_warning(&format!(
2857                            "{} owasp injection probe(s) accepted by the target — see {}",
2858                            owasp_accepted.len(),
2859                            owasp_path.display()
2860                        ));
2861                    }
2862                }
2863            }
2864            // Round 18.1 — surface the "every positive failed with
2865            // the same status" case loudly. Without this, a user
2866            // who forgot `--base-path /api` saw 404 for every
2867            // request, but the per-category negative rollup looked
2868            // all-green (because 404 is in the 4xx range the
2869            // negatives expect). Now the run is correctly called
2870            // out as misconfigured before showing the (meaningless)
2871            // negative results.
2872            if let Some(status) = report.detect_target_misconfiguration() {
2873                let hint = match status {
2874                    404 => " Likely cause: spec paths don't match deployed routes — check --base-path and the spec's `servers` block.",
2875                    401 | 403 => " Likely cause: authentication header is missing or invalid — check --conformance-header.",
2876                    _ => "",
2877                };
2878                TerminalReporter::print_warning(&format!(
2879                    "Self-test misconfiguration: every positive case returned {status}.{hint} Negative results below are meaningless under this condition."
2880                ));
2881            } else if !report.all_passed() {
2882                TerminalReporter::print_warning(
2883                    "Self-test detected gaps — server let through at least one request that should have been a 4xx",
2884                );
2885            } else {
2886                TerminalReporter::print_success(
2887                    "Self-test passed — all positive cases accepted and all negative cases rejected",
2888                );
2889            }
2890            // Round 17.6 — emit a self-contained HTML report alongside
2891            // the JSON. Groups by category and surfaces the missed-
2892            // negative list directly so a user doesn't need to grep
2893            // through the JSON to find which routes failed which
2894            // checks. Optionally folds in a round-17.4 spec audit
2895            // report if one exists in the same output directory.
2896            let html_path = self.output.join("conformance-report.html");
2897            let audit_path = self.output.join("conformance-spec-audit.json");
2898            let audit_value = std::fs::read_to_string(&audit_path)
2899                .ok()
2900                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
2901            // Round 21.1 — `--report-missed-cap N` lets the user
2902            // override the default 200-row HTML drill-down cap.
2903            // `--report-missed-cap 0` maps to `None` (no cap; show
2904            // everything). The JSON report always has the full set.
2905            let render_opts = crate::conformance::report_html::RenderOptions {
2906                missed_cap: match self.report_missed_cap {
2907                    Some(0) => None,
2908                    Some(n) => Some(n as usize),
2909                    None => Some(200),
2910                },
2911            };
2912            let mut html = crate::conformance::report_html::render_html_with_options(
2913                &report,
2914                audit_value.as_ref(),
2915                &render_opts,
2916            );
2917            // Round 32 (#79 / Srikanth) — splice the per-endpoint
2918            // summary just before the closing `</body>` so it lands at
2919            // the bottom of the report. Empty summary renders as an
2920            // empty string so we don't even introduce an extra newline
2921            // when there were no captures.
2922            let summary_section = crate::conformance::per_endpoint_summary::render_html_section(
2923                &per_endpoint_summary,
2924            );
2925            if !summary_section.is_empty() {
2926                if let Some(idx) = html.rfind("</body>") {
2927                    html.insert_str(idx, &summary_section);
2928                } else {
2929                    html.push_str(&summary_section);
2930                }
2931            }
2932            if std::fs::write(&html_path, html).is_ok() {
2933                TerminalReporter::print_progress(&format!(
2934                    "HTML report written to {}",
2935                    html_path.display()
2936                ));
2937            }
2938
2939            // Round 56 (#79) — Srikanth on 0.3.203: "parameter violations
2940            // are still absent from the logs." Root cause: the SINGLE-target
2941            // self-test returned here without ever walking the emitted
2942            // requests. Only the r49 multi-target self-test path (the
2943            // `--targets-file` workflow) called the validator. Mirror that
2944            // wiring here so a plain single-target self-test also writes
2945            // `conformance-request-violations.json`. The validator reads the
2946            // capture we just drained to the JSONL; my r56 change to
2947            // `request_validator.rs` records each `parameters:*` negative as
2948            // a `parameter_negative_probe` even when the emitted request is
2949            // spec-valid (so the three parameter probes stop being silent).
2950            if self.validate_requests && !self.spec.is_empty() {
2951                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
2952                    &self.spec,
2953                    &self.output,
2954                    self.base_path.as_deref(),
2955                )
2956                .await?;
2957                if n > 0 {
2958                    TerminalReporter::print_warning(&format!(
2959                        "{} emitted request(s) recorded against the spec — see conformance-request-violations.json",
2960                        n
2961                    ));
2962                }
2963            }
2964            return Ok(());
2965        }
2966
2967        // Request validation against OpenAPI spec (if --validate-requests is set)
2968        if self.validate_requests && !self.spec.is_empty() {
2969            TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
2970            let violation_count = crate::conformance::request_validator::run_request_validation(
2971                &self.spec,
2972                self.conformance_custom.as_deref(),
2973                self.base_path.as_deref(),
2974                &self.output,
2975            )
2976            .await?;
2977            if violation_count > 0 {
2978                TerminalReporter::print_warning(&format!(
2979                    "{} request validation violation(s) found — see conformance-request-violations.json",
2980                    violation_count
2981                ));
2982            } else {
2983                TerminalReporter::print_success("All requests conform to the OpenAPI spec");
2984            }
2985        }
2986
2987        // If generate-only OR --use-k6, use the k6 script generation path
2988        if self.generate_only || self.use_k6 {
2989            let script = if let Some(annotated) = &annotated_ops {
2990                let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2991                    config,
2992                    annotated.clone(),
2993                );
2994                let op_count = gen.operation_count();
2995                let (script, check_count) = gen.generate()?;
2996                TerminalReporter::print_success(&format!(
2997                    "Conformance: {} operations analyzed, {} unique checks generated",
2998                    op_count, check_count
2999                ));
3000                script
3001            } else {
3002                let generator = ConformanceGenerator::new(config);
3003                generator.generate()?
3004            };
3005
3006            let script_path = self.output.join("k6-conformance.js");
3007            std::fs::write(&script_path, &script).map_err(|e| {
3008                BenchError::Other(format!("Failed to write conformance script: {}", e))
3009            })?;
3010            TerminalReporter::print_success(&format!(
3011                "Conformance script generated: {}",
3012                script_path.display()
3013            ));
3014
3015            if self.generate_only {
3016                println!("\nScript generated. Run with:");
3017                println!("  k6 run {}", script_path.display());
3018                return Ok(());
3019            }
3020
3021            // --use-k6: execute via k6
3022            if !K6Executor::is_k6_installed() {
3023                TerminalReporter::print_error("k6 is not installed");
3024                TerminalReporter::print_warning(
3025                    "Install k6 from: https://k6.io/docs/get-started/installation/",
3026                );
3027                return Err(BenchError::K6NotFound);
3028            }
3029
3030            TerminalReporter::print_progress("Running conformance tests via k6...");
3031            let executor = K6Executor::new()?
3032                .with_local_ips(self.source_ips.join(","))
3033                .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3034            executor.execute(&script_path, Some(&self.output), self.verbose).await?;
3035
3036            let report_path = self.output.join("conformance-report.json");
3037            if report_path.exists() {
3038                let report = ConformanceReport::from_file(&report_path)?;
3039                report.print_report_with_options(self.conformance_all_operations);
3040                self.save_conformance_report(&report, &report_path)?;
3041            } else {
3042                TerminalReporter::print_warning(
3043                    "Conformance report not generated (k6 handleSummary may not have run)",
3044                );
3045            }
3046
3047            // Round 44 (#79) — Srikanth on 0.3.188: "Any reason why
3048            // validate-requests in mockforge client are not catching
3049            // all this query param or body params or path params
3050            // violation issues and record in conformance-request-
3051            // failure logs?" The custom-YAML validator only checks
3052            // the YAML shape at config time. Now, when both
3053            // `--validate-requests` and `--export-requests` are set,
3054            // also walk the emitted `conformance-requests.json` and
3055            // validate each actual wire-level request against the
3056            // spec. Violations are appended to
3057            // `conformance-request-violations.json`.
3058            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3059                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3060                    &self.spec,
3061                    &self.output,
3062                    self.base_path.as_deref(),
3063                )
3064                .await?;
3065                if n > 0 {
3066                    TerminalReporter::print_warning(&format!(
3067                        "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3068                        n
3069                    ));
3070                }
3071            }
3072
3073            return Ok(());
3074        }
3075
3076        // Default: Native Rust executor (no k6 dependency)
3077        TerminalReporter::print_progress("Running conformance tests (native executor)...");
3078
3079        let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3080
3081        // Round 39 (#79) — when the user passed `--conformance-custom`
3082        // WITHOUT `--spec` and without `--conformance-self-test`, fire
3083        // only the YAML's checks. The built-in 47 reference checks
3084        // (`param:path:string`, etc.) hit `/conformance/...` paths that
3085        // do not exist on a real target, so a custom-only run against
3086        // a remote API produced a flood of irrelevant 404s in the
3087        // request log. Srikanth on 0.3.183: "In the exported request I
3088        // see it is sending request to api/conformance/params/hello
3089        // and some other URLs".
3090        let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3091        executor = if let Some(annotated) = &annotated_ops {
3092            executor.with_spec_driven_checks(annotated)
3093        } else if custom_only {
3094            executor
3095        } else {
3096            executor.with_reference_checks()
3097        };
3098        executor = executor.with_custom_checks()?;
3099
3100        TerminalReporter::print_success(&format!(
3101            "Executing {} conformance checks...",
3102            executor.check_count()
3103        ));
3104
3105        let report = executor.execute().await?;
3106        report.print_report_with_options(self.conformance_all_operations);
3107
3108        // Save failure details to a separate file for easy debugging
3109        let failure_details = report.failure_details();
3110        if !failure_details.is_empty() {
3111            let details_path = self.output.join("conformance-failure-details.json");
3112            if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3113                let _ = std::fs::write(&details_path, json);
3114                TerminalReporter::print_success(&format!(
3115                    "Failure details saved to: {}",
3116                    details_path.display()
3117                ));
3118            }
3119        }
3120
3121        // Save report
3122        let report_path = self.output.join("conformance-report.json");
3123        let report_json = serde_json::to_string_pretty(&report.to_json())
3124            .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3125        std::fs::write(&report_path, &report_json)
3126            .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3127        TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
3128
3129        self.save_conformance_report(&report, &report_path)?;
3130
3131        // Round 45 (#79) — Srikanth on 0.3.189: "I am still not seeing
3132        // any conformance failure logs or conformance-request logs are
3133        // also not capturing any failures info." His command does NOT
3134        // pass `--use-k6`, so the round-44 wiring (which only ran on
3135        // the k6 branch) never fired. Mirror the same retrospective
3136        // pass here: when both `--validate-requests` and
3137        // `--export-requests` are set, walk the native executor's
3138        // freshly-written `conformance-requests.json` and validate
3139        // each emitted request against the spec. Violations are
3140        // appended to `conformance-request-violations.json`.
3141        if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3142            let n =
3143                crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3144                    &self.spec,
3145                    &self.output,
3146                    self.base_path.as_deref(),
3147                )
3148                .await?;
3149            if n > 0 {
3150                TerminalReporter::print_warning(&format!(
3151                    "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3152                    n
3153                ));
3154            }
3155        }
3156
3157        Ok(())
3158    }
3159
3160    /// Save conformance report in the requested format (SARIF or JSON copy)
3161    fn save_conformance_report(
3162        &self,
3163        report: &crate::conformance::report::ConformanceReport,
3164        report_path: &Path,
3165    ) -> Result<()> {
3166        if self.conformance_report_format == "sarif" {
3167            use crate::conformance::sarif::ConformanceSarifReport;
3168            ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
3169            TerminalReporter::print_success(&format!(
3170                "SARIF report saved to: {}",
3171                self.conformance_report.display()
3172            ));
3173        } else if self.conformance_report != *report_path {
3174            std::fs::copy(report_path, &self.conformance_report)?;
3175            TerminalReporter::print_success(&format!(
3176                "Report saved to: {}",
3177                self.conformance_report.display()
3178            ));
3179        }
3180        Ok(())
3181    }
3182
3183    /// Round 48 (#79) — Srikanth on 0.3.192: "I ran following commands
3184    /// to test conformance-sef-test duration, but the test ended
3185    /// immediately" with `--targets-file vs_list1.json`. The multi-
3186    /// target dispatch returned before reaching the round-47 self-test
3187    /// iteration loop. This helper runs the self-test driver against
3188    /// every target listed in `targets_file` honouring the same
3189    /// `--conformance-self-test-iterations` and `--conformance-self-
3190    /// test-duration` knobs the single-target path got. One self-test
3191    /// report file per target plus a `conformance-network-events.json`
3192    /// per target so a user can attribute wire failures back to the
3193    /// target they happened against.
3194    async fn execute_multi_target_self_test(&self, targets_file: &Path) -> Result<()> {
3195        use crate::conformance::self_test::SelfTestConfig;
3196
3197        TerminalReporter::print_progress("Multi-target conformance self-test mode");
3198        let targets = parse_targets_file(targets_file)?;
3199        if targets.is_empty() {
3200            return Err(BenchError::Other("No targets found in file".to_string()));
3201        }
3202        TerminalReporter::print_success(&format!("Loaded {} target(s)", targets.len()));
3203
3204        // Spec is shared across targets — load once.
3205        let annotated_ops = if !self.spec.is_empty() {
3206            let parser = SpecParser::from_file(&self.spec[0]).await?;
3207            let operations = parser.get_operations();
3208            Some(
3209                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3210                    &operations,
3211                    parser.spec(),
3212                ),
3213            )
3214        } else {
3215            return Err(BenchError::Other("--conformance-self-test requires --spec".to_string()));
3216        };
3217        let Some(ops) = annotated_ops else {
3218            unreachable!()
3219        };
3220
3221        std::fs::create_dir_all(&self.output)?;
3222        let resolved_base_path = self.base_path.clone();
3223        let target_iterations = self.conformance_self_test_iterations.max(1);
3224        let duration_budget = self
3225            .conformance_self_test_duration
3226            .as_ref()
3227            .map(|s| Self::parse_duration(s))
3228            .transpose()?
3229            .map(std::time::Duration::from_secs);
3230
3231        for (idx, target) in targets.iter().enumerate() {
3232            let target_dir = self.output.join(format!("target_{}", idx));
3233            std::fs::create_dir_all(&target_dir)?;
3234            TerminalReporter::print_progress(&format!(
3235                "[target {}/{}] {}",
3236                idx + 1,
3237                targets.len(),
3238                target.url
3239            ));
3240
3241            let merged_headers: Vec<(String, String)> = self
3242                .conformance_headers
3243                .iter()
3244                .filter_map(|h| {
3245                    let (n, v) = h.split_once(':')?;
3246                    Some((n.trim().to_string(), v.trim().to_string()))
3247                })
3248                .collect();
3249
3250            let cfg = SelfTestConfig {
3251                target_url: target.url.clone(),
3252                skip_tls_verify: self.skip_tls_verify,
3253                timeout: std::time::Duration::from_secs(30),
3254                extra_headers: merged_headers,
3255                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
3256                base_path: resolved_base_path.clone(),
3257                source_ips: parse_ip_list(&self.source_ips, "source-ip"),
3258                geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
3259                geo_source_headers: if self.geo_source_headers.is_empty() {
3260                    crate::conformance::self_test::default_geo_source_headers()
3261                } else {
3262                    self.geo_source_headers.clone()
3263                },
3264                capture: if self.conformance_self_test_capture
3265                    || self.validate_response_schemas
3266                    || self.validate_requests
3267                {
3268                    // Round 56 (#79) — mirror the single-target path: the
3269                    // per-target request validator needs the captured
3270                    // requests, so `--validate-requests` implies capture.
3271                    Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
3272                } else {
3273                    None
3274                },
3275                validate_response_schemas: self.validate_response_schemas,
3276                spec_label: self.spec.first().map(|p| {
3277                    p.file_name()
3278                        .map(|s| s.to_string_lossy().into_owned())
3279                        .unwrap_or_else(|| p.to_string_lossy().into_owned())
3280                }),
3281                network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
3282                current_iteration: 1,
3283            };
3284            let capture_sink = cfg.capture.clone();
3285            let network_events_sink = cfg.network_events.clone();
3286
3287            let start = std::time::Instant::now();
3288            // Round 49 — pass an absolute deadline down so the loop
3289            // can break out mid-iteration once the budget elapses
3290            // instead of overshooting by a full pass.
3291            let deadline = duration_budget.map(|d| start + d);
3292            // Round 49 — stamp `current_iteration` on cfg before each
3293            // pass so CaseCapture's `iteration` field carries the
3294            // loop counter (1-indexed).
3295            let mut cfg = cfg;
3296            cfg.current_iteration = 1;
3297            let mut report =
3298                crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
3299                    .await
3300                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3301            let mut iter_done: u32 = 1;
3302            loop {
3303                let by_iter = iter_done >= target_iterations;
3304                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
3305                if by_iter && by_dur {
3306                    break;
3307                }
3308                cfg.current_iteration = iter_done.saturating_add(1);
3309                let next = crate::conformance::self_test::run_self_test_with_deadline(
3310                    &ops, &cfg, deadline,
3311                )
3312                .await
3313                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3314                report.merge_iteration(next);
3315                iter_done = iter_done.saturating_add(1);
3316            }
3317            if iter_done > 1 {
3318                TerminalReporter::print_progress(&format!(
3319                    "  ran {} iteration(s) in {:.1?}",
3320                    iter_done,
3321                    start.elapsed(),
3322                ));
3323            }
3324
3325            // Drain the per-target sinks.
3326            if let Some(sink) = capture_sink {
3327                if let Ok(guard) = sink.lock() {
3328                    let jsonl = target_dir.join("conformance-self-test-requests.jsonl");
3329                    let mut lines = String::with_capacity(guard.len() * 256);
3330                    for entry in guard.iter() {
3331                        if let Ok(line) = serde_json::to_string(entry) {
3332                            lines.push_str(&line);
3333                            lines.push('\n');
3334                        }
3335                    }
3336                    let _ = std::fs::write(&jsonl, lines);
3337                }
3338            }
3339            if let Some(sink) = network_events_sink {
3340                if let Ok(guard) = sink.lock() {
3341                    let path = target_dir.join("conformance-network-events.json");
3342                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
3343                        let _ = std::fs::write(&path, json);
3344                        if !guard.is_empty() {
3345                            TerminalReporter::print_warning(&format!(
3346                                "  recorded {} wire-level network event(s)",
3347                                guard.len()
3348                            ));
3349                        }
3350                    }
3351                }
3352            }
3353
3354            let json_path = target_dir.join("conformance-self-test.json");
3355            if let Ok(json) = serde_json::to_string_pretty(&report) {
3356                let _ = std::fs::write(&json_path, json);
3357            }
3358            // Round 58 (#79) — per-target "definite issues" sidecar (mirror of
3359            // the single-target path).
3360            let issues = report.definite_issues();
3361            if let Ok(json) = serde_json::to_string_pretty(&issues) {
3362                let issues_path = target_dir.join("conformance-definite-issues.json");
3363                if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
3364                    TerminalReporter::print_warning(&format!(
3365                        "  {} definite issue(s) — see {}",
3366                        issues.len(),
3367                        issues_path.display()
3368                    ));
3369                }
3370            }
3371            // Round 59 (#79) — per-target owasp-accepted sidecar (mirror).
3372            let owasp_accepted = report.owasp_accepted_probes();
3373            if !owasp_accepted.is_empty() {
3374                if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
3375                    let owasp_path = target_dir.join("conformance-owasp-accepted.json");
3376                    if std::fs::write(&owasp_path, json).is_ok() {
3377                        TerminalReporter::print_warning(&format!(
3378                            "  {} owasp injection probe(s) accepted by the target — see {}",
3379                            owasp_accepted.len(),
3380                            owasp_path.display()
3381                        ));
3382                    }
3383                }
3384            }
3385            TerminalReporter::print_progress(&report.render_summary());
3386
3387            // Round 49 (#79) — Srikanth on 0.3.193: "I am not seeing
3388            // any violation requests logs when running [self-test
3389            // + --targets-file]". `validate_emitted_requests` was
3390            // only wired into the bench-export path; self-test
3391            // writes its captures to `conformance-self-test-
3392            // requests.jsonl` instead. The validator now reads that
3393            // file too (see the JSONL branch in request_validator.rs),
3394            // so we just need to invoke it here per-target.
3395            if self.validate_requests {
3396                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3397                    &self.spec,
3398                    &target_dir,
3399                    self.base_path.as_deref(),
3400                )
3401                .await?;
3402                if n > 0 {
3403                    TerminalReporter::print_warning(&format!(
3404                        "  {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3405                        n,
3406                        target_dir.display(),
3407                    ));
3408                }
3409            }
3410        }
3411
3412        Ok(())
3413    }
3414
3415    /// Execute conformance tests against multiple targets from a targets file.
3416    ///
3417    /// Uses the native `NativeConformanceExecutor` (no k6 dependency). Targets are
3418    /// tested sequentially to avoid overwhelming them, and per-target headers from
3419    /// the targets file are merged with the base `--conformance-header` headers.
3420    async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
3421        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
3422        use crate::conformance::report::ConformanceReport;
3423        use crate::conformance::spec::ConformanceFeature;
3424
3425        TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
3426
3427        // Parse targets file
3428        TerminalReporter::print_progress("Parsing targets file...");
3429        let targets = parse_targets_file(targets_file)?;
3430        let num_targets = targets.len();
3431        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
3432
3433        if targets.is_empty() {
3434            return Err(BenchError::Other("No targets found in file".to_string()));
3435        }
3436
3437        TerminalReporter::print_progress(
3438            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
3439        );
3440
3441        // Parse category filter (shared across all targets)
3442        let categories = self.conformance_categories.as_ref().map(|cats_str| {
3443            cats_str
3444                .split(',')
3445                .filter_map(|s| {
3446                    let trimmed = s.trim();
3447                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
3448                        Some(canonical.to_string())
3449                    } else {
3450                        TerminalReporter::print_warning(&format!(
3451                            "Unknown conformance category: '{}'. Valid categories: {}",
3452                            trimmed,
3453                            ConformanceFeature::cli_category_names()
3454                                .iter()
3455                                .map(|(cli, _)| *cli)
3456                                .collect::<Vec<_>>()
3457                                .join(", ")
3458                        ));
3459                        None
3460                    }
3461                })
3462                .collect::<Vec<String>>()
3463        });
3464
3465        // Parse base custom headers from --conformance-header flags
3466        let base_custom_headers: Vec<(String, String)> = self
3467            .conformance_headers
3468            .iter()
3469            .filter_map(|h| {
3470                let (name, value) = h.split_once(':')?;
3471                Some((name.trim().to_string(), value.trim().to_string()))
3472            })
3473            .collect();
3474
3475        if !base_custom_headers.is_empty() {
3476            TerminalReporter::print_progress(&format!(
3477                "Using {} base custom header(s) for authentication",
3478                base_custom_headers.len()
3479            ));
3480        }
3481
3482        // Load spec once if provided (shared across all targets)
3483        let annotated_ops = if !self.spec.is_empty() {
3484            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
3485            let parser = SpecParser::from_file(&self.spec[0]).await?;
3486            let operations = parser.get_operations();
3487            let annotated =
3488                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3489                    &operations,
3490                    parser.spec(),
3491                );
3492            TerminalReporter::print_success(&format!(
3493                "Analyzed {} operations, found {} feature annotations",
3494                operations.len(),
3495                annotated.iter().map(|a| a.features.len()).sum::<usize>()
3496            ));
3497            Some(annotated)
3498        } else {
3499            None
3500        };
3501
3502        // Ensure output dir exists
3503        std::fs::create_dir_all(&self.output)?;
3504
3505        // Collect per-target results for the summary
3506        struct TargetResult {
3507            url: String,
3508            passed: usize,
3509            failed: usize,
3510            elapsed: std::time::Duration,
3511            report_json: serde_json::Value,
3512            owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
3513        }
3514
3515        let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
3516        let total_start = std::time::Instant::now();
3517
3518        for (idx, target) in targets.iter().enumerate() {
3519            tracing::info!(
3520                "Running conformance tests against target {}/{}: {}",
3521                idx + 1,
3522                num_targets,
3523                target.url
3524            );
3525            TerminalReporter::print_progress(&format!(
3526                "\n--- Target {}/{}: {} ---",
3527                idx + 1,
3528                num_targets,
3529                target.url
3530            ));
3531
3532            // Merge base headers with per-target headers
3533            let mut merged_headers = base_custom_headers.clone();
3534            if let Some(ref target_headers) = target.headers {
3535                for (name, value) in target_headers {
3536                    // Per-target headers override base headers with the same name
3537                    if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
3538                        existing.1 = value.clone();
3539                    } else {
3540                        merged_headers.push((name.clone(), value.clone()));
3541                    }
3542                }
3543            }
3544            // Add auth header if present on target
3545            if let Some(ref auth) = target.auth {
3546                if let Some(existing) =
3547                    merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
3548                {
3549                    existing.1 = auth.clone();
3550                } else {
3551                    merged_headers.push(("Authorization".to_string(), auth.clone()));
3552                }
3553            }
3554
3555            // Per-target output dir (used by both native and k6 paths).
3556            // Created before the config so we can point the k6 script's
3557            // handleSummary at the per-target directory rather than the shared
3558            // parent output dir (otherwise every target would overwrite the
3559            // same conformance-report.json).
3560            let target_dir = self.output.join(format!("target_{}", idx));
3561            std::fs::create_dir_all(&target_dir)?;
3562
3563            let config = ConformanceConfig {
3564                target_url: target.url.clone(),
3565                api_key: self.conformance_api_key.clone(),
3566                basic_auth: self.conformance_basic_auth.clone(),
3567                skip_tls_verify: self.skip_tls_verify,
3568                categories: categories.clone(),
3569                base_path: self.base_path.clone(),
3570                custom_headers: merged_headers,
3571                output_dir: Some(target_dir.clone()),
3572                all_operations: self.conformance_all_operations,
3573                custom_checks_file: self.conformance_custom.clone(),
3574                request_delay_ms: self.conformance_delay_ms,
3575                custom_filter: self.conformance_custom_filter.clone(),
3576                export_requests: self.export_requests,
3577                validate_requests: self.validate_requests,
3578            };
3579
3580            let target_start = std::time::Instant::now();
3581            let report = if self.use_k6 {
3582                if !K6Executor::is_k6_installed() {
3583                    TerminalReporter::print_error("k6 is not installed");
3584                    TerminalReporter::print_warning(
3585                        "Install k6 from: https://k6.io/docs/get-started/installation/",
3586                    );
3587                    return Err(BenchError::K6NotFound);
3588                }
3589
3590                let script = if let Some(ref annotated) = annotated_ops {
3591                    let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3592                        config.clone(),
3593                        annotated.clone(),
3594                    );
3595                    let (script, _check_count) = gen.generate()?;
3596                    script
3597                } else {
3598                    let generator = ConformanceGenerator::new(config.clone());
3599                    generator.generate()?
3600                };
3601
3602                let script_path = target_dir.join("k6-conformance.js");
3603                std::fs::write(&script_path, &script).map_err(|e| {
3604                    BenchError::Other(format!("Failed to write conformance script: {}", e))
3605                })?;
3606                TerminalReporter::print_success(&format!(
3607                    "Conformance script generated: {}",
3608                    script_path.display()
3609                ));
3610
3611                TerminalReporter::print_progress(&format!(
3612                    "Running conformance tests via k6 against {}...",
3613                    target.url
3614                ));
3615                let k6 = K6Executor::new()?
3616                    .with_local_ips(self.source_ips.join(","))
3617                    .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3618                // Unique k6 API port per target to avoid collisions.
3619                let api_port = 6565u16.saturating_add(idx as u16);
3620                k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
3621                    .await?;
3622
3623                let report_path = target_dir.join("conformance-report.json");
3624                if report_path.exists() {
3625                    ConformanceReport::from_file(&report_path)?
3626                } else {
3627                    TerminalReporter::print_warning(&format!(
3628                        "Conformance report not generated for target {} (k6 handleSummary may not have run)",
3629                        target.url
3630                    ));
3631                    continue;
3632                }
3633            } else {
3634                let mut executor =
3635                    crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3636
3637                // Round 39 (#79) — see custom_only comment above; same
3638                // logic applied to the multi-target branch.
3639                let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3640                executor = if let Some(ref annotated) = annotated_ops {
3641                    executor.with_spec_driven_checks(annotated)
3642                } else if custom_only {
3643                    executor
3644                } else {
3645                    executor.with_reference_checks()
3646                };
3647                executor = executor.with_custom_checks()?;
3648
3649                TerminalReporter::print_success(&format!(
3650                    "Executing {} conformance checks against {}...",
3651                    executor.check_count(),
3652                    target.url
3653                ));
3654
3655                executor.execute().await?
3656            };
3657            let target_elapsed = target_start.elapsed();
3658
3659            let report_json = report.to_json();
3660
3661            // Extract pass/fail from the summary in the JSON
3662            let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
3663            let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
3664            let total_checks = passed + failed;
3665            let rate = if total_checks == 0 {
3666                0.0
3667            } else {
3668                (passed as f64 / total_checks as f64) * 100.0
3669            };
3670
3671            TerminalReporter::print_success(&format!(
3672                "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
3673                target.url,
3674                passed,
3675                total_checks,
3676                rate,
3677                target_elapsed.as_secs_f64()
3678            ));
3679
3680            // Save per-target report (target_dir created above)
3681            let target_report_path = target_dir.join("conformance-report.json");
3682            let report_str = serde_json::to_string_pretty(&report_json)
3683                .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3684            std::fs::write(&target_report_path, &report_str)
3685                .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3686
3687            // Save failure details if any
3688            let failure_details = report.failure_details();
3689            if !failure_details.is_empty() {
3690                let details_path = target_dir.join("conformance-failure-details.json");
3691                if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3692                    let _ = std::fs::write(&details_path, json);
3693                }
3694            }
3695
3696            // Round 45 (#79) — Srikanth on 0.3.189: `conformance-request-
3697            // violations.json` was never being written in his
3698            // multi-target self-test run. The round-44 wiring sat on
3699            // the single-target branch only. Mirror it here, per
3700            // target_dir, so the multi-target case (his typical
3701            // workflow) actually surfaces wire-level violations.
3702            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3703                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3704                    &self.spec,
3705                    &target_dir,
3706                    self.base_path.as_deref(),
3707                )
3708                .await?;
3709                if n > 0 {
3710                    TerminalReporter::print_warning(&format!(
3711                        "Target {}: {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3712                        target.url,
3713                        n,
3714                        target_dir.display()
3715                    ));
3716                }
3717            }
3718
3719            // Compute OWASP coverage for this target
3720            let owasp_coverage = report.owasp_coverage_data();
3721
3722            target_results.push(TargetResult {
3723                url: target.url.clone(),
3724                passed,
3725                failed,
3726                elapsed: target_elapsed,
3727                report_json,
3728                owasp_coverage,
3729            });
3730        }
3731
3732        let total_elapsed = total_start.elapsed();
3733
3734        // Print summary table
3735        println!("\n{}", "=".repeat(80));
3736        println!("  Multi-Target Conformance Summary");
3737        println!("{}", "=".repeat(80));
3738        println!(
3739            "  {:<40} {:>8} {:>8} {:>8} {:>8}",
3740            "Target URL", "Passed", "Failed", "Rate", "Time"
3741        );
3742        println!("  {}", "-".repeat(76));
3743
3744        let mut total_passed = 0usize;
3745        let mut total_failed = 0usize;
3746
3747        for result in &target_results {
3748            let total_checks = result.passed + result.failed;
3749            let rate = if total_checks == 0 {
3750                0.0
3751            } else {
3752                (result.passed as f64 / total_checks as f64) * 100.0
3753            };
3754
3755            // Truncate long URLs for display
3756            let display_url = if result.url.len() > 38 {
3757                format!("{}...", &result.url[..35])
3758            } else {
3759                result.url.clone()
3760            };
3761
3762            println!(
3763                "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3764                display_url,
3765                result.passed,
3766                result.failed,
3767                rate,
3768                result.elapsed.as_secs_f64()
3769            );
3770
3771            total_passed += result.passed;
3772            total_failed += result.failed;
3773        }
3774
3775        let grand_total = total_passed + total_failed;
3776        let overall_rate = if grand_total == 0 {
3777            0.0
3778        } else {
3779            (total_passed as f64 / grand_total as f64) * 100.0
3780        };
3781
3782        println!("  {}", "-".repeat(76));
3783        println!(
3784            "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3785            format!("TOTAL ({} targets)", num_targets),
3786            total_passed,
3787            total_failed,
3788            overall_rate,
3789            total_elapsed.as_secs_f64()
3790        );
3791        println!("{}", "=".repeat(80));
3792
3793        // Print per-target OWASP coverage
3794        for result in &target_results {
3795            println!("\n  OWASP API Security Top 10 Coverage for {}:", result.url);
3796            for entry in &result.owasp_coverage {
3797                let status = if !entry.tested {
3798                    "-"
3799                } else if entry.all_passed {
3800                    "pass"
3801                } else {
3802                    "FAIL"
3803                };
3804                let via = if entry.via_categories.is_empty() {
3805                    String::new()
3806                } else {
3807                    format!(" (via {})", entry.via_categories.join(", "))
3808                };
3809                println!("    {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
3810            }
3811        }
3812
3813        // Save combined summary
3814        let per_target_summaries: Vec<serde_json::Value> = target_results
3815            .iter()
3816            .enumerate()
3817            .map(|(idx, r)| {
3818                let total_checks = r.passed + r.failed;
3819                let rate = if total_checks == 0 {
3820                    0.0
3821                } else {
3822                    (r.passed as f64 / total_checks as f64) * 100.0
3823                };
3824                let owasp_json: Vec<serde_json::Value> = r
3825                    .owasp_coverage
3826                    .iter()
3827                    .map(|e| {
3828                        serde_json::json!({
3829                            "id": e.id,
3830                            "name": e.name,
3831                            "tested": e.tested,
3832                            "all_passed": e.all_passed,
3833                            "via_categories": e.via_categories,
3834                        })
3835                    })
3836                    .collect();
3837                serde_json::json!({
3838                    "target_url": r.url,
3839                    "target_index": idx,
3840                    "checks_passed": r.passed,
3841                    "checks_failed": r.failed,
3842                    "total_checks": total_checks,
3843                    "pass_rate": rate,
3844                    "elapsed_seconds": r.elapsed.as_secs_f64(),
3845                    "report": r.report_json,
3846                    "owasp_coverage": owasp_json,
3847                })
3848            })
3849            .collect();
3850
3851        let combined_summary = serde_json::json!({
3852            "total_targets": num_targets,
3853            "total_checks_passed": total_passed,
3854            "total_checks_failed": total_failed,
3855            "overall_pass_rate": overall_rate,
3856            "total_elapsed_seconds": total_elapsed.as_secs_f64(),
3857            "targets": per_target_summaries,
3858        });
3859
3860        let summary_path = self.output.join("multi-target-conformance-summary.json");
3861        let summary_str = serde_json::to_string_pretty(&combined_summary)
3862            .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
3863        std::fs::write(&summary_path, &summary_str)
3864            .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
3865        TerminalReporter::print_success(&format!(
3866            "Combined summary saved to: {}",
3867            summary_path.display()
3868        ));
3869
3870        Ok(())
3871    }
3872
3873    /// Execute OWASP API Security Top 10 testing mode
3874    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
3875        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
3876
3877        // Parse custom headers from CLI
3878        let custom_headers = self.parse_headers()?;
3879
3880        // Build OWASP configuration from CLI options
3881        let mut config = OwaspApiConfig::new()
3882            .with_auth_header(&self.owasp_auth_header)
3883            .with_verbose(self.verbose)
3884            .with_insecure(self.skip_tls_verify)
3885            .with_concurrency(self.vus as usize)
3886            .with_iterations(self.owasp_iterations as usize)
3887            .with_base_path(self.base_path.clone())
3888            .with_custom_headers(custom_headers);
3889
3890        // Set valid auth token if provided
3891        if let Some(ref token) = self.owasp_auth_token {
3892            config = config.with_valid_auth_token(token);
3893        }
3894
3895        // Parse categories if provided
3896        if let Some(ref cats_str) = self.owasp_categories {
3897            let categories: Vec<OwaspCategory> = cats_str
3898                .split(',')
3899                .filter_map(|s| {
3900                    let trimmed = s.trim();
3901                    match trimmed.parse::<OwaspCategory>() {
3902                        Ok(cat) => Some(cat),
3903                        Err(e) => {
3904                            TerminalReporter::print_warning(&e);
3905                            None
3906                        }
3907                    }
3908                })
3909                .collect();
3910
3911            if !categories.is_empty() {
3912                config = config.with_categories(categories);
3913            }
3914        }
3915
3916        // Load admin paths from file if provided
3917        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
3918            config.admin_paths_file = Some(admin_paths_file.clone());
3919            if let Err(e) = config.load_admin_paths() {
3920                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
3921            }
3922        }
3923
3924        // Set ID fields if provided
3925        if let Some(ref id_fields_str) = self.owasp_id_fields {
3926            let id_fields: Vec<String> = id_fields_str
3927                .split(',')
3928                .map(|s| s.trim().to_string())
3929                .filter(|s| !s.is_empty())
3930                .collect();
3931            if !id_fields.is_empty() {
3932                config = config.with_id_fields(id_fields);
3933            }
3934        }
3935
3936        // Set report path and format
3937        if let Some(ref report_path) = self.owasp_report {
3938            config = config.with_report_path(report_path);
3939        }
3940        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
3941            config = config.with_report_format(format);
3942        }
3943
3944        // Print configuration summary
3945        let categories = config.categories_to_test();
3946        TerminalReporter::print_success(&format!(
3947            "Testing {} OWASP categories: {}",
3948            categories.len(),
3949            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
3950        ));
3951
3952        if config.valid_auth_token.is_some() {
3953            TerminalReporter::print_progress("Using provided auth token for baseline requests");
3954        }
3955
3956        // Create the OWASP generator
3957        TerminalReporter::print_progress("Generating OWASP security test script...");
3958        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
3959
3960        // Generate the script
3961        let script = generator.generate()?;
3962        TerminalReporter::print_success("OWASP security test script generated");
3963
3964        // Write script to file
3965        let script_path = if let Some(output) = &self.script_output {
3966            output.clone()
3967        } else {
3968            self.output.join("k6-owasp-security-test.js")
3969        };
3970
3971        if let Some(parent) = script_path.parent() {
3972            std::fs::create_dir_all(parent)?;
3973        }
3974        std::fs::write(&script_path, &script)?;
3975        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
3976
3977        // If generate-only mode, exit here
3978        if self.generate_only {
3979            println!("\nOWASP security test script generated. Run it with:");
3980            println!("  k6 run {}", script_path.display());
3981            return Ok(());
3982        }
3983
3984        // Execute k6
3985        TerminalReporter::print_progress("Executing OWASP security tests...");
3986        let executor = K6Executor::new()?
3987            .with_local_ips(self.source_ips.join(","))
3988            .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3989        std::fs::create_dir_all(&self.output)?;
3990
3991        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
3992
3993        let duration_secs = Self::parse_duration(&self.duration)?;
3994        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
3995
3996        println!("\nOWASP security test results saved to: {}", self.output.display());
3997
3998        Ok(())
3999    }
4000}
4001
4002#[cfg(test)]
4003mod tests {
4004    use super::*;
4005    use tempfile::tempdir;
4006
4007    #[test]
4008    fn test_parse_duration() {
4009        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
4010        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
4011        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
4012        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
4013    }
4014
4015    /// Round 22.4 — `start-end` IPv4 range syntax for non-power-of-2
4016    /// ranges. Srikanth (h): `--source-ip 10.0.0.5-10.0.0.27` for 23
4017    /// hosts without finding a clean prefix.
4018    #[test]
4019    fn parse_ip_list_ipv4_range_inclusive() {
4020        let v = parse_ip_list(&["10.0.0.5-10.0.0.27".into()], "source-ip");
4021        assert_eq!(v.len(), 23);
4022        assert_eq!(v.first().unwrap().to_string(), "10.0.0.5");
4023        assert_eq!(v.last().unwrap().to_string(), "10.0.0.27");
4024    }
4025
4026    /// Round 22.4 — range with start > end is rejected with a warning
4027    /// (returns nothing for that entry rather than wrapping around).
4028    #[test]
4029    fn parse_ip_list_range_rejects_backwards() {
4030        let v = parse_ip_list(&["10.0.0.10-10.0.0.5".into()], "source-ip");
4031        assert!(v.is_empty(), "backwards range should produce no IPs; got {v:?}");
4032    }
4033
4034    /// Round 22.4 — IPv6 ranges are intentionally rejected because
4035    /// `2001:db8::1-2001:db8::5` would ambiguously parse against the
4036    /// address literal's `:` separators. Users use CIDR for IPv6.
4037    #[test]
4038    fn parse_ip_list_rejects_ipv6_range_syntax() {
4039        let v = parse_ip_list(&["2001:db8::1-2001:db8::5".into()], "geo-source-ip");
4040        assert!(v.is_empty(), "IPv6 range should be rejected; got {v:?}");
4041    }
4042
4043    /// Round 22.4 — range cap is the same 256 host limit as CIDR.
4044    #[test]
4045    fn parse_ip_list_range_capped_at_256() {
4046        let v = parse_ip_list(&["10.0.0.0-10.0.5.0".into()], "source-ip");
4047        assert_eq!(v.len(), 256);
4048        assert_eq!(v.first().unwrap().to_string(), "10.0.0.0");
4049    }
4050
4051    /// Round 19 — single IPs and comma-separated lists already
4052    /// worked in 18.5; this regression-locks the parse paths.
4053    #[test]
4054    fn parse_ip_list_plain_and_comma() {
4055        let v = parse_ip_list(&["10.0.0.5".into(), "10.0.0.6,10.0.0.7".into()], "source-ip");
4056        assert_eq!(v.len(), 3);
4057        assert_eq!(v[0].to_string(), "10.0.0.5");
4058        assert_eq!(v[2].to_string(), "10.0.0.7");
4059    }
4060
4061    /// Round 19 — IPv4 CIDR expands to host count up to the cap.
4062    /// `/29` = 8 hosts (well under cap), all 8 enumerated.
4063    #[test]
4064    fn parse_ip_list_ipv4_cidr_29_expands_to_8() {
4065        let v = parse_ip_list(&["10.0.0.0/29".into()], "source-ip");
4066        assert_eq!(v.len(), 8);
4067        assert_eq!(v[0].to_string(), "10.0.0.0");
4068        assert_eq!(v[7].to_string(), "10.0.0.7");
4069    }
4070
4071    /// Round 19 — IPv4 CIDR larger than the cap is truncated, not
4072    /// rejected. Cap is 256; `/8` would be 16M without the guard.
4073    #[test]
4074    fn parse_ip_list_ipv4_cidr_8_capped_at_256() {
4075        let v = parse_ip_list(&["10.0.0.0/8".into()], "source-ip");
4076        assert_eq!(v.len(), 256);
4077        assert_eq!(v[0].to_string(), "10.0.0.0");
4078        assert_eq!(v[255].to_string(), "10.0.0.255");
4079    }
4080
4081    /// Round 19 — IPv6 CIDR also expands. `/126` = 4 hosts.
4082    #[test]
4083    fn parse_ip_list_ipv6_cidr_126_expands_to_4() {
4084        let v = parse_ip_list(&["2001:db8::/126".into()], "geo-source-ip");
4085        assert_eq!(v.len(), 4);
4086        assert!(v[0].is_ipv6());
4087        assert_eq!(v[0].to_string(), "2001:db8::");
4088        assert_eq!(v[3].to_string(), "2001:db8::3");
4089    }
4090
4091    /// Round 19 — mixed IPv4 + IPv6 + CIDR in one call works.
4092    #[test]
4093    fn parse_ip_list_mixed_v4_v6_cidr() {
4094        let v = parse_ip_list(&["10.0.0.0/30,2001:db8::1,203.0.113.42".into()], "geo-source-ip");
4095        assert_eq!(v.len(), 6); // 4 from /30 + 1 + 1
4096        assert!(v.iter().any(|ip| ip.to_string() == "2001:db8::1"));
4097        assert!(v.iter().any(|ip| ip.to_string() == "203.0.113.42"));
4098    }
4099
4100    /// Round 19 — malformed entries log and skip; the run continues
4101    /// with whatever resolved.
4102    #[test]
4103    fn parse_ip_list_skips_malformed() {
4104        let v = parse_ip_list(
4105            &[
4106                "10.0.0.5".into(),
4107                "not-an-ip".into(),
4108                "10.0.0.6".into(),
4109                "/24".into(),
4110                "1.2.3.4/200".into(),
4111            ],
4112            "source-ip",
4113        );
4114        assert_eq!(v.len(), 2);
4115        assert_eq!(v[0].to_string(), "10.0.0.5");
4116        assert_eq!(v[1].to_string(), "10.0.0.6");
4117    }
4118
4119    #[test]
4120    fn test_parse_duration_invalid() {
4121        assert!(BenchCommand::parse_duration("invalid").is_err());
4122        assert!(BenchCommand::parse_duration("30x").is_err());
4123    }
4124
4125    #[test]
4126    fn test_parse_headers() {
4127        let cmd = BenchCommand {
4128            spec: vec![PathBuf::from("test.yaml")],
4129            spec_dir: None,
4130            merge_conflicts: "error".to_string(),
4131            spec_mode: "merge".to_string(),
4132            dependency_config: None,
4133            target: "http://localhost".to_string(),
4134            base_path: None,
4135            duration: "1m".to_string(),
4136            vus: 10,
4137            scenario: "ramp-up".to_string(),
4138            operations: None,
4139            exclude_operations: None,
4140            auth: None,
4141            headers: vec![
4142                "X-API-Key:test123".to_string(),
4143                "X-Client-ID:client456".to_string(),
4144            ],
4145            output: PathBuf::from("output"),
4146            generate_only: false,
4147            script_output: None,
4148            threshold_percentile: "p(95)".to_string(),
4149            threshold_ms: 500,
4150            max_error_rate: 0.05,
4151            verbose: false,
4152            skip_tls_verify: false,
4153            chunked_request_bodies: false,
4154            target_rps: None,
4155            no_keep_alive: false,
4156            targets_file: None,
4157            max_concurrency: None,
4158            results_format: "both".to_string(),
4159            params_file: None,
4160            crud_flow: false,
4161            flow_config: None,
4162            extract_fields: None,
4163            parallel_create: None,
4164            data_file: None,
4165            data_distribution: "unique-per-vu".to_string(),
4166            data_mappings: None,
4167            per_uri_control: false,
4168            error_rate: None,
4169            error_types: None,
4170            security_test: false,
4171            security_payloads: None,
4172            security_categories: None,
4173            security_target_fields: None,
4174            wafbench_dir: None,
4175            wafbench_cycle_all: false,
4176            owasp_api_top10: false,
4177            owasp_categories: None,
4178            owasp_auth_header: "Authorization".to_string(),
4179            owasp_auth_token: None,
4180            owasp_admin_paths: None,
4181            owasp_id_fields: None,
4182            owasp_report: None,
4183            owasp_report_format: "json".to_string(),
4184            owasp_iterations: 1,
4185            conformance: false,
4186            conformance_api_key: None,
4187            conformance_basic_auth: None,
4188            conformance_report: PathBuf::from("conformance-report.json"),
4189            conformance_categories: None,
4190            conformance_report_format: "json".to_string(),
4191            conformance_headers: vec![],
4192            conformance_all_operations: false,
4193            conformance_custom: None,
4194            conformance_delay_ms: 0,
4195            use_k6: false,
4196            conformance_custom_filter: None,
4197            export_requests: false,
4198            validate_requests: false,
4199            conformance_self_test: false,
4200            conformance_self_test_capture: false,
4201            conformance_self_test_iterations: 1,
4202            conformance_self_test_duration: None,
4203            validate_response_schemas: false,
4204            source_ips: Vec::new(),
4205            geo_source_ips: Vec::new(),
4206            geo_source_headers: Vec::new(),
4207            report_missed_cap: None,
4208            discard_response_bodies: false,
4209            dns_policy: None,
4210        };
4211
4212        let headers = cmd.parse_headers().unwrap();
4213        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
4214        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
4215    }
4216
4217    #[test]
4218    fn test_parse_header_string_preserves_comma_in_value() {
4219        // #761: with one header per --headers flag, a comma in the value (e.g. a
4220        // Cookie expiry date) is preserved instead of being split into junk pairs.
4221        let inputs = vec![
4222            "Cookie:session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string(),
4223            "X-Trace:1".to_string(),
4224        ];
4225        let headers = parse_header_string(&inputs).unwrap();
4226        assert_eq!(
4227            headers.get("Cookie"),
4228            Some(&"session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string())
4229        );
4230        assert_eq!(headers.get("X-Trace"), Some(&"1".to_string()));
4231    }
4232
4233    #[test]
4234    fn test_get_spec_display_name() {
4235        let cmd = BenchCommand {
4236            spec: vec![PathBuf::from("test.yaml")],
4237            spec_dir: None,
4238            merge_conflicts: "error".to_string(),
4239            spec_mode: "merge".to_string(),
4240            dependency_config: None,
4241            target: "http://localhost".to_string(),
4242            base_path: None,
4243            duration: "1m".to_string(),
4244            vus: 10,
4245            scenario: "ramp-up".to_string(),
4246            operations: None,
4247            exclude_operations: None,
4248            auth: None,
4249            headers: Vec::new(),
4250            output: PathBuf::from("output"),
4251            generate_only: false,
4252            script_output: None,
4253            threshold_percentile: "p(95)".to_string(),
4254            threshold_ms: 500,
4255            max_error_rate: 0.05,
4256            verbose: false,
4257            skip_tls_verify: false,
4258            chunked_request_bodies: false,
4259            target_rps: None,
4260            no_keep_alive: false,
4261            targets_file: None,
4262            max_concurrency: None,
4263            results_format: "both".to_string(),
4264            params_file: None,
4265            crud_flow: false,
4266            flow_config: None,
4267            extract_fields: None,
4268            parallel_create: None,
4269            data_file: None,
4270            data_distribution: "unique-per-vu".to_string(),
4271            data_mappings: None,
4272            per_uri_control: false,
4273            error_rate: None,
4274            error_types: None,
4275            security_test: false,
4276            security_payloads: None,
4277            security_categories: None,
4278            security_target_fields: None,
4279            wafbench_dir: None,
4280            wafbench_cycle_all: false,
4281            owasp_api_top10: false,
4282            owasp_categories: None,
4283            owasp_auth_header: "Authorization".to_string(),
4284            owasp_auth_token: None,
4285            owasp_admin_paths: None,
4286            owasp_id_fields: None,
4287            owasp_report: None,
4288            owasp_report_format: "json".to_string(),
4289            owasp_iterations: 1,
4290            conformance: false,
4291            conformance_api_key: None,
4292            conformance_basic_auth: None,
4293            conformance_report: PathBuf::from("conformance-report.json"),
4294            conformance_categories: None,
4295            conformance_report_format: "json".to_string(),
4296            conformance_headers: vec![],
4297            conformance_all_operations: false,
4298            conformance_custom: None,
4299            conformance_delay_ms: 0,
4300            use_k6: false,
4301            conformance_custom_filter: None,
4302            export_requests: false,
4303            validate_requests: false,
4304            conformance_self_test: false,
4305            conformance_self_test_capture: false,
4306            conformance_self_test_iterations: 1,
4307            conformance_self_test_duration: None,
4308            validate_response_schemas: false,
4309            source_ips: Vec::new(),
4310            geo_source_ips: Vec::new(),
4311            geo_source_headers: Vec::new(),
4312            report_missed_cap: None,
4313            discard_response_bodies: false,
4314            dns_policy: None,
4315        };
4316
4317        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
4318
4319        // Test multiple specs
4320        let cmd_multi = BenchCommand {
4321            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
4322            spec_dir: None,
4323            merge_conflicts: "error".to_string(),
4324            spec_mode: "merge".to_string(),
4325            dependency_config: None,
4326            target: "http://localhost".to_string(),
4327            base_path: None,
4328            duration: "1m".to_string(),
4329            vus: 10,
4330            scenario: "ramp-up".to_string(),
4331            operations: None,
4332            exclude_operations: None,
4333            auth: None,
4334            headers: Vec::new(),
4335            output: PathBuf::from("output"),
4336            generate_only: false,
4337            script_output: None,
4338            threshold_percentile: "p(95)".to_string(),
4339            threshold_ms: 500,
4340            max_error_rate: 0.05,
4341            verbose: false,
4342            skip_tls_verify: false,
4343            chunked_request_bodies: false,
4344            target_rps: None,
4345            no_keep_alive: false,
4346            targets_file: None,
4347            max_concurrency: None,
4348            results_format: "both".to_string(),
4349            params_file: None,
4350            crud_flow: false,
4351            flow_config: None,
4352            extract_fields: None,
4353            parallel_create: None,
4354            data_file: None,
4355            data_distribution: "unique-per-vu".to_string(),
4356            data_mappings: None,
4357            per_uri_control: false,
4358            error_rate: None,
4359            error_types: None,
4360            security_test: false,
4361            security_payloads: None,
4362            security_categories: None,
4363            security_target_fields: None,
4364            wafbench_dir: None,
4365            wafbench_cycle_all: false,
4366            owasp_api_top10: false,
4367            owasp_categories: None,
4368            owasp_auth_header: "Authorization".to_string(),
4369            owasp_auth_token: None,
4370            owasp_admin_paths: None,
4371            owasp_id_fields: None,
4372            owasp_report: None,
4373            owasp_report_format: "json".to_string(),
4374            owasp_iterations: 1,
4375            conformance: false,
4376            conformance_api_key: None,
4377            conformance_basic_auth: None,
4378            conformance_report: PathBuf::from("conformance-report.json"),
4379            conformance_categories: None,
4380            conformance_report_format: "json".to_string(),
4381            conformance_headers: vec![],
4382            conformance_all_operations: false,
4383            conformance_custom: None,
4384            conformance_delay_ms: 0,
4385            use_k6: false,
4386            conformance_custom_filter: None,
4387            export_requests: false,
4388            validate_requests: false,
4389            conformance_self_test: false,
4390            conformance_self_test_capture: false,
4391            conformance_self_test_iterations: 1,
4392            conformance_self_test_duration: None,
4393            validate_response_schemas: false,
4394            source_ips: Vec::new(),
4395            geo_source_ips: Vec::new(),
4396            geo_source_headers: Vec::new(),
4397            report_missed_cap: None,
4398            discard_response_bodies: false,
4399            dns_policy: None,
4400        };
4401
4402        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
4403    }
4404
4405    #[test]
4406    fn test_parse_extracted_values_from_output_dir() {
4407        let dir = tempdir().unwrap();
4408        let path = dir.path().join("extracted_values.json");
4409        std::fs::write(
4410            &path,
4411            r#"{
4412  "pool_id": "abc123",
4413  "count": 0,
4414  "enabled": false,
4415  "metadata": { "owner": "team-a" }
4416}"#,
4417        )
4418        .unwrap();
4419
4420        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4421        assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
4422        assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
4423        assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
4424        assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
4425    }
4426
4427    #[test]
4428    fn test_parse_extracted_values_missing_file() {
4429        let dir = tempdir().unwrap();
4430        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4431        assert!(extracted.values.is_empty());
4432    }
4433}