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