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