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            };
2630            let capture_sink = cfg.capture.clone();
2631            let network_events_sink = cfg.network_events.clone();
2632            TerminalReporter::print_progress(&format!(
2633                "Self-test mode: driving {} operations with positive + per-category negative cases",
2634                ops.len()
2635            ));
2636            // Round 47 (#79) — repeat the matrix per --conformance-
2637            // self-test-iterations / --conformance-self-test-duration.
2638            // Duration wins when both are set; iterations becomes the
2639            // floor so the matrix always runs at least the configured
2640            // number of times. Reports from each iteration are merged
2641            // by the per-category counter sum on the report itself.
2642            let target_iterations = self.conformance_self_test_iterations.max(1);
2643            let duration_budget = self
2644                .conformance_self_test_duration
2645                .as_ref()
2646                .map(|s| Self::parse_duration(s))
2647                .transpose()?
2648                .map(std::time::Duration::from_secs);
2649            let start = std::time::Instant::now();
2650            let mut report = crate::conformance::self_test::run_self_test(&ops, &cfg)
2651                .await
2652                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2653            let mut iter_done: u32 = 1;
2654            loop {
2655                let by_iter = iter_done >= target_iterations;
2656                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
2657                if by_iter && by_dur {
2658                    break;
2659                }
2660                let next = crate::conformance::self_test::run_self_test(&ops, &cfg)
2661                    .await
2662                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2663                report.merge_iteration(next);
2664                iter_done = iter_done.saturating_add(1);
2665            }
2666            if iter_done > 1 {
2667                TerminalReporter::print_progress(&format!(
2668                    "Self-test repeated {} iteration(s) ({:.1?} elapsed)",
2669                    iter_done,
2670                    start.elapsed(),
2671                ));
2672            }
2673            // Round 23 (c-iii) — drain the capture sink into a JSONL
2674            // file next to the JSON/HTML report. One CaseCapture per
2675            // line so the file is grep-able / streamable. Round 24
2676            // (d) — also emit a self-contained HTML viewer at
2677            // `conformance-self-test-requests.html` for users who
2678            // want to browse the capture without piping through `jq`.
2679            // Round 32 (#79 / Srikanth) — derive the per-endpoint
2680            // traffic summary from the same in-memory capture sink so
2681            // we don't re-parse the JSONL from disk later.
2682            let per_endpoint_summary: Vec<
2683                crate::conformance::per_endpoint_summary::PerEndpointSummary,
2684            >;
2685            if let Some(sink) = capture_sink {
2686                if let Ok(guard) = sink.lock() {
2687                    let jsonl_path = self.output.join("conformance-self-test-requests.jsonl");
2688                    let mut lines = String::with_capacity(guard.len() * 256);
2689                    for entry in guard.iter() {
2690                        if let Ok(line) = serde_json::to_string(entry) {
2691                            lines.push_str(&line);
2692                            lines.push('\n');
2693                        }
2694                    }
2695                    let _ = std::fs::write(&jsonl_path, lines);
2696                    let html_path = self.output.join("conformance-self-test-requests.html");
2697                    let html =
2698                        crate::conformance::capture_html::render_capture_html(guard.as_slice());
2699                    let _ = std::fs::write(&html_path, html);
2700
2701                    // Round 32 — per-endpoint summary derived once from
2702                    // the same slice. Written as a JSON sidecar for
2703                    // automation and spliced into the HTML report below.
2704                    per_endpoint_summary =
2705                        crate::conformance::per_endpoint_summary::build_summary(guard.as_slice());
2706                    let summary_path = self.output.join("conformance-per-endpoint.json");
2707                    if let Ok(json) = serde_json::to_string_pretty(&per_endpoint_summary) {
2708                        let _ = std::fs::write(&summary_path, json);
2709                        TerminalReporter::print_progress(&format!(
2710                            "Self-test request/response capture written to {} ({} entries) + {} + {}",
2711                            jsonl_path.display(),
2712                            guard.len(),
2713                            html_path.display(),
2714                            summary_path.display(),
2715                        ));
2716                    } else {
2717                        TerminalReporter::print_progress(&format!(
2718                            "Self-test request/response capture written to {} ({} entries) + {}",
2719                            jsonl_path.display(),
2720                            guard.len(),
2721                            html_path.display(),
2722                        ));
2723                    }
2724                } else {
2725                    per_endpoint_summary = Vec::new();
2726                }
2727            } else {
2728                per_endpoint_summary = Vec::new();
2729            }
2730            TerminalReporter::print_progress(&report.render_summary());
2731            // Round 47 (#79) — drain the self-test wire-level
2732            // network-events sink into `conformance-network-events.json`
2733            // so the user has the same grep-able file the native
2734            // executor's r46 path produces. Empty array when nothing
2735            // failed (the cleanest signal that connectivity stayed up
2736            // throughout the run).
2737            if let Some(sink) = network_events_sink {
2738                if let Ok(guard) = sink.lock() {
2739                    let path = self.output.join("conformance-network-events.json");
2740                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
2741                        let _ = std::fs::write(&path, json);
2742                        if guard.is_empty() {
2743                            TerminalReporter::print_progress(
2744                                "No wire-level network failures during self-test (file written empty)",
2745                            );
2746                        } else {
2747                            TerminalReporter::print_warning(&format!(
2748                                "Recorded {} wire-level network event(s) to {}",
2749                                guard.len(),
2750                                path.display()
2751                            ));
2752                        }
2753                    }
2754                }
2755            }
2756            // Persist the JSON report alongside the regular conformance
2757            // report so it's grep-able next to the buffer dump from the
2758            // admin endpoint.
2759            let json_path = self.output.join("conformance-self-test.json");
2760            if let Ok(json) = serde_json::to_string_pretty(&report) {
2761                let _ = std::fs::write(&json_path, json);
2762                TerminalReporter::print_progress(&format!(
2763                    "Self-test report written to {}",
2764                    json_path.display()
2765                ));
2766            }
2767            // Round 18.1 — surface the "every positive failed with
2768            // the same status" case loudly. Without this, a user
2769            // who forgot `--base-path /api` saw 404 for every
2770            // request, but the per-category negative rollup looked
2771            // all-green (because 404 is in the 4xx range the
2772            // negatives expect). Now the run is correctly called
2773            // out as misconfigured before showing the (meaningless)
2774            // negative results.
2775            if let Some(status) = report.detect_target_misconfiguration() {
2776                let hint = match status {
2777                    404 => " Likely cause: spec paths don't match deployed routes — check --base-path and the spec's `servers` block.",
2778                    401 | 403 => " Likely cause: authentication header is missing or invalid — check --conformance-header.",
2779                    _ => "",
2780                };
2781                TerminalReporter::print_warning(&format!(
2782                    "Self-test misconfiguration: every positive case returned {status}.{hint} Negative results below are meaningless under this condition."
2783                ));
2784            } else if !report.all_passed() {
2785                TerminalReporter::print_warning(
2786                    "Self-test detected gaps — server let through at least one request that should have been a 4xx",
2787                );
2788            } else {
2789                TerminalReporter::print_success(
2790                    "Self-test passed — all positive cases accepted and all negative cases rejected",
2791                );
2792            }
2793            // Round 17.6 — emit a self-contained HTML report alongside
2794            // the JSON. Groups by category and surfaces the missed-
2795            // negative list directly so a user doesn't need to grep
2796            // through the JSON to find which routes failed which
2797            // checks. Optionally folds in a round-17.4 spec audit
2798            // report if one exists in the same output directory.
2799            let html_path = self.output.join("conformance-report.html");
2800            let audit_path = self.output.join("conformance-spec-audit.json");
2801            let audit_value = std::fs::read_to_string(&audit_path)
2802                .ok()
2803                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
2804            // Round 21.1 — `--report-missed-cap N` lets the user
2805            // override the default 200-row HTML drill-down cap.
2806            // `--report-missed-cap 0` maps to `None` (no cap; show
2807            // everything). The JSON report always has the full set.
2808            let render_opts = crate::conformance::report_html::RenderOptions {
2809                missed_cap: match self.report_missed_cap {
2810                    Some(0) => None,
2811                    Some(n) => Some(n as usize),
2812                    None => Some(200),
2813                },
2814            };
2815            let mut html = crate::conformance::report_html::render_html_with_options(
2816                &report,
2817                audit_value.as_ref(),
2818                &render_opts,
2819            );
2820            // Round 32 (#79 / Srikanth) — splice the per-endpoint
2821            // summary just before the closing `</body>` so it lands at
2822            // the bottom of the report. Empty summary renders as an
2823            // empty string so we don't even introduce an extra newline
2824            // when there were no captures.
2825            let summary_section = crate::conformance::per_endpoint_summary::render_html_section(
2826                &per_endpoint_summary,
2827            );
2828            if !summary_section.is_empty() {
2829                if let Some(idx) = html.rfind("</body>") {
2830                    html.insert_str(idx, &summary_section);
2831                } else {
2832                    html.push_str(&summary_section);
2833                }
2834            }
2835            if std::fs::write(&html_path, html).is_ok() {
2836                TerminalReporter::print_progress(&format!(
2837                    "HTML report written to {}",
2838                    html_path.display()
2839                ));
2840            }
2841            return Ok(());
2842        }
2843
2844        // Request validation against OpenAPI spec (if --validate-requests is set)
2845        if self.validate_requests && !self.spec.is_empty() {
2846            TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
2847            let violation_count = crate::conformance::request_validator::run_request_validation(
2848                &self.spec,
2849                self.conformance_custom.as_deref(),
2850                self.base_path.as_deref(),
2851                &self.output,
2852            )
2853            .await?;
2854            if violation_count > 0 {
2855                TerminalReporter::print_warning(&format!(
2856                    "{} request validation violation(s) found — see conformance-request-violations.json",
2857                    violation_count
2858                ));
2859            } else {
2860                TerminalReporter::print_success("All requests conform to the OpenAPI spec");
2861            }
2862        }
2863
2864        // If generate-only OR --use-k6, use the k6 script generation path
2865        if self.generate_only || self.use_k6 {
2866            let script = if let Some(annotated) = &annotated_ops {
2867                let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2868                    config,
2869                    annotated.clone(),
2870                );
2871                let op_count = gen.operation_count();
2872                let (script, check_count) = gen.generate()?;
2873                TerminalReporter::print_success(&format!(
2874                    "Conformance: {} operations analyzed, {} unique checks generated",
2875                    op_count, check_count
2876                ));
2877                script
2878            } else {
2879                let generator = ConformanceGenerator::new(config);
2880                generator.generate()?
2881            };
2882
2883            let script_path = self.output.join("k6-conformance.js");
2884            std::fs::write(&script_path, &script).map_err(|e| {
2885                BenchError::Other(format!("Failed to write conformance script: {}", e))
2886            })?;
2887            TerminalReporter::print_success(&format!(
2888                "Conformance script generated: {}",
2889                script_path.display()
2890            ));
2891
2892            if self.generate_only {
2893                println!("\nScript generated. Run with:");
2894                println!("  k6 run {}", script_path.display());
2895                return Ok(());
2896            }
2897
2898            // --use-k6: execute via k6
2899            if !K6Executor::is_k6_installed() {
2900                TerminalReporter::print_error("k6 is not installed");
2901                TerminalReporter::print_warning(
2902                    "Install k6 from: https://k6.io/docs/get-started/installation/",
2903                );
2904                return Err(BenchError::K6NotFound);
2905            }
2906
2907            TerminalReporter::print_progress("Running conformance tests via k6...");
2908            let executor = K6Executor::new()?.with_local_ips(self.source_ips.join(","));
2909            executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2910
2911            let report_path = self.output.join("conformance-report.json");
2912            if report_path.exists() {
2913                let report = ConformanceReport::from_file(&report_path)?;
2914                report.print_report_with_options(self.conformance_all_operations);
2915                self.save_conformance_report(&report, &report_path)?;
2916            } else {
2917                TerminalReporter::print_warning(
2918                    "Conformance report not generated (k6 handleSummary may not have run)",
2919                );
2920            }
2921
2922            // Round 44 (#79) — Srikanth on 0.3.188: "Any reason why
2923            // validate-requests in mockforge client are not catching
2924            // all this query param or body params or path params
2925            // violation issues and record in conformance-request-
2926            // failure logs?" The custom-YAML validator only checks
2927            // the YAML shape at config time. Now, when both
2928            // `--validate-requests` and `--export-requests` are set,
2929            // also walk the emitted `conformance-requests.json` and
2930            // validate each actual wire-level request against the
2931            // spec. Violations are appended to
2932            // `conformance-request-violations.json`.
2933            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
2934                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
2935                    &self.spec,
2936                    &self.output,
2937                    self.base_path.as_deref(),
2938                )
2939                .await?;
2940                if n > 0 {
2941                    TerminalReporter::print_warning(&format!(
2942                        "{} emitted request(s) violated the spec — see conformance-request-violations.json",
2943                        n
2944                    ));
2945                }
2946            }
2947
2948            return Ok(());
2949        }
2950
2951        // Default: Native Rust executor (no k6 dependency)
2952        TerminalReporter::print_progress("Running conformance tests (native executor)...");
2953
2954        let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2955
2956        // Round 39 (#79) — when the user passed `--conformance-custom`
2957        // WITHOUT `--spec` and without `--conformance-self-test`, fire
2958        // only the YAML's checks. The built-in 47 reference checks
2959        // (`param:path:string`, etc.) hit `/conformance/...` paths that
2960        // do not exist on a real target, so a custom-only run against
2961        // a remote API produced a flood of irrelevant 404s in the
2962        // request log. Srikanth on 0.3.183: "In the exported request I
2963        // see it is sending request to api/conformance/params/hello
2964        // and some other URLs".
2965        let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
2966        executor = if let Some(annotated) = &annotated_ops {
2967            executor.with_spec_driven_checks(annotated)
2968        } else if custom_only {
2969            executor
2970        } else {
2971            executor.with_reference_checks()
2972        };
2973        executor = executor.with_custom_checks()?;
2974
2975        TerminalReporter::print_success(&format!(
2976            "Executing {} conformance checks...",
2977            executor.check_count()
2978        ));
2979
2980        let report = executor.execute().await?;
2981        report.print_report_with_options(self.conformance_all_operations);
2982
2983        // Save failure details to a separate file for easy debugging
2984        let failure_details = report.failure_details();
2985        if !failure_details.is_empty() {
2986            let details_path = self.output.join("conformance-failure-details.json");
2987            if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2988                let _ = std::fs::write(&details_path, json);
2989                TerminalReporter::print_success(&format!(
2990                    "Failure details saved to: {}",
2991                    details_path.display()
2992                ));
2993            }
2994        }
2995
2996        // Save report
2997        let report_path = self.output.join("conformance-report.json");
2998        let report_json = serde_json::to_string_pretty(&report.to_json())
2999            .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3000        std::fs::write(&report_path, &report_json)
3001            .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3002        TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
3003
3004        self.save_conformance_report(&report, &report_path)?;
3005
3006        // Round 45 (#79) — Srikanth on 0.3.189: "I am still not seeing
3007        // any conformance failure logs or conformance-request logs are
3008        // also not capturing any failures info." His command does NOT
3009        // pass `--use-k6`, so the round-44 wiring (which only ran on
3010        // the k6 branch) never fired. Mirror the same retrospective
3011        // pass here: when both `--validate-requests` and
3012        // `--export-requests` are set, walk the native executor's
3013        // freshly-written `conformance-requests.json` and validate
3014        // each emitted request against the spec. Violations are
3015        // appended to `conformance-request-violations.json`.
3016        if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3017            let n =
3018                crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3019                    &self.spec,
3020                    &self.output,
3021                    self.base_path.as_deref(),
3022                )
3023                .await?;
3024            if n > 0 {
3025                TerminalReporter::print_warning(&format!(
3026                    "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3027                    n
3028                ));
3029            }
3030        }
3031
3032        Ok(())
3033    }
3034
3035    /// Save conformance report in the requested format (SARIF or JSON copy)
3036    fn save_conformance_report(
3037        &self,
3038        report: &crate::conformance::report::ConformanceReport,
3039        report_path: &Path,
3040    ) -> Result<()> {
3041        if self.conformance_report_format == "sarif" {
3042            use crate::conformance::sarif::ConformanceSarifReport;
3043            ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
3044            TerminalReporter::print_success(&format!(
3045                "SARIF report saved to: {}",
3046                self.conformance_report.display()
3047            ));
3048        } else if self.conformance_report != *report_path {
3049            std::fs::copy(report_path, &self.conformance_report)?;
3050            TerminalReporter::print_success(&format!(
3051                "Report saved to: {}",
3052                self.conformance_report.display()
3053            ));
3054        }
3055        Ok(())
3056    }
3057
3058    /// Round 48 (#79) — Srikanth on 0.3.192: "I ran following commands
3059    /// to test conformance-sef-test duration, but the test ended
3060    /// immediately" with `--targets-file vs_list1.json`. The multi-
3061    /// target dispatch returned before reaching the round-47 self-test
3062    /// iteration loop. This helper runs the self-test driver against
3063    /// every target listed in `targets_file` honouring the same
3064    /// `--conformance-self-test-iterations` and `--conformance-self-
3065    /// test-duration` knobs the single-target path got. One self-test
3066    /// report file per target plus a `conformance-network-events.json`
3067    /// per target so a user can attribute wire failures back to the
3068    /// target they happened against.
3069    async fn execute_multi_target_self_test(&self, targets_file: &Path) -> Result<()> {
3070        use crate::conformance::self_test::{run_self_test, SelfTestConfig};
3071
3072        TerminalReporter::print_progress("Multi-target conformance self-test mode");
3073        let targets = parse_targets_file(targets_file)?;
3074        if targets.is_empty() {
3075            return Err(BenchError::Other("No targets found in file".to_string()));
3076        }
3077        TerminalReporter::print_success(&format!("Loaded {} target(s)", targets.len()));
3078
3079        // Spec is shared across targets — load once.
3080        let annotated_ops = if !self.spec.is_empty() {
3081            let parser = SpecParser::from_file(&self.spec[0]).await?;
3082            let operations = parser.get_operations();
3083            Some(
3084                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3085                    &operations,
3086                    parser.spec(),
3087                ),
3088            )
3089        } else {
3090            return Err(BenchError::Other("--conformance-self-test requires --spec".to_string()));
3091        };
3092        let Some(ops) = annotated_ops else {
3093            unreachable!()
3094        };
3095
3096        std::fs::create_dir_all(&self.output)?;
3097        let resolved_base_path = self.base_path.clone();
3098        let target_iterations = self.conformance_self_test_iterations.max(1);
3099        let duration_budget = self
3100            .conformance_self_test_duration
3101            .as_ref()
3102            .map(|s| Self::parse_duration(s))
3103            .transpose()?
3104            .map(std::time::Duration::from_secs);
3105
3106        for (idx, target) in targets.iter().enumerate() {
3107            let target_dir = self.output.join(format!("target_{}", idx));
3108            std::fs::create_dir_all(&target_dir)?;
3109            TerminalReporter::print_progress(&format!(
3110                "[target {}/{}] {}",
3111                idx + 1,
3112                targets.len(),
3113                target.url
3114            ));
3115
3116            let merged_headers: Vec<(String, String)> = self
3117                .conformance_headers
3118                .iter()
3119                .filter_map(|h| {
3120                    let (n, v) = h.split_once(':')?;
3121                    Some((n.trim().to_string(), v.trim().to_string()))
3122                })
3123                .collect();
3124
3125            let cfg = SelfTestConfig {
3126                target_url: target.url.clone(),
3127                skip_tls_verify: self.skip_tls_verify,
3128                timeout: std::time::Duration::from_secs(30),
3129                extra_headers: merged_headers,
3130                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
3131                base_path: resolved_base_path.clone(),
3132                source_ips: parse_ip_list(&self.source_ips, "source-ip"),
3133                geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
3134                geo_source_headers: if self.geo_source_headers.is_empty() {
3135                    crate::conformance::self_test::default_geo_source_headers()
3136                } else {
3137                    self.geo_source_headers.clone()
3138                },
3139                capture: if self.conformance_self_test_capture || self.validate_response_schemas {
3140                    Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
3141                } else {
3142                    None
3143                },
3144                validate_response_schemas: self.validate_response_schemas,
3145                spec_label: self.spec.first().map(|p| {
3146                    p.file_name()
3147                        .map(|s| s.to_string_lossy().into_owned())
3148                        .unwrap_or_else(|| p.to_string_lossy().into_owned())
3149                }),
3150                network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
3151            };
3152            let capture_sink = cfg.capture.clone();
3153            let network_events_sink = cfg.network_events.clone();
3154
3155            let start = std::time::Instant::now();
3156            let mut report = run_self_test(&ops, &cfg)
3157                .await
3158                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3159            let mut iter_done: u32 = 1;
3160            loop {
3161                let by_iter = iter_done >= target_iterations;
3162                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
3163                if by_iter && by_dur {
3164                    break;
3165                }
3166                let next = run_self_test(&ops, &cfg)
3167                    .await
3168                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3169                report.merge_iteration(next);
3170                iter_done = iter_done.saturating_add(1);
3171            }
3172            if iter_done > 1 {
3173                TerminalReporter::print_progress(&format!(
3174                    "  ran {} iteration(s) in {:.1?}",
3175                    iter_done,
3176                    start.elapsed(),
3177                ));
3178            }
3179
3180            // Drain the per-target sinks.
3181            if let Some(sink) = capture_sink {
3182                if let Ok(guard) = sink.lock() {
3183                    let jsonl = target_dir.join("conformance-self-test-requests.jsonl");
3184                    let mut lines = String::with_capacity(guard.len() * 256);
3185                    for entry in guard.iter() {
3186                        if let Ok(line) = serde_json::to_string(entry) {
3187                            lines.push_str(&line);
3188                            lines.push('\n');
3189                        }
3190                    }
3191                    let _ = std::fs::write(&jsonl, lines);
3192                }
3193            }
3194            if let Some(sink) = network_events_sink {
3195                if let Ok(guard) = sink.lock() {
3196                    let path = target_dir.join("conformance-network-events.json");
3197                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
3198                        let _ = std::fs::write(&path, json);
3199                        if !guard.is_empty() {
3200                            TerminalReporter::print_warning(&format!(
3201                                "  recorded {} wire-level network event(s)",
3202                                guard.len()
3203                            ));
3204                        }
3205                    }
3206                }
3207            }
3208
3209            let json_path = target_dir.join("conformance-self-test.json");
3210            if let Ok(json) = serde_json::to_string_pretty(&report) {
3211                let _ = std::fs::write(&json_path, json);
3212            }
3213            TerminalReporter::print_progress(&report.render_summary());
3214        }
3215
3216        Ok(())
3217    }
3218
3219    /// Execute conformance tests against multiple targets from a targets file.
3220    ///
3221    /// Uses the native `NativeConformanceExecutor` (no k6 dependency). Targets are
3222    /// tested sequentially to avoid overwhelming them, and per-target headers from
3223    /// the targets file are merged with the base `--conformance-header` headers.
3224    async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
3225        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
3226        use crate::conformance::report::ConformanceReport;
3227        use crate::conformance::spec::ConformanceFeature;
3228
3229        TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
3230
3231        // Parse targets file
3232        TerminalReporter::print_progress("Parsing targets file...");
3233        let targets = parse_targets_file(targets_file)?;
3234        let num_targets = targets.len();
3235        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
3236
3237        if targets.is_empty() {
3238            return Err(BenchError::Other("No targets found in file".to_string()));
3239        }
3240
3241        TerminalReporter::print_progress(
3242            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
3243        );
3244
3245        // Parse category filter (shared across all targets)
3246        let categories = self.conformance_categories.as_ref().map(|cats_str| {
3247            cats_str
3248                .split(',')
3249                .filter_map(|s| {
3250                    let trimmed = s.trim();
3251                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
3252                        Some(canonical.to_string())
3253                    } else {
3254                        TerminalReporter::print_warning(&format!(
3255                            "Unknown conformance category: '{}'. Valid categories: {}",
3256                            trimmed,
3257                            ConformanceFeature::cli_category_names()
3258                                .iter()
3259                                .map(|(cli, _)| *cli)
3260                                .collect::<Vec<_>>()
3261                                .join(", ")
3262                        ));
3263                        None
3264                    }
3265                })
3266                .collect::<Vec<String>>()
3267        });
3268
3269        // Parse base custom headers from --conformance-header flags
3270        let base_custom_headers: Vec<(String, String)> = self
3271            .conformance_headers
3272            .iter()
3273            .filter_map(|h| {
3274                let (name, value) = h.split_once(':')?;
3275                Some((name.trim().to_string(), value.trim().to_string()))
3276            })
3277            .collect();
3278
3279        if !base_custom_headers.is_empty() {
3280            TerminalReporter::print_progress(&format!(
3281                "Using {} base custom header(s) for authentication",
3282                base_custom_headers.len()
3283            ));
3284        }
3285
3286        // Load spec once if provided (shared across all targets)
3287        let annotated_ops = if !self.spec.is_empty() {
3288            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
3289            let parser = SpecParser::from_file(&self.spec[0]).await?;
3290            let operations = parser.get_operations();
3291            let annotated =
3292                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3293                    &operations,
3294                    parser.spec(),
3295                );
3296            TerminalReporter::print_success(&format!(
3297                "Analyzed {} operations, found {} feature annotations",
3298                operations.len(),
3299                annotated.iter().map(|a| a.features.len()).sum::<usize>()
3300            ));
3301            Some(annotated)
3302        } else {
3303            None
3304        };
3305
3306        // Ensure output dir exists
3307        std::fs::create_dir_all(&self.output)?;
3308
3309        // Collect per-target results for the summary
3310        struct TargetResult {
3311            url: String,
3312            passed: usize,
3313            failed: usize,
3314            elapsed: std::time::Duration,
3315            report_json: serde_json::Value,
3316            owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
3317        }
3318
3319        let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
3320        let total_start = std::time::Instant::now();
3321
3322        for (idx, target) in targets.iter().enumerate() {
3323            tracing::info!(
3324                "Running conformance tests against target {}/{}: {}",
3325                idx + 1,
3326                num_targets,
3327                target.url
3328            );
3329            TerminalReporter::print_progress(&format!(
3330                "\n--- Target {}/{}: {} ---",
3331                idx + 1,
3332                num_targets,
3333                target.url
3334            ));
3335
3336            // Merge base headers with per-target headers
3337            let mut merged_headers = base_custom_headers.clone();
3338            if let Some(ref target_headers) = target.headers {
3339                for (name, value) in target_headers {
3340                    // Per-target headers override base headers with the same name
3341                    if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
3342                        existing.1 = value.clone();
3343                    } else {
3344                        merged_headers.push((name.clone(), value.clone()));
3345                    }
3346                }
3347            }
3348            // Add auth header if present on target
3349            if let Some(ref auth) = target.auth {
3350                if let Some(existing) =
3351                    merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
3352                {
3353                    existing.1 = auth.clone();
3354                } else {
3355                    merged_headers.push(("Authorization".to_string(), auth.clone()));
3356                }
3357            }
3358
3359            // Per-target output dir (used by both native and k6 paths).
3360            // Created before the config so we can point the k6 script's
3361            // handleSummary at the per-target directory rather than the shared
3362            // parent output dir (otherwise every target would overwrite the
3363            // same conformance-report.json).
3364            let target_dir = self.output.join(format!("target_{}", idx));
3365            std::fs::create_dir_all(&target_dir)?;
3366
3367            let config = ConformanceConfig {
3368                target_url: target.url.clone(),
3369                api_key: self.conformance_api_key.clone(),
3370                basic_auth: self.conformance_basic_auth.clone(),
3371                skip_tls_verify: self.skip_tls_verify,
3372                categories: categories.clone(),
3373                base_path: self.base_path.clone(),
3374                custom_headers: merged_headers,
3375                output_dir: Some(target_dir.clone()),
3376                all_operations: self.conformance_all_operations,
3377                custom_checks_file: self.conformance_custom.clone(),
3378                request_delay_ms: self.conformance_delay_ms,
3379                custom_filter: self.conformance_custom_filter.clone(),
3380                export_requests: self.export_requests,
3381                validate_requests: self.validate_requests,
3382            };
3383
3384            let target_start = std::time::Instant::now();
3385            let report = if self.use_k6 {
3386                if !K6Executor::is_k6_installed() {
3387                    TerminalReporter::print_error("k6 is not installed");
3388                    TerminalReporter::print_warning(
3389                        "Install k6 from: https://k6.io/docs/get-started/installation/",
3390                    );
3391                    return Err(BenchError::K6NotFound);
3392                }
3393
3394                let script = if let Some(ref annotated) = annotated_ops {
3395                    let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3396                        config.clone(),
3397                        annotated.clone(),
3398                    );
3399                    let (script, _check_count) = gen.generate()?;
3400                    script
3401                } else {
3402                    let generator = ConformanceGenerator::new(config.clone());
3403                    generator.generate()?
3404                };
3405
3406                let script_path = target_dir.join("k6-conformance.js");
3407                std::fs::write(&script_path, &script).map_err(|e| {
3408                    BenchError::Other(format!("Failed to write conformance script: {}", e))
3409                })?;
3410                TerminalReporter::print_success(&format!(
3411                    "Conformance script generated: {}",
3412                    script_path.display()
3413                ));
3414
3415                TerminalReporter::print_progress(&format!(
3416                    "Running conformance tests via k6 against {}...",
3417                    target.url
3418                ));
3419                let k6 = K6Executor::new()?.with_local_ips(self.source_ips.join(","));
3420                // Unique k6 API port per target to avoid collisions.
3421                let api_port = 6565u16.saturating_add(idx as u16);
3422                k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
3423                    .await?;
3424
3425                let report_path = target_dir.join("conformance-report.json");
3426                if report_path.exists() {
3427                    ConformanceReport::from_file(&report_path)?
3428                } else {
3429                    TerminalReporter::print_warning(&format!(
3430                        "Conformance report not generated for target {} (k6 handleSummary may not have run)",
3431                        target.url
3432                    ));
3433                    continue;
3434                }
3435            } else {
3436                let mut executor =
3437                    crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3438
3439                // Round 39 (#79) — see custom_only comment above; same
3440                // logic applied to the multi-target branch.
3441                let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3442                executor = if let Some(ref annotated) = annotated_ops {
3443                    executor.with_spec_driven_checks(annotated)
3444                } else if custom_only {
3445                    executor
3446                } else {
3447                    executor.with_reference_checks()
3448                };
3449                executor = executor.with_custom_checks()?;
3450
3451                TerminalReporter::print_success(&format!(
3452                    "Executing {} conformance checks against {}...",
3453                    executor.check_count(),
3454                    target.url
3455                ));
3456
3457                executor.execute().await?
3458            };
3459            let target_elapsed = target_start.elapsed();
3460
3461            let report_json = report.to_json();
3462
3463            // Extract pass/fail from the summary in the JSON
3464            let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
3465            let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
3466            let total_checks = passed + failed;
3467            let rate = if total_checks == 0 {
3468                0.0
3469            } else {
3470                (passed as f64 / total_checks as f64) * 100.0
3471            };
3472
3473            TerminalReporter::print_success(&format!(
3474                "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
3475                target.url,
3476                passed,
3477                total_checks,
3478                rate,
3479                target_elapsed.as_secs_f64()
3480            ));
3481
3482            // Save per-target report (target_dir created above)
3483            let target_report_path = target_dir.join("conformance-report.json");
3484            let report_str = serde_json::to_string_pretty(&report_json)
3485                .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3486            std::fs::write(&target_report_path, &report_str)
3487                .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3488
3489            // Save failure details if any
3490            let failure_details = report.failure_details();
3491            if !failure_details.is_empty() {
3492                let details_path = target_dir.join("conformance-failure-details.json");
3493                if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3494                    let _ = std::fs::write(&details_path, json);
3495                }
3496            }
3497
3498            // Round 45 (#79) — Srikanth on 0.3.189: `conformance-request-
3499            // violations.json` was never being written in his
3500            // multi-target self-test run. The round-44 wiring sat on
3501            // the single-target branch only. Mirror it here, per
3502            // target_dir, so the multi-target case (his typical
3503            // workflow) actually surfaces wire-level violations.
3504            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3505                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3506                    &self.spec,
3507                    &target_dir,
3508                    self.base_path.as_deref(),
3509                )
3510                .await?;
3511                if n > 0 {
3512                    TerminalReporter::print_warning(&format!(
3513                        "Target {}: {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3514                        target.url,
3515                        n,
3516                        target_dir.display()
3517                    ));
3518                }
3519            }
3520
3521            // Compute OWASP coverage for this target
3522            let owasp_coverage = report.owasp_coverage_data();
3523
3524            target_results.push(TargetResult {
3525                url: target.url.clone(),
3526                passed,
3527                failed,
3528                elapsed: target_elapsed,
3529                report_json,
3530                owasp_coverage,
3531            });
3532        }
3533
3534        let total_elapsed = total_start.elapsed();
3535
3536        // Print summary table
3537        println!("\n{}", "=".repeat(80));
3538        println!("  Multi-Target Conformance Summary");
3539        println!("{}", "=".repeat(80));
3540        println!(
3541            "  {:<40} {:>8} {:>8} {:>8} {:>8}",
3542            "Target URL", "Passed", "Failed", "Rate", "Time"
3543        );
3544        println!("  {}", "-".repeat(76));
3545
3546        let mut total_passed = 0usize;
3547        let mut total_failed = 0usize;
3548
3549        for result in &target_results {
3550            let total_checks = result.passed + result.failed;
3551            let rate = if total_checks == 0 {
3552                0.0
3553            } else {
3554                (result.passed as f64 / total_checks as f64) * 100.0
3555            };
3556
3557            // Truncate long URLs for display
3558            let display_url = if result.url.len() > 38 {
3559                format!("{}...", &result.url[..35])
3560            } else {
3561                result.url.clone()
3562            };
3563
3564            println!(
3565                "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3566                display_url,
3567                result.passed,
3568                result.failed,
3569                rate,
3570                result.elapsed.as_secs_f64()
3571            );
3572
3573            total_passed += result.passed;
3574            total_failed += result.failed;
3575        }
3576
3577        let grand_total = total_passed + total_failed;
3578        let overall_rate = if grand_total == 0 {
3579            0.0
3580        } else {
3581            (total_passed as f64 / grand_total as f64) * 100.0
3582        };
3583
3584        println!("  {}", "-".repeat(76));
3585        println!(
3586            "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3587            format!("TOTAL ({} targets)", num_targets),
3588            total_passed,
3589            total_failed,
3590            overall_rate,
3591            total_elapsed.as_secs_f64()
3592        );
3593        println!("{}", "=".repeat(80));
3594
3595        // Print per-target OWASP coverage
3596        for result in &target_results {
3597            println!("\n  OWASP API Security Top 10 Coverage for {}:", result.url);
3598            for entry in &result.owasp_coverage {
3599                let status = if !entry.tested {
3600                    "-"
3601                } else if entry.all_passed {
3602                    "pass"
3603                } else {
3604                    "FAIL"
3605                };
3606                let via = if entry.via_categories.is_empty() {
3607                    String::new()
3608                } else {
3609                    format!(" (via {})", entry.via_categories.join(", "))
3610                };
3611                println!("    {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
3612            }
3613        }
3614
3615        // Save combined summary
3616        let per_target_summaries: Vec<serde_json::Value> = target_results
3617            .iter()
3618            .enumerate()
3619            .map(|(idx, r)| {
3620                let total_checks = r.passed + r.failed;
3621                let rate = if total_checks == 0 {
3622                    0.0
3623                } else {
3624                    (r.passed as f64 / total_checks as f64) * 100.0
3625                };
3626                let owasp_json: Vec<serde_json::Value> = r
3627                    .owasp_coverage
3628                    .iter()
3629                    .map(|e| {
3630                        serde_json::json!({
3631                            "id": e.id,
3632                            "name": e.name,
3633                            "tested": e.tested,
3634                            "all_passed": e.all_passed,
3635                            "via_categories": e.via_categories,
3636                        })
3637                    })
3638                    .collect();
3639                serde_json::json!({
3640                    "target_url": r.url,
3641                    "target_index": idx,
3642                    "checks_passed": r.passed,
3643                    "checks_failed": r.failed,
3644                    "total_checks": total_checks,
3645                    "pass_rate": rate,
3646                    "elapsed_seconds": r.elapsed.as_secs_f64(),
3647                    "report": r.report_json,
3648                    "owasp_coverage": owasp_json,
3649                })
3650            })
3651            .collect();
3652
3653        let combined_summary = serde_json::json!({
3654            "total_targets": num_targets,
3655            "total_checks_passed": total_passed,
3656            "total_checks_failed": total_failed,
3657            "overall_pass_rate": overall_rate,
3658            "total_elapsed_seconds": total_elapsed.as_secs_f64(),
3659            "targets": per_target_summaries,
3660        });
3661
3662        let summary_path = self.output.join("multi-target-conformance-summary.json");
3663        let summary_str = serde_json::to_string_pretty(&combined_summary)
3664            .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
3665        std::fs::write(&summary_path, &summary_str)
3666            .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
3667        TerminalReporter::print_success(&format!(
3668            "Combined summary saved to: {}",
3669            summary_path.display()
3670        ));
3671
3672        Ok(())
3673    }
3674
3675    /// Execute OWASP API Security Top 10 testing mode
3676    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
3677        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
3678
3679        // Parse custom headers from CLI
3680        let custom_headers = self.parse_headers()?;
3681
3682        // Build OWASP configuration from CLI options
3683        let mut config = OwaspApiConfig::new()
3684            .with_auth_header(&self.owasp_auth_header)
3685            .with_verbose(self.verbose)
3686            .with_insecure(self.skip_tls_verify)
3687            .with_concurrency(self.vus as usize)
3688            .with_iterations(self.owasp_iterations as usize)
3689            .with_base_path(self.base_path.clone())
3690            .with_custom_headers(custom_headers);
3691
3692        // Set valid auth token if provided
3693        if let Some(ref token) = self.owasp_auth_token {
3694            config = config.with_valid_auth_token(token);
3695        }
3696
3697        // Parse categories if provided
3698        if let Some(ref cats_str) = self.owasp_categories {
3699            let categories: Vec<OwaspCategory> = cats_str
3700                .split(',')
3701                .filter_map(|s| {
3702                    let trimmed = s.trim();
3703                    match trimmed.parse::<OwaspCategory>() {
3704                        Ok(cat) => Some(cat),
3705                        Err(e) => {
3706                            TerminalReporter::print_warning(&e);
3707                            None
3708                        }
3709                    }
3710                })
3711                .collect();
3712
3713            if !categories.is_empty() {
3714                config = config.with_categories(categories);
3715            }
3716        }
3717
3718        // Load admin paths from file if provided
3719        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
3720            config.admin_paths_file = Some(admin_paths_file.clone());
3721            if let Err(e) = config.load_admin_paths() {
3722                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
3723            }
3724        }
3725
3726        // Set ID fields if provided
3727        if let Some(ref id_fields_str) = self.owasp_id_fields {
3728            let id_fields: Vec<String> = id_fields_str
3729                .split(',')
3730                .map(|s| s.trim().to_string())
3731                .filter(|s| !s.is_empty())
3732                .collect();
3733            if !id_fields.is_empty() {
3734                config = config.with_id_fields(id_fields);
3735            }
3736        }
3737
3738        // Set report path and format
3739        if let Some(ref report_path) = self.owasp_report {
3740            config = config.with_report_path(report_path);
3741        }
3742        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
3743            config = config.with_report_format(format);
3744        }
3745
3746        // Print configuration summary
3747        let categories = config.categories_to_test();
3748        TerminalReporter::print_success(&format!(
3749            "Testing {} OWASP categories: {}",
3750            categories.len(),
3751            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
3752        ));
3753
3754        if config.valid_auth_token.is_some() {
3755            TerminalReporter::print_progress("Using provided auth token for baseline requests");
3756        }
3757
3758        // Create the OWASP generator
3759        TerminalReporter::print_progress("Generating OWASP security test script...");
3760        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
3761
3762        // Generate the script
3763        let script = generator.generate()?;
3764        TerminalReporter::print_success("OWASP security test script generated");
3765
3766        // Write script to file
3767        let script_path = if let Some(output) = &self.script_output {
3768            output.clone()
3769        } else {
3770            self.output.join("k6-owasp-security-test.js")
3771        };
3772
3773        if let Some(parent) = script_path.parent() {
3774            std::fs::create_dir_all(parent)?;
3775        }
3776        std::fs::write(&script_path, &script)?;
3777        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
3778
3779        // If generate-only mode, exit here
3780        if self.generate_only {
3781            println!("\nOWASP security test script generated. Run it with:");
3782            println!("  k6 run {}", script_path.display());
3783            return Ok(());
3784        }
3785
3786        // Execute k6
3787        TerminalReporter::print_progress("Executing OWASP security tests...");
3788        let executor = K6Executor::new()?.with_local_ips(self.source_ips.join(","));
3789        std::fs::create_dir_all(&self.output)?;
3790
3791        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
3792
3793        let duration_secs = Self::parse_duration(&self.duration)?;
3794        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
3795
3796        println!("\nOWASP security test results saved to: {}", self.output.display());
3797
3798        Ok(())
3799    }
3800}
3801
3802#[cfg(test)]
3803mod tests {
3804    use super::*;
3805    use tempfile::tempdir;
3806
3807    #[test]
3808    fn test_parse_duration() {
3809        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
3810        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
3811        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
3812        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
3813    }
3814
3815    /// Round 22.4 — `start-end` IPv4 range syntax for non-power-of-2
3816    /// ranges. Srikanth (h): `--source-ip 10.0.0.5-10.0.0.27` for 23
3817    /// hosts without finding a clean prefix.
3818    #[test]
3819    fn parse_ip_list_ipv4_range_inclusive() {
3820        let v = parse_ip_list(&["10.0.0.5-10.0.0.27".into()], "source-ip");
3821        assert_eq!(v.len(), 23);
3822        assert_eq!(v.first().unwrap().to_string(), "10.0.0.5");
3823        assert_eq!(v.last().unwrap().to_string(), "10.0.0.27");
3824    }
3825
3826    /// Round 22.4 — range with start > end is rejected with a warning
3827    /// (returns nothing for that entry rather than wrapping around).
3828    #[test]
3829    fn parse_ip_list_range_rejects_backwards() {
3830        let v = parse_ip_list(&["10.0.0.10-10.0.0.5".into()], "source-ip");
3831        assert!(v.is_empty(), "backwards range should produce no IPs; got {v:?}");
3832    }
3833
3834    /// Round 22.4 — IPv6 ranges are intentionally rejected because
3835    /// `2001:db8::1-2001:db8::5` would ambiguously parse against the
3836    /// address literal's `:` separators. Users use CIDR for IPv6.
3837    #[test]
3838    fn parse_ip_list_rejects_ipv6_range_syntax() {
3839        let v = parse_ip_list(&["2001:db8::1-2001:db8::5".into()], "geo-source-ip");
3840        assert!(v.is_empty(), "IPv6 range should be rejected; got {v:?}");
3841    }
3842
3843    /// Round 22.4 — range cap is the same 256 host limit as CIDR.
3844    #[test]
3845    fn parse_ip_list_range_capped_at_256() {
3846        let v = parse_ip_list(&["10.0.0.0-10.0.5.0".into()], "source-ip");
3847        assert_eq!(v.len(), 256);
3848        assert_eq!(v.first().unwrap().to_string(), "10.0.0.0");
3849    }
3850
3851    /// Round 19 — single IPs and comma-separated lists already
3852    /// worked in 18.5; this regression-locks the parse paths.
3853    #[test]
3854    fn parse_ip_list_plain_and_comma() {
3855        let v = parse_ip_list(&["10.0.0.5".into(), "10.0.0.6,10.0.0.7".into()], "source-ip");
3856        assert_eq!(v.len(), 3);
3857        assert_eq!(v[0].to_string(), "10.0.0.5");
3858        assert_eq!(v[2].to_string(), "10.0.0.7");
3859    }
3860
3861    /// Round 19 — IPv4 CIDR expands to host count up to the cap.
3862    /// `/29` = 8 hosts (well under cap), all 8 enumerated.
3863    #[test]
3864    fn parse_ip_list_ipv4_cidr_29_expands_to_8() {
3865        let v = parse_ip_list(&["10.0.0.0/29".into()], "source-ip");
3866        assert_eq!(v.len(), 8);
3867        assert_eq!(v[0].to_string(), "10.0.0.0");
3868        assert_eq!(v[7].to_string(), "10.0.0.7");
3869    }
3870
3871    /// Round 19 — IPv4 CIDR larger than the cap is truncated, not
3872    /// rejected. Cap is 256; `/8` would be 16M without the guard.
3873    #[test]
3874    fn parse_ip_list_ipv4_cidr_8_capped_at_256() {
3875        let v = parse_ip_list(&["10.0.0.0/8".into()], "source-ip");
3876        assert_eq!(v.len(), 256);
3877        assert_eq!(v[0].to_string(), "10.0.0.0");
3878        assert_eq!(v[255].to_string(), "10.0.0.255");
3879    }
3880
3881    /// Round 19 — IPv6 CIDR also expands. `/126` = 4 hosts.
3882    #[test]
3883    fn parse_ip_list_ipv6_cidr_126_expands_to_4() {
3884        let v = parse_ip_list(&["2001:db8::/126".into()], "geo-source-ip");
3885        assert_eq!(v.len(), 4);
3886        assert!(v[0].is_ipv6());
3887        assert_eq!(v[0].to_string(), "2001:db8::");
3888        assert_eq!(v[3].to_string(), "2001:db8::3");
3889    }
3890
3891    /// Round 19 — mixed IPv4 + IPv6 + CIDR in one call works.
3892    #[test]
3893    fn parse_ip_list_mixed_v4_v6_cidr() {
3894        let v = parse_ip_list(&["10.0.0.0/30,2001:db8::1,203.0.113.42".into()], "geo-source-ip");
3895        assert_eq!(v.len(), 6); // 4 from /30 + 1 + 1
3896        assert!(v.iter().any(|ip| ip.to_string() == "2001:db8::1"));
3897        assert!(v.iter().any(|ip| ip.to_string() == "203.0.113.42"));
3898    }
3899
3900    /// Round 19 — malformed entries log and skip; the run continues
3901    /// with whatever resolved.
3902    #[test]
3903    fn parse_ip_list_skips_malformed() {
3904        let v = parse_ip_list(
3905            &[
3906                "10.0.0.5".into(),
3907                "not-an-ip".into(),
3908                "10.0.0.6".into(),
3909                "/24".into(),
3910                "1.2.3.4/200".into(),
3911            ],
3912            "source-ip",
3913        );
3914        assert_eq!(v.len(), 2);
3915        assert_eq!(v[0].to_string(), "10.0.0.5");
3916        assert_eq!(v[1].to_string(), "10.0.0.6");
3917    }
3918
3919    #[test]
3920    fn test_parse_duration_invalid() {
3921        assert!(BenchCommand::parse_duration("invalid").is_err());
3922        assert!(BenchCommand::parse_duration("30x").is_err());
3923    }
3924
3925    #[test]
3926    fn test_parse_headers() {
3927        let cmd = BenchCommand {
3928            spec: vec![PathBuf::from("test.yaml")],
3929            spec_dir: None,
3930            merge_conflicts: "error".to_string(),
3931            spec_mode: "merge".to_string(),
3932            dependency_config: None,
3933            target: "http://localhost".to_string(),
3934            base_path: None,
3935            duration: "1m".to_string(),
3936            vus: 10,
3937            scenario: "ramp-up".to_string(),
3938            operations: None,
3939            exclude_operations: None,
3940            auth: None,
3941            headers: vec![
3942                "X-API-Key:test123".to_string(),
3943                "X-Client-ID:client456".to_string(),
3944            ],
3945            output: PathBuf::from("output"),
3946            generate_only: false,
3947            script_output: None,
3948            threshold_percentile: "p(95)".to_string(),
3949            threshold_ms: 500,
3950            max_error_rate: 0.05,
3951            verbose: false,
3952            skip_tls_verify: false,
3953            chunked_request_bodies: false,
3954            target_rps: None,
3955            no_keep_alive: false,
3956            targets_file: None,
3957            max_concurrency: None,
3958            results_format: "both".to_string(),
3959            params_file: None,
3960            crud_flow: false,
3961            flow_config: None,
3962            extract_fields: None,
3963            parallel_create: None,
3964            data_file: None,
3965            data_distribution: "unique-per-vu".to_string(),
3966            data_mappings: None,
3967            per_uri_control: false,
3968            error_rate: None,
3969            error_types: None,
3970            security_test: false,
3971            security_payloads: None,
3972            security_categories: None,
3973            security_target_fields: None,
3974            wafbench_dir: None,
3975            wafbench_cycle_all: false,
3976            owasp_api_top10: false,
3977            owasp_categories: None,
3978            owasp_auth_header: "Authorization".to_string(),
3979            owasp_auth_token: None,
3980            owasp_admin_paths: None,
3981            owasp_id_fields: None,
3982            owasp_report: None,
3983            owasp_report_format: "json".to_string(),
3984            owasp_iterations: 1,
3985            conformance: false,
3986            conformance_api_key: None,
3987            conformance_basic_auth: None,
3988            conformance_report: PathBuf::from("conformance-report.json"),
3989            conformance_categories: None,
3990            conformance_report_format: "json".to_string(),
3991            conformance_headers: vec![],
3992            conformance_all_operations: false,
3993            conformance_custom: None,
3994            conformance_delay_ms: 0,
3995            use_k6: false,
3996            conformance_custom_filter: None,
3997            export_requests: false,
3998            validate_requests: false,
3999            conformance_self_test: false,
4000            conformance_self_test_capture: false,
4001            conformance_self_test_iterations: 1,
4002            conformance_self_test_duration: None,
4003            validate_response_schemas: false,
4004            source_ips: Vec::new(),
4005            geo_source_ips: Vec::new(),
4006            geo_source_headers: Vec::new(),
4007            report_missed_cap: None,
4008        };
4009
4010        let headers = cmd.parse_headers().unwrap();
4011        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
4012        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
4013    }
4014
4015    #[test]
4016    fn test_parse_header_string_preserves_comma_in_value() {
4017        // #761: with one header per --headers flag, a comma in the value (e.g. a
4018        // Cookie expiry date) is preserved instead of being split into junk pairs.
4019        let inputs = vec![
4020            "Cookie:session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string(),
4021            "X-Trace:1".to_string(),
4022        ];
4023        let headers = parse_header_string(&inputs).unwrap();
4024        assert_eq!(
4025            headers.get("Cookie"),
4026            Some(&"session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string())
4027        );
4028        assert_eq!(headers.get("X-Trace"), Some(&"1".to_string()));
4029    }
4030
4031    #[test]
4032    fn test_get_spec_display_name() {
4033        let cmd = BenchCommand {
4034            spec: vec![PathBuf::from("test.yaml")],
4035            spec_dir: None,
4036            merge_conflicts: "error".to_string(),
4037            spec_mode: "merge".to_string(),
4038            dependency_config: None,
4039            target: "http://localhost".to_string(),
4040            base_path: None,
4041            duration: "1m".to_string(),
4042            vus: 10,
4043            scenario: "ramp-up".to_string(),
4044            operations: None,
4045            exclude_operations: None,
4046            auth: None,
4047            headers: Vec::new(),
4048            output: PathBuf::from("output"),
4049            generate_only: false,
4050            script_output: None,
4051            threshold_percentile: "p(95)".to_string(),
4052            threshold_ms: 500,
4053            max_error_rate: 0.05,
4054            verbose: false,
4055            skip_tls_verify: false,
4056            chunked_request_bodies: false,
4057            target_rps: None,
4058            no_keep_alive: false,
4059            targets_file: None,
4060            max_concurrency: None,
4061            results_format: "both".to_string(),
4062            params_file: None,
4063            crud_flow: false,
4064            flow_config: None,
4065            extract_fields: None,
4066            parallel_create: None,
4067            data_file: None,
4068            data_distribution: "unique-per-vu".to_string(),
4069            data_mappings: None,
4070            per_uri_control: false,
4071            error_rate: None,
4072            error_types: None,
4073            security_test: false,
4074            security_payloads: None,
4075            security_categories: None,
4076            security_target_fields: None,
4077            wafbench_dir: None,
4078            wafbench_cycle_all: false,
4079            owasp_api_top10: false,
4080            owasp_categories: None,
4081            owasp_auth_header: "Authorization".to_string(),
4082            owasp_auth_token: None,
4083            owasp_admin_paths: None,
4084            owasp_id_fields: None,
4085            owasp_report: None,
4086            owasp_report_format: "json".to_string(),
4087            owasp_iterations: 1,
4088            conformance: false,
4089            conformance_api_key: None,
4090            conformance_basic_auth: None,
4091            conformance_report: PathBuf::from("conformance-report.json"),
4092            conformance_categories: None,
4093            conformance_report_format: "json".to_string(),
4094            conformance_headers: vec![],
4095            conformance_all_operations: false,
4096            conformance_custom: None,
4097            conformance_delay_ms: 0,
4098            use_k6: false,
4099            conformance_custom_filter: None,
4100            export_requests: false,
4101            validate_requests: false,
4102            conformance_self_test: false,
4103            conformance_self_test_capture: false,
4104            conformance_self_test_iterations: 1,
4105            conformance_self_test_duration: None,
4106            validate_response_schemas: false,
4107            source_ips: Vec::new(),
4108            geo_source_ips: Vec::new(),
4109            geo_source_headers: Vec::new(),
4110            report_missed_cap: None,
4111        };
4112
4113        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
4114
4115        // Test multiple specs
4116        let cmd_multi = BenchCommand {
4117            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
4118            spec_dir: None,
4119            merge_conflicts: "error".to_string(),
4120            spec_mode: "merge".to_string(),
4121            dependency_config: None,
4122            target: "http://localhost".to_string(),
4123            base_path: None,
4124            duration: "1m".to_string(),
4125            vus: 10,
4126            scenario: "ramp-up".to_string(),
4127            operations: None,
4128            exclude_operations: None,
4129            auth: None,
4130            headers: Vec::new(),
4131            output: PathBuf::from("output"),
4132            generate_only: false,
4133            script_output: None,
4134            threshold_percentile: "p(95)".to_string(),
4135            threshold_ms: 500,
4136            max_error_rate: 0.05,
4137            verbose: false,
4138            skip_tls_verify: false,
4139            chunked_request_bodies: false,
4140            target_rps: None,
4141            no_keep_alive: false,
4142            targets_file: None,
4143            max_concurrency: None,
4144            results_format: "both".to_string(),
4145            params_file: None,
4146            crud_flow: false,
4147            flow_config: None,
4148            extract_fields: None,
4149            parallel_create: None,
4150            data_file: None,
4151            data_distribution: "unique-per-vu".to_string(),
4152            data_mappings: None,
4153            per_uri_control: false,
4154            error_rate: None,
4155            error_types: None,
4156            security_test: false,
4157            security_payloads: None,
4158            security_categories: None,
4159            security_target_fields: None,
4160            wafbench_dir: None,
4161            wafbench_cycle_all: false,
4162            owasp_api_top10: false,
4163            owasp_categories: None,
4164            owasp_auth_header: "Authorization".to_string(),
4165            owasp_auth_token: None,
4166            owasp_admin_paths: None,
4167            owasp_id_fields: None,
4168            owasp_report: None,
4169            owasp_report_format: "json".to_string(),
4170            owasp_iterations: 1,
4171            conformance: false,
4172            conformance_api_key: None,
4173            conformance_basic_auth: None,
4174            conformance_report: PathBuf::from("conformance-report.json"),
4175            conformance_categories: None,
4176            conformance_report_format: "json".to_string(),
4177            conformance_headers: vec![],
4178            conformance_all_operations: false,
4179            conformance_custom: None,
4180            conformance_delay_ms: 0,
4181            use_k6: false,
4182            conformance_custom_filter: None,
4183            export_requests: false,
4184            validate_requests: false,
4185            conformance_self_test: false,
4186            conformance_self_test_capture: false,
4187            conformance_self_test_iterations: 1,
4188            conformance_self_test_duration: None,
4189            validate_response_schemas: false,
4190            source_ips: Vec::new(),
4191            geo_source_ips: Vec::new(),
4192            geo_source_headers: Vec::new(),
4193            report_missed_cap: None,
4194        };
4195
4196        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
4197    }
4198
4199    #[test]
4200    fn test_parse_extracted_values_from_output_dir() {
4201        let dir = tempdir().unwrap();
4202        let path = dir.path().join("extracted_values.json");
4203        std::fs::write(
4204            &path,
4205            r#"{
4206  "pool_id": "abc123",
4207  "count": 0,
4208  "enabled": false,
4209  "metadata": { "owner": "team-a" }
4210}"#,
4211        )
4212        .unwrap();
4213
4214        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4215        assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
4216        assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
4217        assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
4218        assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
4219    }
4220
4221    #[test]
4222    fn test_parse_extracted_values_missing_file() {
4223        let dir = tempdir().unwrap();
4224        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4225        assert!(extracted.values.is_empty());
4226    }
4227}