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