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 comma-separated header string into a `HashMap`.
38///
39/// Format: `Key:Value,Key2:Value2`
40///
41/// **Known limitation**: Header values containing commas will be incorrectly
42/// split. Cookie headers with semicolons work fine, but Cookie values with
43/// commas (e.g. `expires=Thu, 01 Jan 2099`) will break.
44pub fn parse_header_string(input: &str) -> Result<HashMap<String, String>> {
45    let mut headers = HashMap::new();
46
47    for pair in input.split(',') {
48        let parts: Vec<&str> = pair.splitn(2, ':').collect();
49        if parts.len() != 2 {
50            return Err(BenchError::Other(format!(
51                "Invalid header format: '{}'. Expected 'Key:Value'",
52                pair
53            )));
54        }
55        headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
56    }
57
58    Ok(headers)
59}
60
61/// Bench command configuration
62pub struct BenchCommand {
63    /// OpenAPI spec file(s) - can specify multiple
64    pub spec: Vec<PathBuf>,
65    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
66    pub spec_dir: Option<PathBuf>,
67    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
68    pub merge_conflicts: String,
69    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
70    pub spec_mode: String,
71    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
72    pub dependency_config: Option<PathBuf>,
73    pub target: String,
74    /// API base path prefix (e.g., "/api" or "/v2/api")
75    /// If None, extracts from OpenAPI spec's servers URL
76    pub base_path: Option<String>,
77    pub duration: String,
78    pub vus: u32,
79    /// Target requests-per-second. When `Some(n)`, the generated k6 script
80    /// switches to `constant-arrival-rate` executor at `n` RPS with `vus`
81    /// pre-allocated. When `None`, uses the legacy `ramping-vus` executor
82    /// where RPS is implicit (VUs × 1 req/sec from the script's `sleep(1)`).
83    /// Issue #79 — Srikanth's round-3 reply.
84    pub target_rps: Option<u32>,
85    /// When true, every k6 request opens a new TCP/TLS connection
86    /// (`noConnectionReuse: true` and `--no-vu-connection-reuse`). Lets users
87    /// drive a high connections-per-second rate to exercise connection-limit
88    /// chaos and observe TCP-level fault injection. Issue #79.
89    pub no_keep_alive: bool,
90    pub scenario: String,
91    pub operations: Option<String>,
92    /// Exclude operations from testing (comma-separated)
93    ///
94    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
95    pub exclude_operations: Option<String>,
96    pub auth: Option<String>,
97    pub headers: Option<String>,
98    pub output: PathBuf,
99    pub generate_only: bool,
100    pub script_output: Option<PathBuf>,
101    pub threshold_percentile: String,
102    pub threshold_ms: u64,
103    pub max_error_rate: f64,
104    pub verbose: bool,
105    pub skip_tls_verify: bool,
106    /// When true, set `Transfer-Encoding: chunked` on every k6 request body so
107    /// the server experiences chunked-encoded traffic. See
108    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats — k6's Go
109    /// transport may still send Content-Length in some cases.
110    pub chunked_request_bodies: bool,
111    /// Optional file containing multiple targets
112    pub targets_file: Option<PathBuf>,
113    /// Maximum number of parallel executions (for multi-target mode)
114    pub max_concurrency: Option<u32>,
115    /// Results format: "per-target", "aggregated", or "both"
116    pub results_format: String,
117    /// Optional file containing parameter value overrides (JSON or YAML)
118    ///
119    /// Allows users to provide custom values for path parameters, query parameters,
120    /// headers, and request bodies instead of auto-generated placeholder values.
121    pub params_file: Option<PathBuf>,
122
123    // === CRUD Flow Options ===
124    /// Enable CRUD flow mode
125    pub crud_flow: bool,
126    /// Custom CRUD flow configuration file
127    pub flow_config: Option<PathBuf>,
128    /// Fields to extract from responses
129    pub extract_fields: Option<String>,
130
131    // === Parallel Execution Options ===
132    /// Number of resources to create in parallel
133    pub parallel_create: Option<u32>,
134
135    // === Data-Driven Testing Options ===
136    /// Test data file (CSV or JSON)
137    pub data_file: Option<PathBuf>,
138    /// Data distribution strategy
139    pub data_distribution: String,
140    /// Data column to field mappings
141    pub data_mappings: Option<String>,
142    /// Enable per-URI control mode (each row specifies method, uri, body, etc.)
143    pub per_uri_control: bool,
144
145    // === Invalid Data Testing Options ===
146    /// Percentage of requests with invalid data
147    pub error_rate: Option<f64>,
148    /// Types of invalid data to generate
149    pub error_types: Option<String>,
150
151    // === Security Testing Options ===
152    /// Enable security testing
153    pub security_test: bool,
154    /// Custom security payloads file
155    pub security_payloads: Option<PathBuf>,
156    /// Security test categories
157    pub security_categories: Option<String>,
158    /// Fields to target for security injection
159    pub security_target_fields: Option<String>,
160
161    // === WAFBench Integration ===
162    /// WAFBench test directory or glob pattern for loading CRS attack patterns
163    pub wafbench_dir: Option<String>,
164    /// Cycle through ALL WAFBench payloads instead of random sampling
165    pub wafbench_cycle_all: bool,
166
167    // === OpenAPI 3.0.0 Conformance Testing ===
168    /// Enable conformance testing mode
169    pub conformance: bool,
170    /// API key for conformance security tests
171    pub conformance_api_key: Option<String>,
172    /// Basic auth credentials for conformance security tests (user:pass)
173    pub conformance_basic_auth: Option<String>,
174    /// Conformance report output file
175    pub conformance_report: PathBuf,
176    /// Conformance categories to test (comma-separated, e.g. "parameters,security")
177    pub conformance_categories: Option<String>,
178    /// Conformance report format: "json" or "sarif"
179    pub conformance_report_format: String,
180    /// Custom headers to inject into every conformance request (for authentication).
181    /// Each entry is "Header-Name: value" format.
182    pub conformance_headers: Vec<String>,
183    /// When true, test ALL operations for method/response/body categories
184    /// instead of just one representative per feature check.
185    pub conformance_all_operations: bool,
186    /// Optional YAML file with custom conformance checks
187    pub conformance_custom: Option<PathBuf>,
188    /// Delay in milliseconds between consecutive conformance requests.
189    /// Useful when testing against rate-limited APIs.
190    pub conformance_delay_ms: u64,
191    /// Use k6 for conformance test execution instead of the native Rust executor
192    pub use_k6: bool,
193    /// Regex filter for custom conformance checks — only checks whose name or
194    /// path matches the pattern are included. Example: "wafcrs|ssl" to test
195    /// only checks with "wafcrs" or "ssl" in the name/path.
196    pub conformance_custom_filter: Option<String>,
197    /// When true, export all request/response pairs to
198    /// `conformance-requests.json` in the output directory.
199    pub export_requests: bool,
200    /// When true, validate each request against the OpenAPI spec and report
201    /// violations to `conformance-request-violations.json`.
202    pub validate_requests: bool,
203    /// Issue #79 round 13 (4) — when true, replace the standard
204    /// conformance run with a positive + per-category negative
205    /// self-test driver. Verifies that the server actually rejects
206    /// the negatives with 4xx (i.e. its validator is wired correctly).
207    /// Useful to confirm the round-13 (3) validator-bypass fix took
208    /// effect against the user's spec.
209    pub conformance_self_test: bool,
210
211    // === OWASP API Security Top 10 Testing ===
212    /// Enable OWASP API Security Top 10 testing mode
213    pub owasp_api_top10: bool,
214    /// OWASP API categories to test (comma-separated)
215    pub owasp_categories: Option<String>,
216    /// Authorization header name for OWASP auth tests
217    pub owasp_auth_header: String,
218    /// Valid authorization token for OWASP baseline requests
219    pub owasp_auth_token: Option<String>,
220    /// File containing admin/privileged paths to test
221    pub owasp_admin_paths: Option<PathBuf>,
222    /// Fields containing resource IDs for BOLA testing
223    pub owasp_id_fields: Option<String>,
224    /// OWASP report output file
225    pub owasp_report: Option<PathBuf>,
226    /// OWASP report format (json, sarif)
227    pub owasp_report_format: String,
228    /// Number of iterations per VU for OWASP tests (default: 1)
229    pub owasp_iterations: u32,
230}
231
232impl BenchCommand {
233    /// Load and merge specs from --spec files and --spec-dir
234    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
235        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
236
237        // Load specs from --spec flags
238        if !self.spec.is_empty() {
239            let specs = load_specs_from_files(self.spec.clone())
240                .await
241                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
242            all_specs.extend(specs);
243        }
244
245        // Load specs from --spec-dir if provided
246        if let Some(spec_dir) = &self.spec_dir {
247            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
248                BenchError::Other(format!("Failed to load specs from directory: {}", e))
249            })?;
250            all_specs.extend(dir_specs);
251        }
252
253        if all_specs.is_empty() {
254            return Err(BenchError::Other(
255                "No spec files provided. Use --spec or --spec-dir.".to_string(),
256            ));
257        }
258
259        // If only one spec, return it directly (extract just the OpenApiSpec)
260        if all_specs.len() == 1 {
261            // Safe to unwrap because we just checked len() == 1
262            return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
263        }
264
265        // Merge multiple specs
266        let conflict_strategy = match self.merge_conflicts.as_str() {
267            "first" => ConflictStrategy::First,
268            "last" => ConflictStrategy::Last,
269            _ => ConflictStrategy::Error,
270        };
271
272        merge_specs(all_specs, conflict_strategy)
273            .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
274    }
275
276    /// Get a display name for the spec(s)
277    fn get_spec_display_name(&self) -> String {
278        if self.spec.len() == 1 {
279            self.spec[0].to_string_lossy().to_string()
280        } else if !self.spec.is_empty() {
281            format!("{} spec files", self.spec.len())
282        } else if let Some(dir) = &self.spec_dir {
283            format!("specs from {}", dir.display())
284        } else {
285            "no specs".to_string()
286        }
287    }
288
289    /// Execute the bench command
290    pub async fn execute(&self) -> Result<()> {
291        // Check if we're in multi-target mode
292        if let Some(targets_file) = &self.targets_file {
293            if self.conformance {
294                return self.execute_multi_target_conformance(targets_file).await;
295            }
296            return self.execute_multi_target(targets_file).await;
297        }
298
299        // Check if we're in sequential spec mode (for dependency handling)
300        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
301            return self.execute_sequential_specs().await;
302        }
303
304        // Single target mode (existing behavior)
305        // Print header
306        TerminalReporter::print_header(
307            &self.get_spec_display_name(),
308            &self.target,
309            0, // Will be updated later
310            &self.scenario,
311            Self::parse_duration(&self.duration)?,
312        );
313
314        // Validate k6 installation
315        if !K6Executor::is_k6_installed() {
316            TerminalReporter::print_error("k6 is not installed");
317            TerminalReporter::print_warning(
318                "Install k6 from: https://k6.io/docs/get-started/installation/",
319            );
320            return Err(BenchError::K6NotFound);
321        }
322
323        // Check for conformance testing mode (before spec loading — conformance doesn't need a user spec)
324        if self.conformance {
325            return self.execute_conformance_test().await;
326        }
327
328        // Load and parse spec(s)
329        TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
330        let merged_spec = self.load_and_merge_specs().await?;
331        let parser = SpecParser::from_spec(merged_spec);
332        if self.spec.len() > 1 || self.spec_dir.is_some() {
333            TerminalReporter::print_success(&format!(
334                "Loaded and merged {} specification(s)",
335                self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
336            ));
337        } else {
338            TerminalReporter::print_success("Specification loaded");
339        }
340
341        // Check for mock server integration
342        let mock_config = self.build_mock_config().await;
343        if mock_config.is_mock_server {
344            TerminalReporter::print_progress("Mock server integration enabled");
345        }
346
347        // Check for CRUD flow mode
348        if self.crud_flow {
349            return self.execute_crud_flow(&parser).await;
350        }
351
352        // Check for OWASP API Top 10 testing mode
353        if self.owasp_api_top10 {
354            return self.execute_owasp_test(&parser).await;
355        }
356
357        // Get operations
358        TerminalReporter::print_progress("Extracting API operations...");
359        let mut operations = if let Some(filter) = &self.operations {
360            parser.filter_operations(filter)?
361        } else {
362            parser.get_operations()
363        };
364
365        // Apply exclusions if provided
366        if let Some(exclude) = &self.exclude_operations {
367            let before_count = operations.len();
368            operations = parser.exclude_operations(operations, exclude)?;
369            let excluded_count = before_count - operations.len();
370            if excluded_count > 0 {
371                TerminalReporter::print_progress(&format!(
372                    "Excluded {} operations matching '{}'",
373                    excluded_count, exclude
374                ));
375            }
376        }
377
378        if operations.is_empty() {
379            return Err(BenchError::Other("No operations found in spec".to_string()));
380        }
381
382        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
383
384        // Load parameter overrides if provided
385        let param_overrides = if let Some(params_file) = &self.params_file {
386            TerminalReporter::print_progress("Loading parameter overrides...");
387            let overrides = ParameterOverrides::from_file(params_file)?;
388            TerminalReporter::print_success(&format!(
389                "Loaded parameter overrides ({} operation-specific, {} defaults)",
390                overrides.operations.len(),
391                if overrides.defaults.is_empty() { 0 } else { 1 }
392            ));
393            Some(overrides)
394        } else {
395            None
396        };
397
398        // Generate request templates
399        TerminalReporter::print_progress("Generating request templates...");
400        let templates: Vec<_> = operations
401            .iter()
402            .map(|op| {
403                let op_overrides = param_overrides.as_ref().map(|po| {
404                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
405                });
406                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
407            })
408            .collect::<Result<Vec<_>>>()?;
409        TerminalReporter::print_success("Request templates generated");
410
411        // Parse headers
412        let custom_headers = self.parse_headers()?;
413
414        // Resolve base path (CLI option takes priority over spec's servers URL)
415        let base_path = self.resolve_base_path(&parser);
416        if let Some(ref bp) = base_path {
417            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
418        }
419
420        // Generate k6 script
421        TerminalReporter::print_progress("Generating k6 load test script...");
422        let scenario =
423            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
424
425        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
426
427        // Issue #79 round 6 follow-up — Srikanth reported k6 emitting
428        // "Insufficient VUs, reached 5 active VUs and cannot initialize more"
429        // when running `--rps 100 --vus 5`. With the `constant-arrival-rate`
430        // executor, k6 needs roughly `rps × avg_request_seconds` VUs to keep
431        // up; if `--vus` is too low it can't sustain the rate. Warn pre-flight
432        // so users know to bump `--vus` rather than chase the warning.
433        //
434        // Round 8 (#79): the static 100ms heuristic was wrong for fast targets
435        // (~2ms latency). Probe the actual target first to measure baseline
436        // latency, then derive a more accurate sizing recommendation. Fall
437        // back to the 100ms heuristic only when the probe can't reach the
438        // target (auth-gated endpoints, strict WAFs, etc).
439        //
440        // Round 9 (#79): factor in operation count. k6's constant-arrival-rate
441        // counts ITERATIONS, not requests — and every iteration runs all N
442        // operations sequentially, so required VUs scale with N. Srikanth's
443        // 12-op spec at --rps 100 with 15ms latency needs ~19 VUs, not 3.
444        let num_ops = operations.len() as u32;
445        if let Some(rps) = self.target_rps {
446            let probe =
447                crate::preflight::probe_target_latency(&self.target, 3, self.skip_tls_verify).await;
448
449            let (required_vus, basis) = match probe {
450                Some(p) => (
451                    p.required_vus(rps, num_ops),
452                    format!("avg {:.1}ms (measured)", p.avg_latency.as_secs_f64() * 1000.0),
453                ),
454                None => {
455                    // Static fallback: ~100ms heuristic × num_ops per iteration.
456                    let fallback = (rps as u64)
457                        .saturating_mul(num_ops.max(1) as u64)
458                        .div_ceil(10)
459                        .min(u32::MAX as u64) as u32;
460                    (fallback, "~100ms (default — probe failed)".to_string())
461                }
462            };
463
464            if self.vus < required_vus {
465                // Round 10 (#79): Srikanth's 11422-op spec at --rps 100 produced
466                // a recommendation of ~10,740 VUs, which is absurd in practice.
467                // When the recommendation goes super-linear, the real fix is to
468                // reduce the workload (use --operations filter), not bump VUs.
469                // Cap the suggestion at 1000 and steer the user toward filtering.
470                const VU_RECOMMENDATION_CAP: u32 = 1000;
471                let recommendation = required_vus.max(self.vus + 1);
472                if recommendation > VU_RECOMMENDATION_CAP {
473                    TerminalReporter::print_warning(&format!(
474                        "Workload is very large: --rps {} × {} ops/iteration × {} \
475                         baseline ⇒ ~{} VUs needed end-to-end, far beyond what's \
476                         practical to drive. Two ways to fix:\n  1. Reduce \
477                         operations per iteration with `--operations 'pattern,…'` \
478                         (or `--exclude-operations`) to focus the bench on a \
479                         representative subset.\n  2. Drop `--rps` and use \
480                         `--vus {}` alone — closed-model load runs as fast as \
481                         the VU pool allows, bounded by latency, with no per-\
482                         iteration deadline. Expect 1-iteration coverage of ~{} \
483                         operations in {}s.",
484                        rps,
485                        num_ops,
486                        basis,
487                        recommendation,
488                        self.vus.max(5),
489                        num_ops,
490                        Self::parse_duration(&self.duration).unwrap_or(0),
491                    ));
492                } else {
493                    TerminalReporter::print_warning(&format!(
494                        "--vus {} may be insufficient for --rps {} × {} ops/iteration \
495                         (baseline latency {}). k6's constant-arrival-rate counts ITERATIONS \
496                         and each runs every operation in the spec — required ≈ rps × ops × \
497                         latency_secs VUs. Bump --vus to ~{} if you see \"Insufficient VUs\" \
498                         warnings.",
499                        self.vus, rps, num_ops, basis, recommendation,
500                    ));
501                }
502            } else if probe.is_some() {
503                TerminalReporter::print_progress(&format!(
504                    "Pre-flight probe: target latency {}, {} ops/iteration — --vus {} \
505                     is sufficient for --rps {}",
506                    basis, num_ops, self.vus, rps,
507                ));
508            }
509        }
510
511        let k6_config = K6Config {
512            target_url: self.target.clone(),
513            base_path,
514            scenario,
515            duration_secs: Self::parse_duration(&self.duration)?,
516            max_vus: self.vus,
517            threshold_percentile: self.threshold_percentile.clone(),
518            threshold_ms: self.threshold_ms,
519            max_error_rate: self.max_error_rate,
520            auth_header: self.auth.clone(),
521            custom_headers,
522            skip_tls_verify: self.skip_tls_verify,
523            security_testing_enabled,
524            chunked_request_bodies: self.chunked_request_bodies,
525            target_rps: self.target_rps,
526            no_keep_alive: self.no_keep_alive,
527        };
528
529        let generator = K6ScriptGenerator::new(k6_config, templates);
530        let mut script = generator.generate()?;
531        TerminalReporter::print_success("k6 script generated");
532
533        // Check if any advanced features are enabled
534        let has_advanced_features = self.data_file.is_some()
535            || self.error_rate.is_some()
536            || self.security_test
537            || self.parallel_create.is_some()
538            || self.wafbench_dir.is_some();
539
540        // Enhance script with advanced features
541        if has_advanced_features {
542            script = self.generate_enhanced_script(&script)?;
543        }
544
545        // Add mock server integration code
546        if mock_config.is_mock_server {
547            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
548            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
549            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
550
551            // Insert mock server code after imports
552            if let Some(import_end) = script.find("export const options") {
553                script.insert_str(
554                    import_end,
555                    &format!(
556                        "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
557                        helper_code, setup_code, teardown_code
558                    ),
559                );
560            }
561        }
562
563        // Validate the generated script
564        TerminalReporter::print_progress("Validating k6 script...");
565        let validation_errors = K6ScriptGenerator::validate_script(&script);
566        if !validation_errors.is_empty() {
567            TerminalReporter::print_error("Script validation failed");
568            for error in &validation_errors {
569                eprintln!("  {}", error);
570            }
571            return Err(BenchError::Other(format!(
572                "Generated k6 script has {} validation error(s). Please check the output above.",
573                validation_errors.len()
574            )));
575        }
576        TerminalReporter::print_success("Script validation passed");
577
578        // Write script to file
579        let script_path = if let Some(output) = &self.script_output {
580            output.clone()
581        } else {
582            self.output.join("k6-script.js")
583        };
584
585        if let Some(parent) = script_path.parent() {
586            std::fs::create_dir_all(parent)?;
587        }
588        std::fs::write(&script_path, &script)?;
589        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
590
591        // If generate-only mode, exit here
592        if self.generate_only {
593            println!("\nScript generated successfully. Run it with:");
594            println!("  k6 run {}", script_path.display());
595            return Ok(());
596        }
597
598        // Execute k6
599        TerminalReporter::print_progress("Executing load test...");
600        let executor = K6Executor::new()?;
601
602        std::fs::create_dir_all(&self.output)?;
603
604        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
605
606        // Print results
607        let duration_secs = Self::parse_duration(&self.duration)?;
608        TerminalReporter::print_summary_full(
609            &results,
610            duration_secs,
611            self.no_keep_alive,
612            Some(num_ops),
613        );
614
615        println!("\nResults saved to: {}", self.output.display());
616
617        Ok(())
618    }
619
620    /// Execute multi-target bench testing
621    async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
622        TerminalReporter::print_progress("Parsing targets file...");
623        let targets = parse_targets_file(targets_file)?;
624        let num_targets = targets.len();
625        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
626
627        if targets.is_empty() {
628            return Err(BenchError::Other("No targets found in file".to_string()));
629        }
630
631        // Determine max concurrency
632        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
633        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
634
635        // Print header for multi-target mode
636        TerminalReporter::print_header(
637            &self.get_spec_display_name(),
638            &format!("{} targets", num_targets),
639            0,
640            &self.scenario,
641            Self::parse_duration(&self.duration)?,
642        );
643
644        // Create parallel executor
645        let executor = ParallelExecutor::new(
646            BenchCommand {
647                // Clone all fields except targets_file (we don't need it in the executor)
648                spec: self.spec.clone(),
649                spec_dir: self.spec_dir.clone(),
650                merge_conflicts: self.merge_conflicts.clone(),
651                spec_mode: self.spec_mode.clone(),
652                dependency_config: self.dependency_config.clone(),
653                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
654                base_path: self.base_path.clone(),
655                duration: self.duration.clone(),
656                vus: self.vus,
657                target_rps: self.target_rps,
658                no_keep_alive: self.no_keep_alive,
659                scenario: self.scenario.clone(),
660                operations: self.operations.clone(),
661                exclude_operations: self.exclude_operations.clone(),
662                auth: self.auth.clone(),
663                headers: self.headers.clone(),
664                output: self.output.clone(),
665                generate_only: self.generate_only,
666                script_output: self.script_output.clone(),
667                threshold_percentile: self.threshold_percentile.clone(),
668                threshold_ms: self.threshold_ms,
669                max_error_rate: self.max_error_rate,
670                verbose: self.verbose,
671                skip_tls_verify: self.skip_tls_verify,
672                chunked_request_bodies: self.chunked_request_bodies,
673                targets_file: None,
674                max_concurrency: None,
675                results_format: self.results_format.clone(),
676                params_file: self.params_file.clone(),
677                crud_flow: self.crud_flow,
678                flow_config: self.flow_config.clone(),
679                extract_fields: self.extract_fields.clone(),
680                parallel_create: self.parallel_create,
681                data_file: self.data_file.clone(),
682                data_distribution: self.data_distribution.clone(),
683                data_mappings: self.data_mappings.clone(),
684                per_uri_control: self.per_uri_control,
685                error_rate: self.error_rate,
686                error_types: self.error_types.clone(),
687                security_test: self.security_test,
688                security_payloads: self.security_payloads.clone(),
689                security_categories: self.security_categories.clone(),
690                security_target_fields: self.security_target_fields.clone(),
691                wafbench_dir: self.wafbench_dir.clone(),
692                wafbench_cycle_all: self.wafbench_cycle_all,
693                owasp_api_top10: self.owasp_api_top10,
694                owasp_categories: self.owasp_categories.clone(),
695                owasp_auth_header: self.owasp_auth_header.clone(),
696                owasp_auth_token: self.owasp_auth_token.clone(),
697                owasp_admin_paths: self.owasp_admin_paths.clone(),
698                owasp_id_fields: self.owasp_id_fields.clone(),
699                owasp_report: self.owasp_report.clone(),
700                owasp_report_format: self.owasp_report_format.clone(),
701                owasp_iterations: self.owasp_iterations,
702                conformance: false,
703                conformance_api_key: None,
704                conformance_basic_auth: None,
705                conformance_report: PathBuf::from("conformance-report.json"),
706                conformance_categories: None,
707                conformance_report_format: "json".to_string(),
708                conformance_headers: vec![],
709                conformance_all_operations: false,
710                conformance_custom: None,
711                conformance_delay_ms: 0,
712                use_k6: false,
713                conformance_custom_filter: None,
714                export_requests: false,
715                validate_requests: false,
716                conformance_self_test: false,
717            },
718            targets,
719            max_concurrency,
720        );
721
722        // Execute all targets
723        let start_time = std::time::Instant::now();
724        let aggregated_results = executor.execute_all().await?;
725        let elapsed = start_time.elapsed();
726
727        // Organize and report results
728        self.report_multi_target_results(&aggregated_results, elapsed)?;
729
730        Ok(())
731    }
732
733    /// Report results for multi-target execution
734    fn report_multi_target_results(
735        &self,
736        results: &AggregatedResults,
737        elapsed: std::time::Duration,
738    ) -> Result<()> {
739        // Print summary
740        TerminalReporter::print_multi_target_summary(results);
741
742        // Print elapsed time
743        let total_secs = elapsed.as_secs();
744        let hours = total_secs / 3600;
745        let minutes = (total_secs % 3600) / 60;
746        let seconds = total_secs % 60;
747        if hours > 0 {
748            println!("\n  Total Elapsed Time:   {}h {}m {}s", hours, minutes, seconds);
749        } else if minutes > 0 {
750            println!("\n  Total Elapsed Time:   {}m {}s", minutes, seconds);
751        } else {
752            println!("\n  Total Elapsed Time:   {}s", seconds);
753        }
754
755        // Save aggregated summary if requested
756        if self.results_format == "aggregated" || self.results_format == "both" {
757            let summary_path = self.output.join("aggregated_summary.json");
758            let summary_json = serde_json::json!({
759                "total_elapsed_seconds": elapsed.as_secs(),
760                "total_targets": results.total_targets,
761                "successful_targets": results.successful_targets,
762                "failed_targets": results.failed_targets,
763                "aggregated_metrics": {
764                    "total_requests": results.aggregated_metrics.total_requests,
765                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
766                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
767                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
768                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
769                    "error_rate": results.aggregated_metrics.error_rate,
770                    "total_rps": results.aggregated_metrics.total_rps,
771                    "avg_rps": results.aggregated_metrics.avg_rps,
772                    "total_vus_max": results.aggregated_metrics.total_vus_max,
773                },
774                "target_results": results.target_results.iter().map(|r| {
775                    serde_json::json!({
776                        "target_url": r.target_url,
777                        "target_index": r.target_index,
778                        "success": r.success,
779                        "error": r.error,
780                        "total_requests": r.results.total_requests,
781                        "failed_requests": r.results.failed_requests,
782                        "avg_duration_ms": r.results.avg_duration_ms,
783                        "min_duration_ms": r.results.min_duration_ms,
784                        "med_duration_ms": r.results.med_duration_ms,
785                        "p90_duration_ms": r.results.p90_duration_ms,
786                        "p95_duration_ms": r.results.p95_duration_ms,
787                        "p99_duration_ms": r.results.p99_duration_ms,
788                        "max_duration_ms": r.results.max_duration_ms,
789                        "rps": r.results.rps,
790                        "vus_max": r.results.vus_max,
791                        "output_dir": r.output_dir.to_string_lossy(),
792                    })
793                }).collect::<Vec<_>>(),
794            });
795
796            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
797            TerminalReporter::print_success(&format!(
798                "Aggregated summary saved to: {}",
799                summary_path.display()
800            ));
801        }
802
803        // Write CSV with all per-target results for easy parsing
804        let csv_path = self.output.join("all_targets.csv");
805        let mut csv = String::from(
806            "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
807        );
808        for r in &results.target_results {
809            csv.push_str(&format!(
810                "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
811                r.target_url,
812                r.success,
813                r.results.total_requests,
814                r.results.failed_requests,
815                r.results.rps,
816                r.results.vus_max,
817                r.results.min_duration_ms,
818                r.results.avg_duration_ms,
819                r.results.med_duration_ms,
820                r.results.p90_duration_ms,
821                r.results.p95_duration_ms,
822                r.results.p99_duration_ms,
823                r.results.max_duration_ms,
824                r.error.as_deref().unwrap_or(""),
825            ));
826        }
827        let _ = std::fs::write(&csv_path, &csv);
828
829        println!("\nResults saved to: {}", self.output.display());
830        println!("  - Per-target results: {}", self.output.join("target_*").display());
831        println!("  - All targets CSV:    {}", csv_path.display());
832        if self.results_format == "aggregated" || self.results_format == "both" {
833            println!(
834                "  - Aggregated summary: {}",
835                self.output.join("aggregated_summary.json").display()
836            );
837        }
838
839        Ok(())
840    }
841
842    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
843    pub fn parse_duration(duration: &str) -> Result<u64> {
844        let duration = duration.trim();
845
846        if let Some(secs) = duration.strip_suffix('s') {
847            secs.parse::<u64>()
848                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
849        } else if let Some(mins) = duration.strip_suffix('m') {
850            mins.parse::<u64>()
851                .map(|m| m * 60)
852                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
853        } else if let Some(hours) = duration.strip_suffix('h') {
854            hours
855                .parse::<u64>()
856                .map(|h| h * 3600)
857                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
858        } else {
859            // Try parsing as seconds without suffix
860            duration
861                .parse::<u64>()
862                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
863        }
864    }
865
866    /// Parse headers from command line format (Key:Value,Key2:Value2)
867    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
868        match &self.headers {
869            Some(s) => parse_header_string(s),
870            None => Ok(HashMap::new()),
871        }
872    }
873
874    fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
875        let extracted_path = output_dir.join("extracted_values.json");
876        if !extracted_path.exists() {
877            return Ok(ExtractedValues::new());
878        }
879
880        let content = std::fs::read_to_string(&extracted_path)
881            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
882        let parsed: serde_json::Value = serde_json::from_str(&content)
883            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
884
885        let mut extracted = ExtractedValues::new();
886        if let Some(values) = parsed.as_object() {
887            for (key, value) in values {
888                extracted.set(key.clone(), value.clone());
889            }
890        }
891
892        Ok(extracted)
893    }
894
895    /// Resolve the effective base path for API endpoints
896    ///
897    /// Priority:
898    /// 1. CLI --base-path option (if provided, even if empty string)
899    /// 2. Base path extracted from OpenAPI spec's servers URL
900    /// 3. None (no base path)
901    ///
902    /// An empty string from CLI explicitly disables base path.
903    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
904        // CLI option takes priority (including empty string to disable)
905        if let Some(cli_base_path) = &self.base_path {
906            if cli_base_path.is_empty() {
907                // Empty string explicitly means "no base path"
908                return None;
909            }
910            return Some(cli_base_path.clone());
911        }
912
913        // Fall back to spec's base path
914        parser.get_base_path()
915    }
916
917    /// Build mock server integration configuration
918    async fn build_mock_config(&self) -> MockIntegrationConfig {
919        // Check if target looks like a mock server
920        if MockServerDetector::looks_like_mock_server(&self.target) {
921            // Try to detect if it's actually a MockForge server
922            if let Ok(info) = MockServerDetector::detect(&self.target).await {
923                if info.is_mockforge {
924                    TerminalReporter::print_success(&format!(
925                        "Detected MockForge server (version: {})",
926                        info.version.as_deref().unwrap_or("unknown")
927                    ));
928                    return MockIntegrationConfig::mock_server();
929                }
930            }
931        }
932        MockIntegrationConfig::real_api()
933    }
934
935    /// Build CRUD flow configuration
936    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
937        if !self.crud_flow {
938            return None;
939        }
940
941        // If flow_config file is provided, load it
942        if let Some(config_path) = &self.flow_config {
943            match CrudFlowConfig::from_file(config_path) {
944                Ok(config) => return Some(config),
945                Err(e) => {
946                    TerminalReporter::print_warning(&format!(
947                        "Failed to load flow config: {}. Using auto-detection.",
948                        e
949                    ));
950                }
951            }
952        }
953
954        // Parse extract fields
955        let extract_fields = self
956            .extract_fields
957            .as_ref()
958            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
959            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
960
961        Some(CrudFlowConfig {
962            flows: Vec::new(), // Will be auto-detected
963            default_extract_fields: extract_fields,
964        })
965    }
966
967    /// Build data-driven testing configuration
968    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
969        let data_file = self.data_file.as_ref()?;
970
971        let distribution = DataDistribution::from_str(&self.data_distribution)
972            .unwrap_or(DataDistribution::UniquePerVu);
973
974        let mappings = self
975            .data_mappings
976            .as_ref()
977            .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
978            .unwrap_or_default();
979
980        Some(DataDrivenConfig {
981            file_path: data_file.to_string_lossy().to_string(),
982            distribution,
983            mappings,
984            csv_has_header: true,
985            per_uri_control: self.per_uri_control,
986            per_uri_columns: crate::data_driven::PerUriColumns::default(),
987        })
988    }
989
990    /// Build invalid data testing configuration
991    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
992        let error_rate = self.error_rate?;
993
994        let error_types = self
995            .error_types
996            .as_ref()
997            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
998            .unwrap_or_default();
999
1000        Some(InvalidDataConfig {
1001            error_rate,
1002            error_types,
1003            target_fields: Vec::new(),
1004        })
1005    }
1006
1007    /// Build security testing configuration
1008    fn build_security_config(&self) -> Option<SecurityTestConfig> {
1009        if !self.security_test {
1010            return None;
1011        }
1012
1013        let categories = self
1014            .security_categories
1015            .as_ref()
1016            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
1017            .unwrap_or_else(|| {
1018                let mut default = HashSet::new();
1019                default.insert(SecurityCategory::SqlInjection);
1020                default.insert(SecurityCategory::Xss);
1021                default
1022            });
1023
1024        let target_fields = self
1025            .security_target_fields
1026            .as_ref()
1027            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
1028            .unwrap_or_default();
1029
1030        let custom_payloads_file =
1031            self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
1032
1033        Some(SecurityTestConfig {
1034            enabled: true,
1035            categories,
1036            target_fields,
1037            custom_payloads_file,
1038            include_high_risk: false,
1039        })
1040    }
1041
1042    /// Build parallel execution configuration
1043    fn build_parallel_config(&self) -> Option<ParallelConfig> {
1044        let count = self.parallel_create?;
1045
1046        Some(ParallelConfig::new(count))
1047    }
1048
1049    /// Load WAFBench payloads from the specified directory or pattern
1050    fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
1051        let Some(ref wafbench_dir) = self.wafbench_dir else {
1052            return Vec::new();
1053        };
1054
1055        let mut loader = WafBenchLoader::new();
1056
1057        if let Err(e) = loader.load_from_pattern(wafbench_dir) {
1058            TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
1059            return Vec::new();
1060        }
1061
1062        let stats = loader.stats();
1063
1064        if stats.files_processed == 0 {
1065            TerminalReporter::print_warning(&format!(
1066                "No WAFBench YAML files found matching '{}'",
1067                wafbench_dir
1068            ));
1069            // Also report any parse errors that may explain why no files were processed
1070            if !stats.parse_errors.is_empty() {
1071                TerminalReporter::print_warning("Some files were found but failed to parse:");
1072                for error in &stats.parse_errors {
1073                    TerminalReporter::print_warning(&format!("  - {}", error));
1074                }
1075            }
1076            return Vec::new();
1077        }
1078
1079        TerminalReporter::print_progress(&format!(
1080            "Loaded {} WAFBench files, {} test cases, {} payloads",
1081            stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
1082        ));
1083
1084        // Print category breakdown
1085        for (category, count) in &stats.by_category {
1086            TerminalReporter::print_progress(&format!("  - {}: {} tests", category, count));
1087        }
1088
1089        // Report any parse errors
1090        for error in &stats.parse_errors {
1091            TerminalReporter::print_warning(&format!("  Parse error: {}", error));
1092        }
1093
1094        loader.to_security_payloads()
1095    }
1096
1097    /// Generate enhanced k6 script with advanced features
1098    pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
1099        let mut enhanced_script = base_script.to_string();
1100        let mut additional_code = String::new();
1101
1102        // Add data-driven testing code
1103        if let Some(config) = self.build_data_driven_config() {
1104            TerminalReporter::print_progress("Adding data-driven testing support...");
1105            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
1106            additional_code.push('\n');
1107            TerminalReporter::print_success("Data-driven testing enabled");
1108        }
1109
1110        // Add invalid data generation code
1111        if let Some(config) = self.build_invalid_data_config() {
1112            TerminalReporter::print_progress("Adding invalid data testing support...");
1113            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
1114            additional_code.push('\n');
1115            additional_code
1116                .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
1117            additional_code.push('\n');
1118            additional_code
1119                .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
1120            additional_code.push('\n');
1121            TerminalReporter::print_success(&format!(
1122                "Invalid data testing enabled ({}% error rate)",
1123                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1124            ));
1125        }
1126
1127        // Add security testing code
1128        let security_config = self.build_security_config();
1129        let wafbench_payloads = self.load_wafbench_payloads();
1130        let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
1131
1132        if security_config.is_some() || !wafbench_payloads.is_empty() {
1133            TerminalReporter::print_progress("Adding security testing support...");
1134
1135            // Combine built-in payloads with WAFBench payloads
1136            let mut payload_list: Vec<SecurityPayload> = Vec::new();
1137
1138            if let Some(ref config) = security_config {
1139                payload_list.extend(SecurityPayloads::get_payloads(config));
1140            }
1141
1142            // Add WAFBench payloads
1143            if !wafbench_payloads.is_empty() {
1144                TerminalReporter::print_progress(&format!(
1145                    "Loading {} WAFBench attack patterns...",
1146                    wafbench_payloads.len()
1147                ));
1148                payload_list.extend(wafbench_payloads);
1149            }
1150
1151            let target_fields =
1152                security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1153
1154            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1155                &payload_list,
1156                self.wafbench_cycle_all,
1157            ));
1158            additional_code.push('\n');
1159            additional_code
1160                .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1161            additional_code.push('\n');
1162            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1163            additional_code.push('\n');
1164
1165            let mode = if self.wafbench_cycle_all {
1166                "cycle-all"
1167            } else {
1168                "random"
1169            };
1170            TerminalReporter::print_success(&format!(
1171                "Security testing enabled ({} payloads, {} mode)",
1172                payload_list.len(),
1173                mode
1174            ));
1175        } else if security_requested {
1176            // User requested security testing (e.g., --wafbench-dir) but no payloads were loaded.
1177            // The template has security_testing_enabled=true so it renders calling code.
1178            // We must inject stub definitions to avoid undefined function references.
1179            TerminalReporter::print_warning(
1180                "Security testing was requested but no payloads were loaded. \
1181                 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1182            );
1183            additional_code
1184                .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1185            additional_code.push('\n');
1186            additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1187            additional_code.push('\n');
1188        }
1189
1190        // Add parallel execution code
1191        if let Some(config) = self.build_parallel_config() {
1192            TerminalReporter::print_progress("Adding parallel execution support...");
1193            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1194            additional_code.push('\n');
1195            TerminalReporter::print_success(&format!(
1196                "Parallel execution enabled (count: {})",
1197                config.count
1198            ));
1199        }
1200
1201        // Insert additional code after the imports section
1202        if !additional_code.is_empty() {
1203            // Find the end of the import section
1204            if let Some(import_end) = enhanced_script.find("export const options") {
1205                enhanced_script.insert_str(
1206                    import_end,
1207                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1208                );
1209            }
1210        }
1211
1212        Ok(enhanced_script)
1213    }
1214
1215    /// Execute specs sequentially with dependency ordering and value passing
1216    async fn execute_sequential_specs(&self) -> Result<()> {
1217        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1218
1219        // Load all specs (without merging)
1220        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1221
1222        if !self.spec.is_empty() {
1223            let specs = load_specs_from_files(self.spec.clone())
1224                .await
1225                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1226            all_specs.extend(specs);
1227        }
1228
1229        if let Some(spec_dir) = &self.spec_dir {
1230            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1231                BenchError::Other(format!("Failed to load specs from directory: {}", e))
1232            })?;
1233            all_specs.extend(dir_specs);
1234        }
1235
1236        if all_specs.is_empty() {
1237            return Err(BenchError::Other(
1238                "No spec files found for sequential execution".to_string(),
1239            ));
1240        }
1241
1242        TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1243
1244        // Load dependency config or auto-detect
1245        let execution_order = if let Some(config_path) = &self.dependency_config {
1246            TerminalReporter::print_progress("Loading dependency configuration...");
1247            let config = SpecDependencyConfig::from_file(config_path)?;
1248
1249            if !config.disable_auto_detect && config.execution_order.is_empty() {
1250                // Auto-detect if config doesn't specify order
1251                self.detect_and_sort_specs(&all_specs)?
1252            } else {
1253                // Use configured order
1254                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1255            }
1256        } else {
1257            // Auto-detect dependencies
1258            self.detect_and_sort_specs(&all_specs)?
1259        };
1260
1261        TerminalReporter::print_success(&format!(
1262            "Execution order: {}",
1263            execution_order
1264                .iter()
1265                .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1266                .collect::<Vec<_>>()
1267                .join(" → ")
1268        ));
1269
1270        // Execute each spec in order
1271        let mut extracted_values = ExtractedValues::new();
1272        let total_specs = execution_order.len();
1273
1274        for (index, spec_path) in execution_order.iter().enumerate() {
1275            let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1276
1277            TerminalReporter::print_progress(&format!(
1278                "[{}/{}] Executing spec: {}",
1279                index + 1,
1280                total_specs,
1281                spec_name
1282            ));
1283
1284            // Find the spec in our loaded specs (match by full path or filename)
1285            let spec = all_specs
1286                .iter()
1287                .find(|(p, _)| {
1288                    p == spec_path
1289                        || p.file_name() == spec_path.file_name()
1290                        || p.file_name() == Some(spec_path.as_os_str())
1291                })
1292                .map(|(_, s)| s.clone())
1293                .ok_or_else(|| {
1294                    BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1295                })?;
1296
1297            // Execute this spec with any extracted values from previous specs
1298            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1299
1300            // Merge extracted values for the next spec
1301            extracted_values.merge(&new_values);
1302
1303            TerminalReporter::print_success(&format!(
1304                "[{}/{}] Completed: {} (extracted {} values)",
1305                index + 1,
1306                total_specs,
1307                spec_name,
1308                new_values.values.len()
1309            ));
1310        }
1311
1312        TerminalReporter::print_success(&format!(
1313            "Sequential execution complete: {} specs executed",
1314            total_specs
1315        ));
1316
1317        Ok(())
1318    }
1319
1320    /// Detect dependencies and return topologically sorted spec paths
1321    fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1322        TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1323
1324        let mut detector = DependencyDetector::new();
1325        let dependencies = detector.detect_dependencies(specs);
1326
1327        if dependencies.is_empty() {
1328            TerminalReporter::print_progress("No dependencies detected, using file order");
1329            return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1330        }
1331
1332        TerminalReporter::print_progress(&format!(
1333            "Detected {} cross-spec dependencies",
1334            dependencies.len()
1335        ));
1336
1337        for dep in &dependencies {
1338            TerminalReporter::print_progress(&format!(
1339                "  {} → {} (via field '{}')",
1340                dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1341                dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1342                dep.field_name
1343            ));
1344        }
1345
1346        topological_sort(specs, &dependencies)
1347    }
1348
1349    /// Execute a single spec and extract values for dependent specs
1350    async fn execute_single_spec(
1351        &self,
1352        spec: &OpenApiSpec,
1353        spec_name: &str,
1354        _external_values: &ExtractedValues,
1355    ) -> Result<ExtractedValues> {
1356        let parser = SpecParser::from_spec(spec.clone());
1357
1358        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
1359        if self.crud_flow {
1360            // Execute CRUD flow and extract values
1361            self.execute_crud_flow_with_extraction(&parser, spec_name).await
1362        } else {
1363            // Execute standard benchmark (no value extraction in non-CRUD mode)
1364            self.execute_standard_spec(&parser, spec_name).await?;
1365            Ok(ExtractedValues::new())
1366        }
1367    }
1368
1369    /// Execute CRUD flow with value extraction for sequential mode
1370    async fn execute_crud_flow_with_extraction(
1371        &self,
1372        parser: &SpecParser,
1373        spec_name: &str,
1374    ) -> Result<ExtractedValues> {
1375        let operations = parser.get_operations();
1376        let flows = CrudFlowDetector::detect_flows(&operations);
1377
1378        if flows.is_empty() {
1379            TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1380            return Ok(ExtractedValues::new());
1381        }
1382
1383        TerminalReporter::print_progress(&format!(
1384            "  {} CRUD flow(s) in {}",
1385            flows.len(),
1386            spec_name
1387        ));
1388
1389        // Generate and execute the CRUD flow script
1390        let mut handlebars = handlebars::Handlebars::new();
1391        // Register json helper for serializing arrays/objects in templates
1392        handlebars.register_helper(
1393            "json",
1394            Box::new(
1395                |h: &handlebars::Helper,
1396                 _: &handlebars::Handlebars,
1397                 _: &handlebars::Context,
1398                 _: &mut handlebars::RenderContext,
1399                 out: &mut dyn handlebars::Output|
1400                 -> handlebars::HelperResult {
1401                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1402                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1403                    Ok(())
1404                },
1405            ),
1406        );
1407        let template = include_str!("templates/k6_crud_flow.hbs");
1408        let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1409
1410        let custom_headers = self.parse_headers()?;
1411        let config = self.build_crud_flow_config().unwrap_or_default();
1412
1413        // Load parameter overrides if provided (for body configurations)
1414        let param_overrides = if let Some(params_file) = &self.params_file {
1415            let overrides = ParameterOverrides::from_file(params_file)?;
1416            Some(overrides)
1417        } else {
1418            None
1419        };
1420
1421        // Generate stages from scenario
1422        let duration_secs = Self::parse_duration(&self.duration)?;
1423        let scenario =
1424            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1425        let stages = scenario.generate_stages(duration_secs, self.vus);
1426
1427        // Resolve base path (CLI option takes priority over spec's servers URL)
1428        let api_base_path = self.resolve_base_path(parser);
1429
1430        // Build headers JSON string for the template
1431        let mut all_headers = custom_headers.clone();
1432        if let Some(auth) = &self.auth {
1433            all_headers.insert("Authorization".to_string(), auth.clone());
1434        }
1435        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1436
1437        // Track all dynamic placeholders across all operations
1438        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1439
1440        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1441            // Use the metric-name sanitizer (caps at 112 chars + hash suffix)
1442            // so deeply nested flow names don't blow past k6's 128-char limit
1443            // when concatenated with `_step{i}_latency`. See issue #79.
1444            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1445            serde_json::json!({
1446                "name": sanitized_name.clone(),
1447                "display_name": f.name,
1448                "base_path": f.base_path,
1449                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1450                    // Parse operation to get method and path
1451                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1452                    let method_raw = if !parts.is_empty() {
1453                        parts[0].to_uppercase()
1454                    } else {
1455                        "GET".to_string()
1456                    };
1457                    let method = if !parts.is_empty() {
1458                        let m = parts[0].to_lowercase();
1459                        // k6 uses 'del' for DELETE
1460                        if m == "delete" { "del".to_string() } else { m }
1461                    } else {
1462                        "get".to_string()
1463                    };
1464                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1465                    // Prepend API base path if configured
1466                    let path = if let Some(ref bp) = api_base_path {
1467                        format!("{}{}", bp, raw_path)
1468                    } else {
1469                        raw_path.to_string()
1470                    };
1471                    let is_get_or_head = method == "get" || method == "head";
1472                    // POST, PUT, PATCH typically have bodies
1473                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1474
1475                    // Look up body from params file if available
1476                    let body_value = if has_body {
1477                        param_overrides.as_ref()
1478                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1479                            .and_then(|oo| oo.body)
1480                            .unwrap_or_else(|| serde_json::json!({}))
1481                    } else {
1482                        serde_json::json!({})
1483                    };
1484
1485                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1486                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1487
1488                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1489                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1490                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1491
1492                    serde_json::json!({
1493                        "operation": s.operation,
1494                        "method": method,
1495                        "path": path,
1496                        "extract": s.extract,
1497                        "use_values": s.use_values,
1498                        "use_body": s.use_body,
1499                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1500                        "inject_attacks": s.inject_attacks,
1501                        "attack_types": s.attack_types,
1502                        "description": s.description,
1503                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1504                        "is_get_or_head": is_get_or_head,
1505                        "has_body": has_body,
1506                        "body": processed_body.value,
1507                        "body_is_dynamic": body_is_dynamic,
1508                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1509                    })
1510                }).collect::<Vec<_>>(),
1511            })
1512        }).collect();
1513
1514        // Collect all placeholders from all steps
1515        for flow_data in &flows_data {
1516            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1517                for step in steps {
1518                    if let Some(placeholders_arr) =
1519                        step.get("_placeholders").and_then(|p| p.as_array())
1520                    {
1521                        for p_str in placeholders_arr {
1522                            if let Some(p_name) = p_str.as_str() {
1523                                match p_name {
1524                                    "VU" => {
1525                                        all_placeholders.insert(DynamicPlaceholder::VU);
1526                                    }
1527                                    "Iteration" => {
1528                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1529                                    }
1530                                    "Timestamp" => {
1531                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1532                                    }
1533                                    "UUID" => {
1534                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1535                                    }
1536                                    "Random" => {
1537                                        all_placeholders.insert(DynamicPlaceholder::Random);
1538                                    }
1539                                    "Counter" => {
1540                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1541                                    }
1542                                    "Date" => {
1543                                        all_placeholders.insert(DynamicPlaceholder::Date);
1544                                    }
1545                                    "VuIter" => {
1546                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1547                                    }
1548                                    _ => {}
1549                                }
1550                            }
1551                        }
1552                    }
1553                }
1554            }
1555        }
1556
1557        // Get required imports and globals based on placeholders used
1558        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1559        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1560
1561        // Check if security testing is enabled
1562        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1563
1564        let data = serde_json::json!({
1565            "base_url": self.target,
1566            "flows": flows_data,
1567            "extract_fields": config.default_extract_fields,
1568            "duration_secs": duration_secs,
1569            "max_vus": self.vus,
1570            "auth_header": self.auth,
1571            "custom_headers": custom_headers,
1572            "skip_tls_verify": self.skip_tls_verify,
1573            // Add missing template fields
1574            "stages": stages.iter().map(|s| serde_json::json!({
1575                "duration": s.duration,
1576                "target": s.target,
1577            })).collect::<Vec<_>>(),
1578            "threshold_percentile": self.threshold_percentile,
1579            "threshold_ms": self.threshold_ms,
1580            "max_error_rate": self.max_error_rate,
1581            "headers": headers_json,
1582            "dynamic_imports": required_imports,
1583            "dynamic_globals": required_globals,
1584            "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
1585            // Security testing settings
1586            "security_testing_enabled": security_testing_enabled,
1587            "has_custom_headers": !custom_headers.is_empty(),
1588        });
1589
1590        let mut script = handlebars
1591            .render_template(template, &data)
1592            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1593
1594        // Enhance script with security testing support if enabled
1595        if security_testing_enabled {
1596            script = self.generate_enhanced_script(&script)?;
1597        }
1598
1599        // Write and execute script
1600        let script_path =
1601            self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1602
1603        std::fs::create_dir_all(self.output.clone())?;
1604        std::fs::write(&script_path, &script)?;
1605
1606        if !self.generate_only {
1607            let executor = K6Executor::new()?;
1608            std::fs::create_dir_all(&output_dir)?;
1609
1610            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1611
1612            let extracted = Self::parse_extracted_values(&output_dir)?;
1613            TerminalReporter::print_progress(&format!(
1614                "  Extracted {} value(s) from {}",
1615                extracted.values.len(),
1616                spec_name
1617            ));
1618            return Ok(extracted);
1619        }
1620
1621        Ok(ExtractedValues::new())
1622    }
1623
1624    /// Execute standard (non-CRUD) spec benchmark
1625    async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1626        let mut operations = if let Some(filter) = &self.operations {
1627            parser.filter_operations(filter)?
1628        } else {
1629            parser.get_operations()
1630        };
1631
1632        if let Some(exclude) = &self.exclude_operations {
1633            operations = parser.exclude_operations(operations, exclude)?;
1634        }
1635
1636        if operations.is_empty() {
1637            TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1638            return Ok(());
1639        }
1640
1641        TerminalReporter::print_progress(&format!(
1642            "  {} operations in {}",
1643            operations.len(),
1644            spec_name
1645        ));
1646
1647        // Generate request templates
1648        let templates: Vec<_> = operations
1649            .iter()
1650            .map(RequestGenerator::generate_template)
1651            .collect::<Result<Vec<_>>>()?;
1652
1653        // Parse headers
1654        let custom_headers = self.parse_headers()?;
1655
1656        // Resolve base path
1657        let base_path = self.resolve_base_path(parser);
1658
1659        // Generate k6 script
1660        let scenario =
1661            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1662
1663        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1664
1665        let k6_config = K6Config {
1666            target_url: self.target.clone(),
1667            base_path,
1668            scenario,
1669            duration_secs: Self::parse_duration(&self.duration)?,
1670            max_vus: self.vus,
1671            threshold_percentile: self.threshold_percentile.clone(),
1672            threshold_ms: self.threshold_ms,
1673            max_error_rate: self.max_error_rate,
1674            auth_header: self.auth.clone(),
1675            custom_headers,
1676            skip_tls_verify: self.skip_tls_verify,
1677            security_testing_enabled,
1678            chunked_request_bodies: self.chunked_request_bodies,
1679            target_rps: self.target_rps,
1680            no_keep_alive: self.no_keep_alive,
1681        };
1682
1683        let generator = K6ScriptGenerator::new(k6_config, templates);
1684        let mut script = generator.generate()?;
1685
1686        // Enhance script with advanced features (security testing, etc.)
1687        let has_advanced_features = self.data_file.is_some()
1688            || self.error_rate.is_some()
1689            || self.security_test
1690            || self.parallel_create.is_some()
1691            || self.wafbench_dir.is_some();
1692
1693        if has_advanced_features {
1694            script = self.generate_enhanced_script(&script)?;
1695        }
1696
1697        // Write and execute script
1698        let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1699
1700        std::fs::create_dir_all(self.output.clone())?;
1701        std::fs::write(&script_path, &script)?;
1702
1703        if !self.generate_only {
1704            let executor = K6Executor::new()?;
1705            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1706            std::fs::create_dir_all(&output_dir)?;
1707
1708            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1709        }
1710
1711        Ok(())
1712    }
1713
1714    /// Execute CRUD flow testing mode
1715    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1716        // Check if a custom flow config is provided
1717        let config = self.build_crud_flow_config().unwrap_or_default();
1718
1719        // Use flows from config if provided, otherwise auto-detect
1720        let flows = if !config.flows.is_empty() {
1721            TerminalReporter::print_progress("Using custom flow configuration...");
1722            config.flows.clone()
1723        } else {
1724            TerminalReporter::print_progress("Detecting CRUD operations...");
1725            let operations = parser.get_operations();
1726            CrudFlowDetector::detect_flows(&operations)
1727        };
1728
1729        if flows.is_empty() {
1730            return Err(BenchError::Other(
1731                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1732            ));
1733        }
1734
1735        if config.flows.is_empty() {
1736            TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1737        } else {
1738            TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1739        }
1740
1741        for flow in &flows {
1742            TerminalReporter::print_progress(&format!(
1743                "  - {}: {} steps",
1744                flow.name,
1745                flow.steps.len()
1746            ));
1747        }
1748
1749        // Generate CRUD flow script
1750        let mut handlebars = handlebars::Handlebars::new();
1751        // Register json helper for serializing arrays/objects in templates
1752        handlebars.register_helper(
1753            "json",
1754            Box::new(
1755                |h: &handlebars::Helper,
1756                 _: &handlebars::Handlebars,
1757                 _: &handlebars::Context,
1758                 _: &mut handlebars::RenderContext,
1759                 out: &mut dyn handlebars::Output|
1760                 -> handlebars::HelperResult {
1761                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1762                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1763                    Ok(())
1764                },
1765            ),
1766        );
1767        let template = include_str!("templates/k6_crud_flow.hbs");
1768
1769        let custom_headers = self.parse_headers()?;
1770
1771        // Load parameter overrides if provided (for body configurations)
1772        let param_overrides = if let Some(params_file) = &self.params_file {
1773            TerminalReporter::print_progress("Loading parameter overrides...");
1774            let overrides = ParameterOverrides::from_file(params_file)?;
1775            TerminalReporter::print_success(&format!(
1776                "Loaded parameter overrides ({} operation-specific, {} defaults)",
1777                overrides.operations.len(),
1778                if overrides.defaults.is_empty() { 0 } else { 1 }
1779            ));
1780            Some(overrides)
1781        } else {
1782            None
1783        };
1784
1785        // Generate stages from scenario
1786        let duration_secs = Self::parse_duration(&self.duration)?;
1787        let scenario =
1788            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1789        let stages = scenario.generate_stages(duration_secs, self.vus);
1790
1791        // Resolve base path (CLI option takes priority over spec's servers URL)
1792        let api_base_path = self.resolve_base_path(parser);
1793        if let Some(ref bp) = api_base_path {
1794            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1795        }
1796
1797        // Build headers JSON string for the template
1798        let mut all_headers = custom_headers.clone();
1799        if let Some(auth) = &self.auth {
1800            all_headers.insert("Authorization".to_string(), auth.clone());
1801        }
1802        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1803
1804        // Track all dynamic placeholders across all operations
1805        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1806
1807        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1808            // Sanitize flow name for use as JavaScript variable and k6 metric names.
1809            // Use the metric-name sanitizer (caps at 112 chars + hash suffix) so
1810            // deeply nested flow names don't blow past k6's 128-char limit when
1811            // concatenated with `_step{i}_latency`. See issue #79.
1812            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1813            serde_json::json!({
1814                "name": sanitized_name.clone(),  // Use sanitized name for variable names
1815                "display_name": f.name,          // Keep original for comments/display
1816                "base_path": f.base_path,
1817                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1818                    // Parse operation to get method and path
1819                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1820                    let method_raw = if !parts.is_empty() {
1821                        parts[0].to_uppercase()
1822                    } else {
1823                        "GET".to_string()
1824                    };
1825                    let method = if !parts.is_empty() {
1826                        let m = parts[0].to_lowercase();
1827                        // k6 uses 'del' for DELETE
1828                        if m == "delete" { "del".to_string() } else { m }
1829                    } else {
1830                        "get".to_string()
1831                    };
1832                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1833                    // Prepend API base path if configured
1834                    let path = if let Some(ref bp) = api_base_path {
1835                        format!("{}{}", bp, raw_path)
1836                    } else {
1837                        raw_path.to_string()
1838                    };
1839                    let is_get_or_head = method == "get" || method == "head";
1840                    // POST, PUT, PATCH typically have bodies
1841                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1842
1843                    // Look up body from params file if available (use raw_path for matching)
1844                    let body_value = if has_body {
1845                        param_overrides.as_ref()
1846                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1847                            .and_then(|oo| oo.body)
1848                            .unwrap_or_else(|| serde_json::json!({}))
1849                    } else {
1850                        serde_json::json!({})
1851                    };
1852
1853                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1854                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1855                    // Note: all_placeholders is captured by the closure but we can't mutate it directly
1856                    // We'll collect placeholders separately below
1857
1858                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1859                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1860                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1861
1862                    serde_json::json!({
1863                        "operation": s.operation,
1864                        "method": method,
1865                        "path": path,
1866                        "extract": s.extract,
1867                        "use_values": s.use_values,
1868                        "use_body": s.use_body,
1869                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1870                        "inject_attacks": s.inject_attacks,
1871                        "attack_types": s.attack_types,
1872                        "description": s.description,
1873                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1874                        "is_get_or_head": is_get_or_head,
1875                        "has_body": has_body,
1876                        "body": processed_body.value,
1877                        "body_is_dynamic": body_is_dynamic,
1878                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1879                    })
1880                }).collect::<Vec<_>>(),
1881            })
1882        }).collect();
1883
1884        // Collect all placeholders from all steps
1885        for flow_data in &flows_data {
1886            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1887                for step in steps {
1888                    if let Some(placeholders_arr) =
1889                        step.get("_placeholders").and_then(|p| p.as_array())
1890                    {
1891                        for p_str in placeholders_arr {
1892                            if let Some(p_name) = p_str.as_str() {
1893                                // Parse placeholder from debug string
1894                                match p_name {
1895                                    "VU" => {
1896                                        all_placeholders.insert(DynamicPlaceholder::VU);
1897                                    }
1898                                    "Iteration" => {
1899                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1900                                    }
1901                                    "Timestamp" => {
1902                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1903                                    }
1904                                    "UUID" => {
1905                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1906                                    }
1907                                    "Random" => {
1908                                        all_placeholders.insert(DynamicPlaceholder::Random);
1909                                    }
1910                                    "Counter" => {
1911                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1912                                    }
1913                                    "Date" => {
1914                                        all_placeholders.insert(DynamicPlaceholder::Date);
1915                                    }
1916                                    "VuIter" => {
1917                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1918                                    }
1919                                    _ => {}
1920                                }
1921                            }
1922                        }
1923                    }
1924                }
1925            }
1926        }
1927
1928        // Get required imports and globals based on placeholders used
1929        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1930        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1931
1932        // Build invalid data config if error injection is enabled
1933        let invalid_data_config = self.build_invalid_data_config();
1934        let error_injection_enabled = invalid_data_config.is_some();
1935        let error_rate = self.error_rate.unwrap_or(0.0);
1936        let error_types: Vec<String> = invalid_data_config
1937            .as_ref()
1938            .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1939            .unwrap_or_default();
1940
1941        if error_injection_enabled {
1942            TerminalReporter::print_progress(&format!(
1943                "Error injection enabled ({}% rate)",
1944                (error_rate * 100.0) as u32
1945            ));
1946        }
1947
1948        // Check if security testing is enabled
1949        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1950
1951        let data = serde_json::json!({
1952            "base_url": self.target,
1953            "flows": flows_data,
1954            "extract_fields": config.default_extract_fields,
1955            "duration_secs": duration_secs,
1956            "max_vus": self.vus,
1957            "auth_header": self.auth,
1958            "custom_headers": custom_headers,
1959            "skip_tls_verify": self.skip_tls_verify,
1960            // Add missing template fields
1961            "stages": stages.iter().map(|s| serde_json::json!({
1962                "duration": s.duration,
1963                "target": s.target,
1964            })).collect::<Vec<_>>(),
1965            "threshold_percentile": self.threshold_percentile,
1966            "threshold_ms": self.threshold_ms,
1967            "max_error_rate": self.max_error_rate,
1968            "headers": headers_json,
1969            "dynamic_imports": required_imports,
1970            "dynamic_globals": required_globals,
1971            "extracted_values_output_path": self
1972                .output
1973                .join("crud_flow_extracted_values.json")
1974                .to_string_lossy(),
1975            // Error injection settings
1976            "error_injection_enabled": error_injection_enabled,
1977            "error_rate": error_rate,
1978            "error_types": error_types,
1979            // Security testing settings
1980            "security_testing_enabled": security_testing_enabled,
1981            "has_custom_headers": !custom_headers.is_empty(),
1982        });
1983
1984        let mut script = handlebars
1985            .render_template(template, &data)
1986            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1987
1988        // Enhance script with security testing support if enabled
1989        if security_testing_enabled {
1990            script = self.generate_enhanced_script(&script)?;
1991        }
1992
1993        // Validate the generated CRUD flow script
1994        TerminalReporter::print_progress("Validating CRUD flow script...");
1995        let validation_errors = K6ScriptGenerator::validate_script(&script);
1996        if !validation_errors.is_empty() {
1997            TerminalReporter::print_error("CRUD flow script validation failed");
1998            for error in &validation_errors {
1999                eprintln!("  {}", error);
2000            }
2001            return Err(BenchError::Other(format!(
2002                "CRUD flow script validation failed with {} error(s)",
2003                validation_errors.len()
2004            )));
2005        }
2006
2007        TerminalReporter::print_success("CRUD flow script generated");
2008
2009        // Write and execute script
2010        let script_path = if let Some(output) = &self.script_output {
2011            output.clone()
2012        } else {
2013            self.output.join("k6-crud-flow-script.js")
2014        };
2015
2016        if let Some(parent) = script_path.parent() {
2017            std::fs::create_dir_all(parent)?;
2018        }
2019        std::fs::write(&script_path, &script)?;
2020        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2021
2022        if self.generate_only {
2023            println!("\nScript generated successfully. Run it with:");
2024            println!("  k6 run {}", script_path.display());
2025            return Ok(());
2026        }
2027
2028        // Execute k6
2029        TerminalReporter::print_progress("Executing CRUD flow test...");
2030        let executor = K6Executor::new()?;
2031        std::fs::create_dir_all(&self.output)?;
2032
2033        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2034
2035        let duration_secs = Self::parse_duration(&self.duration)?;
2036        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
2037
2038        Ok(())
2039    }
2040
2041    /// Execute OpenAPI 3.0.0 conformance testing mode
2042    async fn execute_conformance_test(&self) -> Result<()> {
2043        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2044        use crate::conformance::report::ConformanceReport;
2045        use crate::conformance::spec::ConformanceFeature;
2046
2047        TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
2048
2049        // Conformance testing is a functional correctness check (1 VU, 1 iteration).
2050        // --vus and -d flags are always ignored in this mode.
2051        TerminalReporter::print_progress(
2052            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
2053        );
2054
2055        // Parse category filter
2056        let categories = self.conformance_categories.as_ref().map(|cats_str| {
2057            cats_str
2058                .split(',')
2059                .filter_map(|s| {
2060                    let trimmed = s.trim();
2061                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2062                        Some(canonical.to_string())
2063                    } else {
2064                        TerminalReporter::print_warning(&format!(
2065                            "Unknown conformance category: '{}'. Valid categories: {}",
2066                            trimmed,
2067                            ConformanceFeature::cli_category_names()
2068                                .iter()
2069                                .map(|(cli, _)| *cli)
2070                                .collect::<Vec<_>>()
2071                                .join(", ")
2072                        ));
2073                        None
2074                    }
2075                })
2076                .collect::<Vec<String>>()
2077        });
2078
2079        // Parse custom headers from "Key: Value" format
2080        let custom_headers: Vec<(String, String)> = self
2081            .conformance_headers
2082            .iter()
2083            .filter_map(|h| {
2084                let (name, value) = h.split_once(':')?;
2085                Some((name.trim().to_string(), value.trim().to_string()))
2086            })
2087            .collect();
2088
2089        if !custom_headers.is_empty() {
2090            TerminalReporter::print_progress(&format!(
2091                "Using {} custom header(s) for authentication",
2092                custom_headers.len()
2093            ));
2094        }
2095
2096        if self.conformance_delay_ms > 0 {
2097            TerminalReporter::print_progress(&format!(
2098                "Using {}ms delay between conformance requests",
2099                self.conformance_delay_ms
2100            ));
2101        }
2102
2103        // Ensure output dir exists so canonicalize works for the report path
2104        std::fs::create_dir_all(&self.output)?;
2105
2106        let config = ConformanceConfig {
2107            target_url: self.target.clone(),
2108            api_key: self.conformance_api_key.clone(),
2109            basic_auth: self.conformance_basic_auth.clone(),
2110            skip_tls_verify: self.skip_tls_verify,
2111            categories,
2112            base_path: self.base_path.clone(),
2113            custom_headers,
2114            output_dir: Some(self.output.clone()),
2115            all_operations: self.conformance_all_operations,
2116            custom_checks_file: self.conformance_custom.clone(),
2117            request_delay_ms: self.conformance_delay_ms,
2118            custom_filter: self.conformance_custom_filter.clone(),
2119            export_requests: self.export_requests,
2120            validate_requests: self.validate_requests,
2121        };
2122
2123        // Branch: spec-driven mode vs reference mode
2124        // Annotate operations if spec is provided (used by both native and k6 paths)
2125        let annotated_ops = if !self.spec.is_empty() {
2126            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2127            let parser = SpecParser::from_file(&self.spec[0]).await?;
2128
2129            // Issue #79 round 12 — Srikanth ran `--conformance --operations "GET,POST"`
2130            // and saw DELETE/PATCH exercised anyway. Conformance silently ignored
2131            // the filter. Apply it (and `--exclude-operations`) the same way the
2132            // regular bench path does so users can scope the run.
2133            let mut operations = if let Some(filter) = &self.operations {
2134                parser.filter_operations(filter)?
2135            } else {
2136                parser.get_operations()
2137            };
2138            if let Some(exclude) = &self.exclude_operations {
2139                let before_count = operations.len();
2140                operations = parser.exclude_operations(operations, exclude)?;
2141                let excluded_count = before_count - operations.len();
2142                if excluded_count > 0 {
2143                    TerminalReporter::print_progress(&format!(
2144                        "Excluded {} operations matching '{}'",
2145                        excluded_count, exclude
2146                    ));
2147                }
2148            }
2149
2150            let annotated =
2151                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2152                    &operations,
2153                    parser.spec(),
2154                );
2155            TerminalReporter::print_success(&format!(
2156                "Analyzed {} operations, found {} feature annotations",
2157                operations.len(),
2158                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2159            ));
2160            Some(annotated)
2161        } else {
2162            None
2163        };
2164
2165        // Issue #79 round 13 (4) — `--conformance-self-test` replaces
2166        // the standard conformance run with a positive + per-category
2167        // negative driver that verifies the server actually rejects
2168        // bad requests with 4xx. Wires the spec-annotated operations
2169        // through `conformance::self_test::run_self_test` and prints
2170        // the resulting pass/fail matrix.
2171        if self.conformance_self_test {
2172            let Some(ops) = annotated_ops else {
2173                TerminalReporter::print_error(
2174                    "--conformance-self-test requires --spec; no operations to test",
2175                );
2176                return Ok(());
2177            };
2178            let cfg = crate::conformance::self_test::SelfTestConfig {
2179                target_url: self.target.clone(),
2180                skip_tls_verify: self.skip_tls_verify,
2181                timeout: std::time::Duration::from_secs(30),
2182                // `custom_headers` was already moved into the
2183                // `ConformanceConfig` above; re-derive from `self` so
2184                // we don't borrow it twice.
2185                extra_headers: self
2186                    .conformance_headers
2187                    .iter()
2188                    .filter_map(|h| {
2189                        let (n, v) = h.split_once(':')?;
2190                        Some((n.trim().to_string(), v.trim().to_string()))
2191                    })
2192                    .collect(),
2193                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
2194            };
2195            TerminalReporter::print_progress(&format!(
2196                "Self-test mode: driving {} operations with positive + per-category negative cases",
2197                ops.len()
2198            ));
2199            let report =
2200                crate::conformance::self_test::run_self_test(&ops, &cfg).await.map_err(|e| {
2201                    crate::error::BenchError::Other(format!("self-test client error: {e}"))
2202                })?;
2203            TerminalReporter::print_progress(&report.render_summary());
2204            // Persist the JSON report alongside the regular conformance
2205            // report so it's grep-able next to the buffer dump from the
2206            // admin endpoint.
2207            let json_path = self.output.join("conformance-self-test.json");
2208            if let Ok(json) = serde_json::to_string_pretty(&report) {
2209                let _ = std::fs::write(&json_path, json);
2210                TerminalReporter::print_progress(&format!(
2211                    "Self-test report written to {}",
2212                    json_path.display()
2213                ));
2214            }
2215            if !report.all_passed() {
2216                TerminalReporter::print_warning(
2217                    "Self-test detected gaps — server let through at least one request that should have been a 4xx",
2218                );
2219            } else {
2220                TerminalReporter::print_success(
2221                    "Self-test passed — all positive cases accepted and all negative cases rejected",
2222                );
2223            }
2224            return Ok(());
2225        }
2226
2227        // Request validation against OpenAPI spec (if --validate-requests is set)
2228        if self.validate_requests && !self.spec.is_empty() {
2229            TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
2230            let violation_count = crate::conformance::request_validator::run_request_validation(
2231                &self.spec,
2232                self.conformance_custom.as_deref(),
2233                self.base_path.as_deref(),
2234                &self.output,
2235            )
2236            .await?;
2237            if violation_count > 0 {
2238                TerminalReporter::print_warning(&format!(
2239                    "{} request validation violation(s) found — see conformance-request-violations.json",
2240                    violation_count
2241                ));
2242            } else {
2243                TerminalReporter::print_success("All requests conform to the OpenAPI spec");
2244            }
2245        }
2246
2247        // If generate-only OR --use-k6, use the k6 script generation path
2248        if self.generate_only || self.use_k6 {
2249            let script = if let Some(annotated) = &annotated_ops {
2250                let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2251                    config,
2252                    annotated.clone(),
2253                );
2254                let op_count = gen.operation_count();
2255                let (script, check_count) = gen.generate()?;
2256                TerminalReporter::print_success(&format!(
2257                    "Conformance: {} operations analyzed, {} unique checks generated",
2258                    op_count, check_count
2259                ));
2260                script
2261            } else {
2262                let generator = ConformanceGenerator::new(config);
2263                generator.generate()?
2264            };
2265
2266            let script_path = self.output.join("k6-conformance.js");
2267            std::fs::write(&script_path, &script).map_err(|e| {
2268                BenchError::Other(format!("Failed to write conformance script: {}", e))
2269            })?;
2270            TerminalReporter::print_success(&format!(
2271                "Conformance script generated: {}",
2272                script_path.display()
2273            ));
2274
2275            if self.generate_only {
2276                println!("\nScript generated. Run with:");
2277                println!("  k6 run {}", script_path.display());
2278                return Ok(());
2279            }
2280
2281            // --use-k6: execute via k6
2282            if !K6Executor::is_k6_installed() {
2283                TerminalReporter::print_error("k6 is not installed");
2284                TerminalReporter::print_warning(
2285                    "Install k6 from: https://k6.io/docs/get-started/installation/",
2286                );
2287                return Err(BenchError::K6NotFound);
2288            }
2289
2290            TerminalReporter::print_progress("Running conformance tests via k6...");
2291            let executor = K6Executor::new()?;
2292            executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2293
2294            let report_path = self.output.join("conformance-report.json");
2295            if report_path.exists() {
2296                let report = ConformanceReport::from_file(&report_path)?;
2297                report.print_report_with_options(self.conformance_all_operations);
2298                self.save_conformance_report(&report, &report_path)?;
2299            } else {
2300                TerminalReporter::print_warning(
2301                    "Conformance report not generated (k6 handleSummary may not have run)",
2302                );
2303            }
2304
2305            return Ok(());
2306        }
2307
2308        // Default: Native Rust executor (no k6 dependency)
2309        TerminalReporter::print_progress("Running conformance tests (native executor)...");
2310
2311        let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2312
2313        executor = if let Some(annotated) = &annotated_ops {
2314            executor.with_spec_driven_checks(annotated)
2315        } else {
2316            executor.with_reference_checks()
2317        };
2318        executor = executor.with_custom_checks()?;
2319
2320        TerminalReporter::print_success(&format!(
2321            "Executing {} conformance checks...",
2322            executor.check_count()
2323        ));
2324
2325        let report = executor.execute().await?;
2326        report.print_report_with_options(self.conformance_all_operations);
2327
2328        // Save failure details to a separate file for easy debugging
2329        let failure_details = report.failure_details();
2330        if !failure_details.is_empty() {
2331            let details_path = self.output.join("conformance-failure-details.json");
2332            if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2333                let _ = std::fs::write(&details_path, json);
2334                TerminalReporter::print_success(&format!(
2335                    "Failure details saved to: {}",
2336                    details_path.display()
2337                ));
2338            }
2339        }
2340
2341        // Save report
2342        let report_path = self.output.join("conformance-report.json");
2343        let report_json = serde_json::to_string_pretty(&report.to_json())
2344            .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2345        std::fs::write(&report_path, &report_json)
2346            .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2347        TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
2348
2349        self.save_conformance_report(&report, &report_path)?;
2350
2351        Ok(())
2352    }
2353
2354    /// Save conformance report in the requested format (SARIF or JSON copy)
2355    fn save_conformance_report(
2356        &self,
2357        report: &crate::conformance::report::ConformanceReport,
2358        report_path: &Path,
2359    ) -> Result<()> {
2360        if self.conformance_report_format == "sarif" {
2361            use crate::conformance::sarif::ConformanceSarifReport;
2362            ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
2363            TerminalReporter::print_success(&format!(
2364                "SARIF report saved to: {}",
2365                self.conformance_report.display()
2366            ));
2367        } else if self.conformance_report != *report_path {
2368            std::fs::copy(report_path, &self.conformance_report)?;
2369            TerminalReporter::print_success(&format!(
2370                "Report saved to: {}",
2371                self.conformance_report.display()
2372            ));
2373        }
2374        Ok(())
2375    }
2376
2377    /// Execute conformance tests against multiple targets from a targets file.
2378    ///
2379    /// Uses the native `NativeConformanceExecutor` (no k6 dependency). Targets are
2380    /// tested sequentially to avoid overwhelming them, and per-target headers from
2381    /// the targets file are merged with the base `--conformance-header` headers.
2382    async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
2383        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2384        use crate::conformance::report::ConformanceReport;
2385        use crate::conformance::spec::ConformanceFeature;
2386
2387        TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
2388
2389        // Parse targets file
2390        TerminalReporter::print_progress("Parsing targets file...");
2391        let targets = parse_targets_file(targets_file)?;
2392        let num_targets = targets.len();
2393        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
2394
2395        if targets.is_empty() {
2396            return Err(BenchError::Other("No targets found in file".to_string()));
2397        }
2398
2399        TerminalReporter::print_progress(
2400            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
2401        );
2402
2403        // Parse category filter (shared across all targets)
2404        let categories = self.conformance_categories.as_ref().map(|cats_str| {
2405            cats_str
2406                .split(',')
2407                .filter_map(|s| {
2408                    let trimmed = s.trim();
2409                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2410                        Some(canonical.to_string())
2411                    } else {
2412                        TerminalReporter::print_warning(&format!(
2413                            "Unknown conformance category: '{}'. Valid categories: {}",
2414                            trimmed,
2415                            ConformanceFeature::cli_category_names()
2416                                .iter()
2417                                .map(|(cli, _)| *cli)
2418                                .collect::<Vec<_>>()
2419                                .join(", ")
2420                        ));
2421                        None
2422                    }
2423                })
2424                .collect::<Vec<String>>()
2425        });
2426
2427        // Parse base custom headers from --conformance-header flags
2428        let base_custom_headers: Vec<(String, String)> = self
2429            .conformance_headers
2430            .iter()
2431            .filter_map(|h| {
2432                let (name, value) = h.split_once(':')?;
2433                Some((name.trim().to_string(), value.trim().to_string()))
2434            })
2435            .collect();
2436
2437        if !base_custom_headers.is_empty() {
2438            TerminalReporter::print_progress(&format!(
2439                "Using {} base custom header(s) for authentication",
2440                base_custom_headers.len()
2441            ));
2442        }
2443
2444        // Load spec once if provided (shared across all targets)
2445        let annotated_ops = if !self.spec.is_empty() {
2446            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2447            let parser = SpecParser::from_file(&self.spec[0]).await?;
2448            let operations = parser.get_operations();
2449            let annotated =
2450                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2451                    &operations,
2452                    parser.spec(),
2453                );
2454            TerminalReporter::print_success(&format!(
2455                "Analyzed {} operations, found {} feature annotations",
2456                operations.len(),
2457                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2458            ));
2459            Some(annotated)
2460        } else {
2461            None
2462        };
2463
2464        // Ensure output dir exists
2465        std::fs::create_dir_all(&self.output)?;
2466
2467        // Collect per-target results for the summary
2468        struct TargetResult {
2469            url: String,
2470            passed: usize,
2471            failed: usize,
2472            elapsed: std::time::Duration,
2473            report_json: serde_json::Value,
2474            owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
2475        }
2476
2477        let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
2478        let total_start = std::time::Instant::now();
2479
2480        for (idx, target) in targets.iter().enumerate() {
2481            tracing::info!(
2482                "Running conformance tests against target {}/{}: {}",
2483                idx + 1,
2484                num_targets,
2485                target.url
2486            );
2487            TerminalReporter::print_progress(&format!(
2488                "\n--- Target {}/{}: {} ---",
2489                idx + 1,
2490                num_targets,
2491                target.url
2492            ));
2493
2494            // Merge base headers with per-target headers
2495            let mut merged_headers = base_custom_headers.clone();
2496            if let Some(ref target_headers) = target.headers {
2497                for (name, value) in target_headers {
2498                    // Per-target headers override base headers with the same name
2499                    if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
2500                        existing.1 = value.clone();
2501                    } else {
2502                        merged_headers.push((name.clone(), value.clone()));
2503                    }
2504                }
2505            }
2506            // Add auth header if present on target
2507            if let Some(ref auth) = target.auth {
2508                if let Some(existing) =
2509                    merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
2510                {
2511                    existing.1 = auth.clone();
2512                } else {
2513                    merged_headers.push(("Authorization".to_string(), auth.clone()));
2514                }
2515            }
2516
2517            // Per-target output dir (used by both native and k6 paths).
2518            // Created before the config so we can point the k6 script's
2519            // handleSummary at the per-target directory rather than the shared
2520            // parent output dir (otherwise every target would overwrite the
2521            // same conformance-report.json).
2522            let target_dir = self.output.join(format!("target_{}", idx));
2523            std::fs::create_dir_all(&target_dir)?;
2524
2525            let config = ConformanceConfig {
2526                target_url: target.url.clone(),
2527                api_key: self.conformance_api_key.clone(),
2528                basic_auth: self.conformance_basic_auth.clone(),
2529                skip_tls_verify: self.skip_tls_verify,
2530                categories: categories.clone(),
2531                base_path: self.base_path.clone(),
2532                custom_headers: merged_headers,
2533                output_dir: Some(target_dir.clone()),
2534                all_operations: self.conformance_all_operations,
2535                custom_checks_file: self.conformance_custom.clone(),
2536                request_delay_ms: self.conformance_delay_ms,
2537                custom_filter: self.conformance_custom_filter.clone(),
2538                export_requests: self.export_requests,
2539                validate_requests: self.validate_requests,
2540            };
2541
2542            let target_start = std::time::Instant::now();
2543            let report = if self.use_k6 {
2544                if !K6Executor::is_k6_installed() {
2545                    TerminalReporter::print_error("k6 is not installed");
2546                    TerminalReporter::print_warning(
2547                        "Install k6 from: https://k6.io/docs/get-started/installation/",
2548                    );
2549                    return Err(BenchError::K6NotFound);
2550                }
2551
2552                let script = if let Some(ref annotated) = annotated_ops {
2553                    let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2554                        config.clone(),
2555                        annotated.clone(),
2556                    );
2557                    let (script, _check_count) = gen.generate()?;
2558                    script
2559                } else {
2560                    let generator = ConformanceGenerator::new(config.clone());
2561                    generator.generate()?
2562                };
2563
2564                let script_path = target_dir.join("k6-conformance.js");
2565                std::fs::write(&script_path, &script).map_err(|e| {
2566                    BenchError::Other(format!("Failed to write conformance script: {}", e))
2567                })?;
2568                TerminalReporter::print_success(&format!(
2569                    "Conformance script generated: {}",
2570                    script_path.display()
2571                ));
2572
2573                TerminalReporter::print_progress(&format!(
2574                    "Running conformance tests via k6 against {}...",
2575                    target.url
2576                ));
2577                let k6 = K6Executor::new()?;
2578                // Unique k6 API port per target to avoid collisions.
2579                let api_port = 6565u16.saturating_add(idx as u16);
2580                k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
2581                    .await?;
2582
2583                let report_path = target_dir.join("conformance-report.json");
2584                if report_path.exists() {
2585                    ConformanceReport::from_file(&report_path)?
2586                } else {
2587                    TerminalReporter::print_warning(&format!(
2588                        "Conformance report not generated for target {} (k6 handleSummary may not have run)",
2589                        target.url
2590                    ));
2591                    continue;
2592                }
2593            } else {
2594                let mut executor =
2595                    crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2596
2597                executor = if let Some(ref annotated) = annotated_ops {
2598                    executor.with_spec_driven_checks(annotated)
2599                } else {
2600                    executor.with_reference_checks()
2601                };
2602                executor = executor.with_custom_checks()?;
2603
2604                TerminalReporter::print_success(&format!(
2605                    "Executing {} conformance checks against {}...",
2606                    executor.check_count(),
2607                    target.url
2608                ));
2609
2610                executor.execute().await?
2611            };
2612            let target_elapsed = target_start.elapsed();
2613
2614            let report_json = report.to_json();
2615
2616            // Extract pass/fail from the summary in the JSON
2617            let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
2618            let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
2619            let total_checks = passed + failed;
2620            let rate = if total_checks == 0 {
2621                0.0
2622            } else {
2623                (passed as f64 / total_checks as f64) * 100.0
2624            };
2625
2626            TerminalReporter::print_success(&format!(
2627                "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
2628                target.url,
2629                passed,
2630                total_checks,
2631                rate,
2632                target_elapsed.as_secs_f64()
2633            ));
2634
2635            // Save per-target report (target_dir created above)
2636            let target_report_path = target_dir.join("conformance-report.json");
2637            let report_str = serde_json::to_string_pretty(&report_json)
2638                .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2639            std::fs::write(&target_report_path, &report_str)
2640                .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2641
2642            // Save failure details if any
2643            let failure_details = report.failure_details();
2644            if !failure_details.is_empty() {
2645                let details_path = target_dir.join("conformance-failure-details.json");
2646                if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2647                    let _ = std::fs::write(&details_path, json);
2648                }
2649            }
2650
2651            // Compute OWASP coverage for this target
2652            let owasp_coverage = report.owasp_coverage_data();
2653
2654            target_results.push(TargetResult {
2655                url: target.url.clone(),
2656                passed,
2657                failed,
2658                elapsed: target_elapsed,
2659                report_json,
2660                owasp_coverage,
2661            });
2662        }
2663
2664        let total_elapsed = total_start.elapsed();
2665
2666        // Print summary table
2667        println!("\n{}", "=".repeat(80));
2668        println!("  Multi-Target Conformance Summary");
2669        println!("{}", "=".repeat(80));
2670        println!(
2671            "  {:<40} {:>8} {:>8} {:>8} {:>8}",
2672            "Target URL", "Passed", "Failed", "Rate", "Time"
2673        );
2674        println!("  {}", "-".repeat(76));
2675
2676        let mut total_passed = 0usize;
2677        let mut total_failed = 0usize;
2678
2679        for result in &target_results {
2680            let total_checks = result.passed + result.failed;
2681            let rate = if total_checks == 0 {
2682                0.0
2683            } else {
2684                (result.passed as f64 / total_checks as f64) * 100.0
2685            };
2686
2687            // Truncate long URLs for display
2688            let display_url = if result.url.len() > 38 {
2689                format!("{}...", &result.url[..35])
2690            } else {
2691                result.url.clone()
2692            };
2693
2694            println!(
2695                "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2696                display_url,
2697                result.passed,
2698                result.failed,
2699                rate,
2700                result.elapsed.as_secs_f64()
2701            );
2702
2703            total_passed += result.passed;
2704            total_failed += result.failed;
2705        }
2706
2707        let grand_total = total_passed + total_failed;
2708        let overall_rate = if grand_total == 0 {
2709            0.0
2710        } else {
2711            (total_passed as f64 / grand_total as f64) * 100.0
2712        };
2713
2714        println!("  {}", "-".repeat(76));
2715        println!(
2716            "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2717            format!("TOTAL ({} targets)", num_targets),
2718            total_passed,
2719            total_failed,
2720            overall_rate,
2721            total_elapsed.as_secs_f64()
2722        );
2723        println!("{}", "=".repeat(80));
2724
2725        // Print per-target OWASP coverage
2726        for result in &target_results {
2727            println!("\n  OWASP API Security Top 10 Coverage for {}:", result.url);
2728            for entry in &result.owasp_coverage {
2729                let status = if !entry.tested {
2730                    "-"
2731                } else if entry.all_passed {
2732                    "pass"
2733                } else {
2734                    "FAIL"
2735                };
2736                let via = if entry.via_categories.is_empty() {
2737                    String::new()
2738                } else {
2739                    format!(" (via {})", entry.via_categories.join(", "))
2740                };
2741                println!("    {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
2742            }
2743        }
2744
2745        // Save combined summary
2746        let per_target_summaries: Vec<serde_json::Value> = target_results
2747            .iter()
2748            .enumerate()
2749            .map(|(idx, r)| {
2750                let total_checks = r.passed + r.failed;
2751                let rate = if total_checks == 0 {
2752                    0.0
2753                } else {
2754                    (r.passed as f64 / total_checks as f64) * 100.0
2755                };
2756                let owasp_json: Vec<serde_json::Value> = r
2757                    .owasp_coverage
2758                    .iter()
2759                    .map(|e| {
2760                        serde_json::json!({
2761                            "id": e.id,
2762                            "name": e.name,
2763                            "tested": e.tested,
2764                            "all_passed": e.all_passed,
2765                            "via_categories": e.via_categories,
2766                        })
2767                    })
2768                    .collect();
2769                serde_json::json!({
2770                    "target_url": r.url,
2771                    "target_index": idx,
2772                    "checks_passed": r.passed,
2773                    "checks_failed": r.failed,
2774                    "total_checks": total_checks,
2775                    "pass_rate": rate,
2776                    "elapsed_seconds": r.elapsed.as_secs_f64(),
2777                    "report": r.report_json,
2778                    "owasp_coverage": owasp_json,
2779                })
2780            })
2781            .collect();
2782
2783        let combined_summary = serde_json::json!({
2784            "total_targets": num_targets,
2785            "total_checks_passed": total_passed,
2786            "total_checks_failed": total_failed,
2787            "overall_pass_rate": overall_rate,
2788            "total_elapsed_seconds": total_elapsed.as_secs_f64(),
2789            "targets": per_target_summaries,
2790        });
2791
2792        let summary_path = self.output.join("multi-target-conformance-summary.json");
2793        let summary_str = serde_json::to_string_pretty(&combined_summary)
2794            .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
2795        std::fs::write(&summary_path, &summary_str)
2796            .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
2797        TerminalReporter::print_success(&format!(
2798            "Combined summary saved to: {}",
2799            summary_path.display()
2800        ));
2801
2802        Ok(())
2803    }
2804
2805    /// Execute OWASP API Security Top 10 testing mode
2806    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
2807        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
2808
2809        // Parse custom headers from CLI
2810        let custom_headers = self.parse_headers()?;
2811
2812        // Build OWASP configuration from CLI options
2813        let mut config = OwaspApiConfig::new()
2814            .with_auth_header(&self.owasp_auth_header)
2815            .with_verbose(self.verbose)
2816            .with_insecure(self.skip_tls_verify)
2817            .with_concurrency(self.vus as usize)
2818            .with_iterations(self.owasp_iterations as usize)
2819            .with_base_path(self.base_path.clone())
2820            .with_custom_headers(custom_headers);
2821
2822        // Set valid auth token if provided
2823        if let Some(ref token) = self.owasp_auth_token {
2824            config = config.with_valid_auth_token(token);
2825        }
2826
2827        // Parse categories if provided
2828        if let Some(ref cats_str) = self.owasp_categories {
2829            let categories: Vec<OwaspCategory> = cats_str
2830                .split(',')
2831                .filter_map(|s| {
2832                    let trimmed = s.trim();
2833                    match trimmed.parse::<OwaspCategory>() {
2834                        Ok(cat) => Some(cat),
2835                        Err(e) => {
2836                            TerminalReporter::print_warning(&e);
2837                            None
2838                        }
2839                    }
2840                })
2841                .collect();
2842
2843            if !categories.is_empty() {
2844                config = config.with_categories(categories);
2845            }
2846        }
2847
2848        // Load admin paths from file if provided
2849        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
2850            config.admin_paths_file = Some(admin_paths_file.clone());
2851            if let Err(e) = config.load_admin_paths() {
2852                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
2853            }
2854        }
2855
2856        // Set ID fields if provided
2857        if let Some(ref id_fields_str) = self.owasp_id_fields {
2858            let id_fields: Vec<String> = id_fields_str
2859                .split(',')
2860                .map(|s| s.trim().to_string())
2861                .filter(|s| !s.is_empty())
2862                .collect();
2863            if !id_fields.is_empty() {
2864                config = config.with_id_fields(id_fields);
2865            }
2866        }
2867
2868        // Set report path and format
2869        if let Some(ref report_path) = self.owasp_report {
2870            config = config.with_report_path(report_path);
2871        }
2872        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
2873            config = config.with_report_format(format);
2874        }
2875
2876        // Print configuration summary
2877        let categories = config.categories_to_test();
2878        TerminalReporter::print_success(&format!(
2879            "Testing {} OWASP categories: {}",
2880            categories.len(),
2881            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
2882        ));
2883
2884        if config.valid_auth_token.is_some() {
2885            TerminalReporter::print_progress("Using provided auth token for baseline requests");
2886        }
2887
2888        // Create the OWASP generator
2889        TerminalReporter::print_progress("Generating OWASP security test script...");
2890        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
2891
2892        // Generate the script
2893        let script = generator.generate()?;
2894        TerminalReporter::print_success("OWASP security test script generated");
2895
2896        // Write script to file
2897        let script_path = if let Some(output) = &self.script_output {
2898            output.clone()
2899        } else {
2900            self.output.join("k6-owasp-security-test.js")
2901        };
2902
2903        if let Some(parent) = script_path.parent() {
2904            std::fs::create_dir_all(parent)?;
2905        }
2906        std::fs::write(&script_path, &script)?;
2907        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2908
2909        // If generate-only mode, exit here
2910        if self.generate_only {
2911            println!("\nOWASP security test script generated. Run it with:");
2912            println!("  k6 run {}", script_path.display());
2913            return Ok(());
2914        }
2915
2916        // Execute k6
2917        TerminalReporter::print_progress("Executing OWASP security tests...");
2918        let executor = K6Executor::new()?;
2919        std::fs::create_dir_all(&self.output)?;
2920
2921        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2922
2923        let duration_secs = Self::parse_duration(&self.duration)?;
2924        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
2925
2926        println!("\nOWASP security test results saved to: {}", self.output.display());
2927
2928        Ok(())
2929    }
2930}
2931
2932#[cfg(test)]
2933mod tests {
2934    use super::*;
2935    use tempfile::tempdir;
2936
2937    #[test]
2938    fn test_parse_duration() {
2939        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
2940        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
2941        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
2942        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
2943    }
2944
2945    #[test]
2946    fn test_parse_duration_invalid() {
2947        assert!(BenchCommand::parse_duration("invalid").is_err());
2948        assert!(BenchCommand::parse_duration("30x").is_err());
2949    }
2950
2951    #[test]
2952    fn test_parse_headers() {
2953        let cmd = BenchCommand {
2954            spec: vec![PathBuf::from("test.yaml")],
2955            spec_dir: None,
2956            merge_conflicts: "error".to_string(),
2957            spec_mode: "merge".to_string(),
2958            dependency_config: None,
2959            target: "http://localhost".to_string(),
2960            base_path: None,
2961            duration: "1m".to_string(),
2962            vus: 10,
2963            scenario: "ramp-up".to_string(),
2964            operations: None,
2965            exclude_operations: None,
2966            auth: None,
2967            headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
2968            output: PathBuf::from("output"),
2969            generate_only: false,
2970            script_output: None,
2971            threshold_percentile: "p(95)".to_string(),
2972            threshold_ms: 500,
2973            max_error_rate: 0.05,
2974            verbose: false,
2975            skip_tls_verify: false,
2976            chunked_request_bodies: false,
2977            target_rps: None,
2978            no_keep_alive: false,
2979            targets_file: None,
2980            max_concurrency: None,
2981            results_format: "both".to_string(),
2982            params_file: None,
2983            crud_flow: false,
2984            flow_config: None,
2985            extract_fields: None,
2986            parallel_create: None,
2987            data_file: None,
2988            data_distribution: "unique-per-vu".to_string(),
2989            data_mappings: None,
2990            per_uri_control: false,
2991            error_rate: None,
2992            error_types: None,
2993            security_test: false,
2994            security_payloads: None,
2995            security_categories: None,
2996            security_target_fields: None,
2997            wafbench_dir: None,
2998            wafbench_cycle_all: false,
2999            owasp_api_top10: false,
3000            owasp_categories: None,
3001            owasp_auth_header: "Authorization".to_string(),
3002            owasp_auth_token: None,
3003            owasp_admin_paths: None,
3004            owasp_id_fields: None,
3005            owasp_report: None,
3006            owasp_report_format: "json".to_string(),
3007            owasp_iterations: 1,
3008            conformance: false,
3009            conformance_api_key: None,
3010            conformance_basic_auth: None,
3011            conformance_report: PathBuf::from("conformance-report.json"),
3012            conformance_categories: None,
3013            conformance_report_format: "json".to_string(),
3014            conformance_headers: vec![],
3015            conformance_all_operations: false,
3016            conformance_custom: None,
3017            conformance_delay_ms: 0,
3018            use_k6: false,
3019            conformance_custom_filter: None,
3020            export_requests: false,
3021            validate_requests: false,
3022            conformance_self_test: false,
3023        };
3024
3025        let headers = cmd.parse_headers().unwrap();
3026        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
3027        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
3028    }
3029
3030    #[test]
3031    fn test_get_spec_display_name() {
3032        let cmd = BenchCommand {
3033            spec: vec![PathBuf::from("test.yaml")],
3034            spec_dir: None,
3035            merge_conflicts: "error".to_string(),
3036            spec_mode: "merge".to_string(),
3037            dependency_config: None,
3038            target: "http://localhost".to_string(),
3039            base_path: None,
3040            duration: "1m".to_string(),
3041            vus: 10,
3042            scenario: "ramp-up".to_string(),
3043            operations: None,
3044            exclude_operations: None,
3045            auth: None,
3046            headers: None,
3047            output: PathBuf::from("output"),
3048            generate_only: false,
3049            script_output: None,
3050            threshold_percentile: "p(95)".to_string(),
3051            threshold_ms: 500,
3052            max_error_rate: 0.05,
3053            verbose: false,
3054            skip_tls_verify: false,
3055            chunked_request_bodies: false,
3056            target_rps: None,
3057            no_keep_alive: false,
3058            targets_file: None,
3059            max_concurrency: None,
3060            results_format: "both".to_string(),
3061            params_file: None,
3062            crud_flow: false,
3063            flow_config: None,
3064            extract_fields: None,
3065            parallel_create: None,
3066            data_file: None,
3067            data_distribution: "unique-per-vu".to_string(),
3068            data_mappings: None,
3069            per_uri_control: false,
3070            error_rate: None,
3071            error_types: None,
3072            security_test: false,
3073            security_payloads: None,
3074            security_categories: None,
3075            security_target_fields: None,
3076            wafbench_dir: None,
3077            wafbench_cycle_all: false,
3078            owasp_api_top10: false,
3079            owasp_categories: None,
3080            owasp_auth_header: "Authorization".to_string(),
3081            owasp_auth_token: None,
3082            owasp_admin_paths: None,
3083            owasp_id_fields: None,
3084            owasp_report: None,
3085            owasp_report_format: "json".to_string(),
3086            owasp_iterations: 1,
3087            conformance: false,
3088            conformance_api_key: None,
3089            conformance_basic_auth: None,
3090            conformance_report: PathBuf::from("conformance-report.json"),
3091            conformance_categories: None,
3092            conformance_report_format: "json".to_string(),
3093            conformance_headers: vec![],
3094            conformance_all_operations: false,
3095            conformance_custom: None,
3096            conformance_delay_ms: 0,
3097            use_k6: false,
3098            conformance_custom_filter: None,
3099            export_requests: false,
3100            validate_requests: false,
3101            conformance_self_test: false,
3102        };
3103
3104        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
3105
3106        // Test multiple specs
3107        let cmd_multi = BenchCommand {
3108            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
3109            spec_dir: None,
3110            merge_conflicts: "error".to_string(),
3111            spec_mode: "merge".to_string(),
3112            dependency_config: None,
3113            target: "http://localhost".to_string(),
3114            base_path: None,
3115            duration: "1m".to_string(),
3116            vus: 10,
3117            scenario: "ramp-up".to_string(),
3118            operations: None,
3119            exclude_operations: None,
3120            auth: None,
3121            headers: None,
3122            output: PathBuf::from("output"),
3123            generate_only: false,
3124            script_output: None,
3125            threshold_percentile: "p(95)".to_string(),
3126            threshold_ms: 500,
3127            max_error_rate: 0.05,
3128            verbose: false,
3129            skip_tls_verify: false,
3130            chunked_request_bodies: false,
3131            target_rps: None,
3132            no_keep_alive: false,
3133            targets_file: None,
3134            max_concurrency: None,
3135            results_format: "both".to_string(),
3136            params_file: None,
3137            crud_flow: false,
3138            flow_config: None,
3139            extract_fields: None,
3140            parallel_create: None,
3141            data_file: None,
3142            data_distribution: "unique-per-vu".to_string(),
3143            data_mappings: None,
3144            per_uri_control: false,
3145            error_rate: None,
3146            error_types: None,
3147            security_test: false,
3148            security_payloads: None,
3149            security_categories: None,
3150            security_target_fields: None,
3151            wafbench_dir: None,
3152            wafbench_cycle_all: false,
3153            owasp_api_top10: false,
3154            owasp_categories: None,
3155            owasp_auth_header: "Authorization".to_string(),
3156            owasp_auth_token: None,
3157            owasp_admin_paths: None,
3158            owasp_id_fields: None,
3159            owasp_report: None,
3160            owasp_report_format: "json".to_string(),
3161            owasp_iterations: 1,
3162            conformance: false,
3163            conformance_api_key: None,
3164            conformance_basic_auth: None,
3165            conformance_report: PathBuf::from("conformance-report.json"),
3166            conformance_categories: None,
3167            conformance_report_format: "json".to_string(),
3168            conformance_headers: vec![],
3169            conformance_all_operations: false,
3170            conformance_custom: None,
3171            conformance_delay_ms: 0,
3172            use_k6: false,
3173            conformance_custom_filter: None,
3174            export_requests: false,
3175            validate_requests: false,
3176            conformance_self_test: false,
3177        };
3178
3179        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
3180    }
3181
3182    #[test]
3183    fn test_parse_extracted_values_from_output_dir() {
3184        let dir = tempdir().unwrap();
3185        let path = dir.path().join("extracted_values.json");
3186        std::fs::write(
3187            &path,
3188            r#"{
3189  "pool_id": "abc123",
3190  "count": 0,
3191  "enabled": false,
3192  "metadata": { "owner": "team-a" }
3193}"#,
3194        )
3195        .unwrap();
3196
3197        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
3198        assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
3199        assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
3200        assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
3201        assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
3202    }
3203
3204    #[test]
3205    fn test_parse_extracted_values_missing_file() {
3206        let dir = tempdir().unwrap();
3207        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
3208        assert!(extracted.values.is_empty());
3209    }
3210}