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