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, InvalidDataType};
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_core::openapi::multi_spec::{
30    load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_core::openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::PathBuf;
35use std::str::FromStr;
36
37/// Bench command configuration
38pub struct BenchCommand {
39    /// OpenAPI spec file(s) - can specify multiple
40    pub spec: Vec<PathBuf>,
41    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
42    pub spec_dir: Option<PathBuf>,
43    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
44    pub merge_conflicts: String,
45    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
46    pub spec_mode: String,
47    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
48    pub dependency_config: Option<PathBuf>,
49    pub target: String,
50    /// API base path prefix (e.g., "/api" or "/v2/api")
51    /// If None, extracts from OpenAPI spec's servers URL
52    pub base_path: Option<String>,
53    pub duration: String,
54    pub vus: u32,
55    pub scenario: String,
56    pub operations: Option<String>,
57    /// Exclude operations from testing (comma-separated)
58    ///
59    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
60    pub exclude_operations: Option<String>,
61    pub auth: Option<String>,
62    pub headers: Option<String>,
63    pub output: PathBuf,
64    pub generate_only: bool,
65    pub script_output: Option<PathBuf>,
66    pub threshold_percentile: String,
67    pub threshold_ms: u64,
68    pub max_error_rate: f64,
69    pub verbose: bool,
70    pub skip_tls_verify: bool,
71    /// Optional file containing multiple targets
72    pub targets_file: Option<PathBuf>,
73    /// Maximum number of parallel executions (for multi-target mode)
74    pub max_concurrency: Option<u32>,
75    /// Results format: "per-target", "aggregated", or "both"
76    pub results_format: String,
77    /// Optional file containing parameter value overrides (JSON or YAML)
78    ///
79    /// Allows users to provide custom values for path parameters, query parameters,
80    /// headers, and request bodies instead of auto-generated placeholder values.
81    pub params_file: Option<PathBuf>,
82
83    // === CRUD Flow Options ===
84    /// Enable CRUD flow mode
85    pub crud_flow: bool,
86    /// Custom CRUD flow configuration file
87    pub flow_config: Option<PathBuf>,
88    /// Fields to extract from responses
89    pub extract_fields: Option<String>,
90
91    // === Parallel Execution Options ===
92    /// Number of resources to create in parallel
93    pub parallel_create: Option<u32>,
94
95    // === Data-Driven Testing Options ===
96    /// Test data file (CSV or JSON)
97    pub data_file: Option<PathBuf>,
98    /// Data distribution strategy
99    pub data_distribution: String,
100    /// Data column to field mappings
101    pub data_mappings: Option<String>,
102    /// Enable per-URI control mode (each row specifies method, uri, body, etc.)
103    pub per_uri_control: bool,
104
105    // === Invalid Data Testing Options ===
106    /// Percentage of requests with invalid data
107    pub error_rate: Option<f64>,
108    /// Types of invalid data to generate
109    pub error_types: Option<String>,
110
111    // === Security Testing Options ===
112    /// Enable security testing
113    pub security_test: bool,
114    /// Custom security payloads file
115    pub security_payloads: Option<PathBuf>,
116    /// Security test categories
117    pub security_categories: Option<String>,
118    /// Fields to target for security injection
119    pub security_target_fields: Option<String>,
120
121    // === WAFBench Integration ===
122    /// WAFBench test directory or glob pattern for loading CRS attack patterns
123    pub wafbench_dir: Option<String>,
124    /// Cycle through ALL WAFBench payloads instead of random sampling
125    pub wafbench_cycle_all: bool,
126
127    // === OWASP API Security Top 10 Testing ===
128    /// Enable OWASP API Security Top 10 testing mode
129    pub owasp_api_top10: bool,
130    /// OWASP API categories to test (comma-separated)
131    pub owasp_categories: Option<String>,
132    /// Authorization header name for OWASP auth tests
133    pub owasp_auth_header: String,
134    /// Valid authorization token for OWASP baseline requests
135    pub owasp_auth_token: Option<String>,
136    /// File containing admin/privileged paths to test
137    pub owasp_admin_paths: Option<PathBuf>,
138    /// Fields containing resource IDs for BOLA testing
139    pub owasp_id_fields: Option<String>,
140    /// OWASP report output file
141    pub owasp_report: Option<PathBuf>,
142    /// OWASP report format (json, sarif)
143    pub owasp_report_format: String,
144    /// Number of iterations per VU for OWASP tests (default: 1)
145    pub owasp_iterations: u32,
146}
147
148impl BenchCommand {
149    /// Load and merge specs from --spec files and --spec-dir
150    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
151        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
152
153        // Load specs from --spec flags
154        if !self.spec.is_empty() {
155            let specs = load_specs_from_files(self.spec.clone())
156                .await
157                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
158            all_specs.extend(specs);
159        }
160
161        // Load specs from --spec-dir if provided
162        if let Some(spec_dir) = &self.spec_dir {
163            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
164                BenchError::Other(format!("Failed to load specs from directory: {}", e))
165            })?;
166            all_specs.extend(dir_specs);
167        }
168
169        if all_specs.is_empty() {
170            return Err(BenchError::Other(
171                "No spec files provided. Use --spec or --spec-dir.".to_string(),
172            ));
173        }
174
175        // If only one spec, return it directly (extract just the OpenApiSpec)
176        if all_specs.len() == 1 {
177            // Safe to unwrap because we just checked len() == 1
178            return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
179        }
180
181        // Merge multiple specs
182        let conflict_strategy = match self.merge_conflicts.as_str() {
183            "first" => ConflictStrategy::First,
184            "last" => ConflictStrategy::Last,
185            _ => ConflictStrategy::Error,
186        };
187
188        merge_specs(all_specs, conflict_strategy)
189            .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
190    }
191
192    /// Get a display name for the spec(s)
193    fn get_spec_display_name(&self) -> String {
194        if self.spec.len() == 1 {
195            self.spec[0].to_string_lossy().to_string()
196        } else if !self.spec.is_empty() {
197            format!("{} spec files", self.spec.len())
198        } else if let Some(dir) = &self.spec_dir {
199            format!("specs from {}", dir.display())
200        } else {
201            "no specs".to_string()
202        }
203    }
204
205    /// Execute the bench command
206    pub async fn execute(&self) -> Result<()> {
207        // Check if we're in multi-target mode
208        if let Some(targets_file) = &self.targets_file {
209            return self.execute_multi_target(targets_file).await;
210        }
211
212        // Check if we're in sequential spec mode (for dependency handling)
213        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
214            return self.execute_sequential_specs().await;
215        }
216
217        // Single target mode (existing behavior)
218        // Print header
219        TerminalReporter::print_header(
220            &self.get_spec_display_name(),
221            &self.target,
222            0, // Will be updated later
223            &self.scenario,
224            Self::parse_duration(&self.duration)?,
225        );
226
227        // Validate k6 installation
228        if !K6Executor::is_k6_installed() {
229            TerminalReporter::print_error("k6 is not installed");
230            TerminalReporter::print_warning(
231                "Install k6 from: https://k6.io/docs/get-started/installation/",
232            );
233            return Err(BenchError::K6NotFound);
234        }
235
236        // Load and parse spec(s)
237        TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
238        let merged_spec = self.load_and_merge_specs().await?;
239        let parser = SpecParser::from_spec(merged_spec);
240        if self.spec.len() > 1 || self.spec_dir.is_some() {
241            TerminalReporter::print_success(&format!(
242                "Loaded and merged {} specification(s)",
243                self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
244            ));
245        } else {
246            TerminalReporter::print_success("Specification loaded");
247        }
248
249        // Check for mock server integration
250        let mock_config = self.build_mock_config().await;
251        if mock_config.is_mock_server {
252            TerminalReporter::print_progress("Mock server integration enabled");
253        }
254
255        // Check for CRUD flow mode
256        if self.crud_flow {
257            return self.execute_crud_flow(&parser).await;
258        }
259
260        // Check for OWASP API Top 10 testing mode
261        if self.owasp_api_top10 {
262            return self.execute_owasp_test(&parser).await;
263        }
264
265        // Get operations
266        TerminalReporter::print_progress("Extracting API operations...");
267        let mut operations = if let Some(filter) = &self.operations {
268            parser.filter_operations(filter)?
269        } else {
270            parser.get_operations()
271        };
272
273        // Apply exclusions if provided
274        if let Some(exclude) = &self.exclude_operations {
275            let before_count = operations.len();
276            operations = parser.exclude_operations(operations, exclude)?;
277            let excluded_count = before_count - operations.len();
278            if excluded_count > 0 {
279                TerminalReporter::print_progress(&format!(
280                    "Excluded {} operations matching '{}'",
281                    excluded_count, exclude
282                ));
283            }
284        }
285
286        if operations.is_empty() {
287            return Err(BenchError::Other("No operations found in spec".to_string()));
288        }
289
290        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
291
292        // Load parameter overrides if provided
293        let param_overrides = if let Some(params_file) = &self.params_file {
294            TerminalReporter::print_progress("Loading parameter overrides...");
295            let overrides = ParameterOverrides::from_file(params_file)?;
296            TerminalReporter::print_success(&format!(
297                "Loaded parameter overrides ({} operation-specific, {} defaults)",
298                overrides.operations.len(),
299                if overrides.defaults.is_empty() { 0 } else { 1 }
300            ));
301            Some(overrides)
302        } else {
303            None
304        };
305
306        // Generate request templates
307        TerminalReporter::print_progress("Generating request templates...");
308        let templates: Vec<_> = operations
309            .iter()
310            .map(|op| {
311                let op_overrides = param_overrides.as_ref().map(|po| {
312                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
313                });
314                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
315            })
316            .collect::<Result<Vec<_>>>()?;
317        TerminalReporter::print_success("Request templates generated");
318
319        // Parse headers
320        let custom_headers = self.parse_headers()?;
321
322        // Resolve base path (CLI option takes priority over spec's servers URL)
323        let base_path = self.resolve_base_path(&parser);
324        if let Some(ref bp) = base_path {
325            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
326        }
327
328        // Generate k6 script
329        TerminalReporter::print_progress("Generating k6 load test script...");
330        let scenario =
331            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
332
333        let k6_config = K6Config {
334            target_url: self.target.clone(),
335            base_path,
336            scenario,
337            duration_secs: Self::parse_duration(&self.duration)?,
338            max_vus: self.vus,
339            threshold_percentile: self.threshold_percentile.clone(),
340            threshold_ms: self.threshold_ms,
341            max_error_rate: self.max_error_rate,
342            auth_header: self.auth.clone(),
343            custom_headers,
344            skip_tls_verify: self.skip_tls_verify,
345        };
346
347        let generator = K6ScriptGenerator::new(k6_config, templates);
348        let mut script = generator.generate()?;
349        TerminalReporter::print_success("k6 script generated");
350
351        // Check if any advanced features are enabled
352        let has_advanced_features = self.data_file.is_some()
353            || self.error_rate.is_some()
354            || self.security_test
355            || self.parallel_create.is_some();
356
357        // Enhance script with advanced features
358        if has_advanced_features {
359            script = self.generate_enhanced_script(&script)?;
360        }
361
362        // Add mock server integration code
363        if mock_config.is_mock_server {
364            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
365            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
366            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
367
368            // Insert mock server code after imports
369            if let Some(import_end) = script.find("export const options") {
370                script.insert_str(
371                    import_end,
372                    &format!(
373                        "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
374                        helper_code, setup_code, teardown_code
375                    ),
376                );
377            }
378        }
379
380        // Validate the generated script
381        TerminalReporter::print_progress("Validating k6 script...");
382        let validation_errors = K6ScriptGenerator::validate_script(&script);
383        if !validation_errors.is_empty() {
384            TerminalReporter::print_error("Script validation failed");
385            for error in &validation_errors {
386                eprintln!("  {}", error);
387            }
388            return Err(BenchError::Other(format!(
389                "Generated k6 script has {} validation error(s). Please check the output above.",
390                validation_errors.len()
391            )));
392        }
393        TerminalReporter::print_success("Script validation passed");
394
395        // Write script to file
396        let script_path = if let Some(output) = &self.script_output {
397            output.clone()
398        } else {
399            self.output.join("k6-script.js")
400        };
401
402        if let Some(parent) = script_path.parent() {
403            std::fs::create_dir_all(parent)?;
404        }
405        std::fs::write(&script_path, &script)?;
406        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
407
408        // If generate-only mode, exit here
409        if self.generate_only {
410            println!("\nScript generated successfully. Run it with:");
411            println!("  k6 run {}", script_path.display());
412            return Ok(());
413        }
414
415        // Execute k6
416        TerminalReporter::print_progress("Executing load test...");
417        let executor = K6Executor::new()?;
418
419        std::fs::create_dir_all(&self.output)?;
420
421        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
422
423        // Print results
424        let duration_secs = Self::parse_duration(&self.duration)?;
425        TerminalReporter::print_summary(&results, duration_secs);
426
427        println!("\nResults saved to: {}", self.output.display());
428
429        Ok(())
430    }
431
432    /// Execute multi-target bench testing
433    async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
434        TerminalReporter::print_progress("Parsing targets file...");
435        let targets = parse_targets_file(targets_file)?;
436        let num_targets = targets.len();
437        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
438
439        if targets.is_empty() {
440            return Err(BenchError::Other("No targets found in file".to_string()));
441        }
442
443        // Determine max concurrency
444        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
445        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
446
447        // Print header for multi-target mode
448        TerminalReporter::print_header(
449            &self.get_spec_display_name(),
450            &format!("{} targets", num_targets),
451            0,
452            &self.scenario,
453            Self::parse_duration(&self.duration)?,
454        );
455
456        // Create parallel executor
457        let executor = ParallelExecutor::new(
458            BenchCommand {
459                // Clone all fields except targets_file (we don't need it in the executor)
460                spec: self.spec.clone(),
461                spec_dir: self.spec_dir.clone(),
462                merge_conflicts: self.merge_conflicts.clone(),
463                spec_mode: self.spec_mode.clone(),
464                dependency_config: self.dependency_config.clone(),
465                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
466                base_path: self.base_path.clone(),
467                duration: self.duration.clone(),
468                vus: self.vus,
469                scenario: self.scenario.clone(),
470                operations: self.operations.clone(),
471                exclude_operations: self.exclude_operations.clone(),
472                auth: self.auth.clone(),
473                headers: self.headers.clone(),
474                output: self.output.clone(),
475                generate_only: self.generate_only,
476                script_output: self.script_output.clone(),
477                threshold_percentile: self.threshold_percentile.clone(),
478                threshold_ms: self.threshold_ms,
479                max_error_rate: self.max_error_rate,
480                verbose: self.verbose,
481                skip_tls_verify: self.skip_tls_verify,
482                targets_file: None,
483                max_concurrency: None,
484                results_format: self.results_format.clone(),
485                params_file: self.params_file.clone(),
486                crud_flow: self.crud_flow,
487                flow_config: self.flow_config.clone(),
488                extract_fields: self.extract_fields.clone(),
489                parallel_create: self.parallel_create,
490                data_file: self.data_file.clone(),
491                data_distribution: self.data_distribution.clone(),
492                data_mappings: self.data_mappings.clone(),
493                per_uri_control: self.per_uri_control,
494                error_rate: self.error_rate,
495                error_types: self.error_types.clone(),
496                security_test: self.security_test,
497                security_payloads: self.security_payloads.clone(),
498                security_categories: self.security_categories.clone(),
499                security_target_fields: self.security_target_fields.clone(),
500                wafbench_dir: self.wafbench_dir.clone(),
501                wafbench_cycle_all: self.wafbench_cycle_all,
502                owasp_api_top10: self.owasp_api_top10,
503                owasp_categories: self.owasp_categories.clone(),
504                owasp_auth_header: self.owasp_auth_header.clone(),
505                owasp_auth_token: self.owasp_auth_token.clone(),
506                owasp_admin_paths: self.owasp_admin_paths.clone(),
507                owasp_id_fields: self.owasp_id_fields.clone(),
508                owasp_report: self.owasp_report.clone(),
509                owasp_report_format: self.owasp_report_format.clone(),
510                owasp_iterations: self.owasp_iterations,
511            },
512            targets,
513            max_concurrency,
514        );
515
516        // Execute all targets
517        let aggregated_results = executor.execute_all().await?;
518
519        // Organize and report results
520        self.report_multi_target_results(&aggregated_results)?;
521
522        Ok(())
523    }
524
525    /// Report results for multi-target execution
526    fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
527        // Print summary
528        TerminalReporter::print_multi_target_summary(results);
529
530        // Save aggregated summary if requested
531        if self.results_format == "aggregated" || self.results_format == "both" {
532            let summary_path = self.output.join("aggregated_summary.json");
533            let summary_json = serde_json::json!({
534                "total_targets": results.total_targets,
535                "successful_targets": results.successful_targets,
536                "failed_targets": results.failed_targets,
537                "aggregated_metrics": {
538                    "total_requests": results.aggregated_metrics.total_requests,
539                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
540                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
541                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
542                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
543                    "error_rate": results.aggregated_metrics.error_rate,
544                },
545                "target_results": results.target_results.iter().map(|r| {
546                    serde_json::json!({
547                        "target_url": r.target_url,
548                        "target_index": r.target_index,
549                        "success": r.success,
550                        "error": r.error,
551                        "total_requests": r.results.total_requests,
552                        "failed_requests": r.results.failed_requests,
553                        "avg_duration_ms": r.results.avg_duration_ms,
554                        "p95_duration_ms": r.results.p95_duration_ms,
555                        "p99_duration_ms": r.results.p99_duration_ms,
556                        "output_dir": r.output_dir.to_string_lossy(),
557                    })
558                }).collect::<Vec<_>>(),
559            });
560
561            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
562            TerminalReporter::print_success(&format!(
563                "Aggregated summary saved to: {}",
564                summary_path.display()
565            ));
566        }
567
568        println!("\nResults saved to: {}", self.output.display());
569        println!("  - Per-target results: {}", self.output.join("target_*").display());
570        if self.results_format == "aggregated" || self.results_format == "both" {
571            println!(
572                "  - Aggregated summary: {}",
573                self.output.join("aggregated_summary.json").display()
574            );
575        }
576
577        Ok(())
578    }
579
580    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
581    pub fn parse_duration(duration: &str) -> Result<u64> {
582        let duration = duration.trim();
583
584        if let Some(secs) = duration.strip_suffix('s') {
585            secs.parse::<u64>()
586                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
587        } else if let Some(mins) = duration.strip_suffix('m') {
588            mins.parse::<u64>()
589                .map(|m| m * 60)
590                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
591        } else if let Some(hours) = duration.strip_suffix('h') {
592            hours
593                .parse::<u64>()
594                .map(|h| h * 3600)
595                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
596        } else {
597            // Try parsing as seconds without suffix
598            duration
599                .parse::<u64>()
600                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
601        }
602    }
603
604    /// Parse headers from command line format (Key:Value,Key2:Value2)
605    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
606        let mut headers = HashMap::new();
607
608        if let Some(header_str) = &self.headers {
609            for pair in header_str.split(',') {
610                let parts: Vec<&str> = pair.splitn(2, ':').collect();
611                if parts.len() != 2 {
612                    return Err(BenchError::Other(format!(
613                        "Invalid header format: '{}'. Expected 'Key:Value'",
614                        pair
615                    )));
616                }
617                headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
618            }
619        }
620
621        Ok(headers)
622    }
623
624    /// Resolve the effective base path for API endpoints
625    ///
626    /// Priority:
627    /// 1. CLI --base-path option (if provided, even if empty string)
628    /// 2. Base path extracted from OpenAPI spec's servers URL
629    /// 3. None (no base path)
630    ///
631    /// An empty string from CLI explicitly disables base path.
632    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
633        // CLI option takes priority (including empty string to disable)
634        if let Some(cli_base_path) = &self.base_path {
635            if cli_base_path.is_empty() {
636                // Empty string explicitly means "no base path"
637                return None;
638            }
639            return Some(cli_base_path.clone());
640        }
641
642        // Fall back to spec's base path
643        parser.get_base_path()
644    }
645
646    /// Build mock server integration configuration
647    async fn build_mock_config(&self) -> MockIntegrationConfig {
648        // Check if target looks like a mock server
649        if MockServerDetector::looks_like_mock_server(&self.target) {
650            // Try to detect if it's actually a MockForge server
651            if let Ok(info) = MockServerDetector::detect(&self.target).await {
652                if info.is_mockforge {
653                    TerminalReporter::print_success(&format!(
654                        "Detected MockForge server (version: {})",
655                        info.version.as_deref().unwrap_or("unknown")
656                    ));
657                    return MockIntegrationConfig::mock_server();
658                }
659            }
660        }
661        MockIntegrationConfig::real_api()
662    }
663
664    /// Build CRUD flow configuration
665    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
666        if !self.crud_flow {
667            return None;
668        }
669
670        // If flow_config file is provided, load it
671        if let Some(config_path) = &self.flow_config {
672            match CrudFlowConfig::from_file(config_path) {
673                Ok(config) => return Some(config),
674                Err(e) => {
675                    TerminalReporter::print_warning(&format!(
676                        "Failed to load flow config: {}. Using auto-detection.",
677                        e
678                    ));
679                }
680            }
681        }
682
683        // Parse extract fields
684        let extract_fields = self
685            .extract_fields
686            .as_ref()
687            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
688            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
689
690        Some(CrudFlowConfig {
691            flows: Vec::new(), // Will be auto-detected
692            default_extract_fields: extract_fields,
693        })
694    }
695
696    /// Build data-driven testing configuration
697    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
698        let data_file = self.data_file.as_ref()?;
699
700        let distribution = DataDistribution::from_str(&self.data_distribution)
701            .unwrap_or(DataDistribution::UniquePerVu);
702
703        let mappings = self
704            .data_mappings
705            .as_ref()
706            .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
707            .unwrap_or_default();
708
709        Some(DataDrivenConfig {
710            file_path: data_file.to_string_lossy().to_string(),
711            distribution,
712            mappings,
713            csv_has_header: true,
714            per_uri_control: self.per_uri_control,
715            per_uri_columns: crate::data_driven::PerUriColumns::default(),
716        })
717    }
718
719    /// Build invalid data testing configuration
720    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
721        let error_rate = self.error_rate?;
722
723        let error_types = self
724            .error_types
725            .as_ref()
726            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
727            .unwrap_or_default();
728
729        Some(InvalidDataConfig {
730            error_rate,
731            error_types,
732            target_fields: Vec::new(),
733        })
734    }
735
736    /// Build security testing configuration
737    fn build_security_config(&self) -> Option<SecurityTestConfig> {
738        if !self.security_test {
739            return None;
740        }
741
742        let categories = self
743            .security_categories
744            .as_ref()
745            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
746            .unwrap_or_else(|| {
747                let mut default = std::collections::HashSet::new();
748                default.insert(SecurityCategory::SqlInjection);
749                default.insert(SecurityCategory::Xss);
750                default
751            });
752
753        let target_fields = self
754            .security_target_fields
755            .as_ref()
756            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
757            .unwrap_or_default();
758
759        let custom_payloads_file =
760            self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
761
762        Some(SecurityTestConfig {
763            enabled: true,
764            categories,
765            target_fields,
766            custom_payloads_file,
767            include_high_risk: false,
768        })
769    }
770
771    /// Build parallel execution configuration
772    fn build_parallel_config(&self) -> Option<ParallelConfig> {
773        let count = self.parallel_create?;
774
775        Some(ParallelConfig::new(count))
776    }
777
778    /// Load WAFBench payloads from the specified directory or pattern
779    fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
780        let Some(ref wafbench_dir) = self.wafbench_dir else {
781            return Vec::new();
782        };
783
784        let mut loader = WafBenchLoader::new();
785
786        if let Err(e) = loader.load_from_pattern(wafbench_dir) {
787            TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
788            return Vec::new();
789        }
790
791        let stats = loader.stats();
792
793        if stats.files_processed == 0 {
794            TerminalReporter::print_warning(&format!(
795                "No WAFBench YAML files found matching '{}'",
796                wafbench_dir
797            ));
798            // Also report any parse errors that may explain why no files were processed
799            if !stats.parse_errors.is_empty() {
800                TerminalReporter::print_warning("Some files were found but failed to parse:");
801                for error in &stats.parse_errors {
802                    TerminalReporter::print_warning(&format!("  - {}", error));
803                }
804            }
805            return Vec::new();
806        }
807
808        TerminalReporter::print_progress(&format!(
809            "Loaded {} WAFBench files, {} test cases, {} payloads",
810            stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
811        ));
812
813        // Print category breakdown
814        for (category, count) in &stats.by_category {
815            TerminalReporter::print_progress(&format!("  - {}: {} tests", category, count));
816        }
817
818        // Report any parse errors
819        for error in &stats.parse_errors {
820            TerminalReporter::print_warning(&format!("  Parse error: {}", error));
821        }
822
823        loader.to_security_payloads()
824    }
825
826    /// Generate enhanced k6 script with advanced features
827    fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
828        let mut enhanced_script = base_script.to_string();
829        let mut additional_code = String::new();
830
831        // Add data-driven testing code
832        if let Some(config) = self.build_data_driven_config() {
833            TerminalReporter::print_progress("Adding data-driven testing support...");
834            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
835            additional_code.push('\n');
836            TerminalReporter::print_success("Data-driven testing enabled");
837        }
838
839        // Add invalid data generation code
840        if let Some(config) = self.build_invalid_data_config() {
841            TerminalReporter::print_progress("Adding invalid data testing support...");
842            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
843            additional_code.push('\n');
844            additional_code
845                .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
846            additional_code.push('\n');
847            additional_code
848                .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
849            additional_code.push('\n');
850            TerminalReporter::print_success(&format!(
851                "Invalid data testing enabled ({}% error rate)",
852                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
853            ));
854        }
855
856        // Add security testing code
857        let security_config = self.build_security_config();
858        let wafbench_payloads = self.load_wafbench_payloads();
859
860        if security_config.is_some() || !wafbench_payloads.is_empty() {
861            TerminalReporter::print_progress("Adding security testing support...");
862
863            // Combine built-in payloads with WAFBench payloads
864            let mut payload_list: Vec<SecurityPayload> = Vec::new();
865
866            if let Some(ref config) = security_config {
867                payload_list.extend(SecurityPayloads::get_payloads(config));
868            }
869
870            // Add WAFBench payloads
871            if !wafbench_payloads.is_empty() {
872                TerminalReporter::print_progress(&format!(
873                    "Loading {} WAFBench attack patterns...",
874                    wafbench_payloads.len()
875                ));
876                payload_list.extend(wafbench_payloads);
877            }
878
879            let target_fields =
880                security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
881
882            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
883                &payload_list,
884                self.wafbench_cycle_all,
885            ));
886            additional_code.push('\n');
887            additional_code
888                .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
889            additional_code.push('\n');
890            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
891            additional_code.push('\n');
892
893            let mode = if self.wafbench_cycle_all {
894                "cycle-all"
895            } else {
896                "random"
897            };
898            TerminalReporter::print_success(&format!(
899                "Security testing enabled ({} payloads, {} mode)",
900                payload_list.len(),
901                mode
902            ));
903        }
904
905        // Add parallel execution code
906        if let Some(config) = self.build_parallel_config() {
907            TerminalReporter::print_progress("Adding parallel execution support...");
908            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
909            additional_code.push('\n');
910            TerminalReporter::print_success(&format!(
911                "Parallel execution enabled (count: {})",
912                config.count
913            ));
914        }
915
916        // Insert additional code after the imports section
917        if !additional_code.is_empty() {
918            // Find the end of the import section
919            if let Some(import_end) = enhanced_script.find("export const options") {
920                enhanced_script.insert_str(
921                    import_end,
922                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
923                );
924            }
925        }
926
927        Ok(enhanced_script)
928    }
929
930    /// Execute specs sequentially with dependency ordering and value passing
931    async fn execute_sequential_specs(&self) -> Result<()> {
932        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
933
934        // Load all specs (without merging)
935        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
936
937        if !self.spec.is_empty() {
938            let specs = load_specs_from_files(self.spec.clone())
939                .await
940                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
941            all_specs.extend(specs);
942        }
943
944        if let Some(spec_dir) = &self.spec_dir {
945            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
946                BenchError::Other(format!("Failed to load specs from directory: {}", e))
947            })?;
948            all_specs.extend(dir_specs);
949        }
950
951        if all_specs.is_empty() {
952            return Err(BenchError::Other(
953                "No spec files found for sequential execution".to_string(),
954            ));
955        }
956
957        TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
958
959        // Load dependency config or auto-detect
960        let execution_order = if let Some(config_path) = &self.dependency_config {
961            TerminalReporter::print_progress("Loading dependency configuration...");
962            let config = SpecDependencyConfig::from_file(config_path)?;
963
964            if !config.disable_auto_detect && config.execution_order.is_empty() {
965                // Auto-detect if config doesn't specify order
966                self.detect_and_sort_specs(&all_specs)?
967            } else {
968                // Use configured order
969                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
970            }
971        } else {
972            // Auto-detect dependencies
973            self.detect_and_sort_specs(&all_specs)?
974        };
975
976        TerminalReporter::print_success(&format!(
977            "Execution order: {}",
978            execution_order
979                .iter()
980                .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
981                .collect::<Vec<_>>()
982                .join(" → ")
983        ));
984
985        // Execute each spec in order
986        let mut extracted_values = ExtractedValues::new();
987        let total_specs = execution_order.len();
988
989        for (index, spec_path) in execution_order.iter().enumerate() {
990            let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
991
992            TerminalReporter::print_progress(&format!(
993                "[{}/{}] Executing spec: {}",
994                index + 1,
995                total_specs,
996                spec_name
997            ));
998
999            // Find the spec in our loaded specs (match by full path or filename)
1000            let spec = all_specs
1001                .iter()
1002                .find(|(p, _)| {
1003                    p == spec_path
1004                        || p.file_name() == spec_path.file_name()
1005                        || p.file_name() == Some(spec_path.as_os_str())
1006                })
1007                .map(|(_, s)| s.clone())
1008                .ok_or_else(|| {
1009                    BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1010                })?;
1011
1012            // Execute this spec with any extracted values from previous specs
1013            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1014
1015            // Merge extracted values for the next spec
1016            extracted_values.merge(&new_values);
1017
1018            TerminalReporter::print_success(&format!(
1019                "[{}/{}] Completed: {} (extracted {} values)",
1020                index + 1,
1021                total_specs,
1022                spec_name,
1023                new_values.values.len()
1024            ));
1025        }
1026
1027        TerminalReporter::print_success(&format!(
1028            "Sequential execution complete: {} specs executed",
1029            total_specs
1030        ));
1031
1032        Ok(())
1033    }
1034
1035    /// Detect dependencies and return topologically sorted spec paths
1036    fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1037        TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1038
1039        let mut detector = DependencyDetector::new();
1040        let dependencies = detector.detect_dependencies(specs);
1041
1042        if dependencies.is_empty() {
1043            TerminalReporter::print_progress("No dependencies detected, using file order");
1044            return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1045        }
1046
1047        TerminalReporter::print_progress(&format!(
1048            "Detected {} cross-spec dependencies",
1049            dependencies.len()
1050        ));
1051
1052        for dep in &dependencies {
1053            TerminalReporter::print_progress(&format!(
1054                "  {} → {} (via field '{}')",
1055                dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1056                dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1057                dep.field_name
1058            ));
1059        }
1060
1061        topological_sort(specs, &dependencies)
1062    }
1063
1064    /// Execute a single spec and extract values for dependent specs
1065    async fn execute_single_spec(
1066        &self,
1067        spec: &OpenApiSpec,
1068        spec_name: &str,
1069        _external_values: &ExtractedValues,
1070    ) -> Result<ExtractedValues> {
1071        let parser = SpecParser::from_spec(spec.clone());
1072
1073        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
1074        if self.crud_flow {
1075            // Execute CRUD flow and extract values
1076            self.execute_crud_flow_with_extraction(&parser, spec_name).await
1077        } else {
1078            // Execute standard benchmark (no value extraction in non-CRUD mode)
1079            self.execute_standard_spec(&parser, spec_name).await?;
1080            Ok(ExtractedValues::new())
1081        }
1082    }
1083
1084    /// Execute CRUD flow with value extraction for sequential mode
1085    async fn execute_crud_flow_with_extraction(
1086        &self,
1087        parser: &SpecParser,
1088        spec_name: &str,
1089    ) -> Result<ExtractedValues> {
1090        let operations = parser.get_operations();
1091        let flows = CrudFlowDetector::detect_flows(&operations);
1092
1093        if flows.is_empty() {
1094            TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1095            return Ok(ExtractedValues::new());
1096        }
1097
1098        TerminalReporter::print_progress(&format!(
1099            "  {} CRUD flow(s) in {}",
1100            flows.len(),
1101            spec_name
1102        ));
1103
1104        // Generate and execute the CRUD flow script
1105        let mut handlebars = handlebars::Handlebars::new();
1106        // Register json helper for serializing arrays/objects in templates
1107        handlebars.register_helper(
1108            "json",
1109            Box::new(
1110                |h: &handlebars::Helper,
1111                 _: &handlebars::Handlebars,
1112                 _: &handlebars::Context,
1113                 _: &mut handlebars::RenderContext,
1114                 out: &mut dyn handlebars::Output|
1115                 -> handlebars::HelperResult {
1116                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1117                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1118                    Ok(())
1119                },
1120            ),
1121        );
1122        let template = include_str!("templates/k6_crud_flow.hbs");
1123
1124        let custom_headers = self.parse_headers()?;
1125        let config = self.build_crud_flow_config().unwrap_or_default();
1126
1127        // Load parameter overrides if provided (for body configurations)
1128        let param_overrides = if let Some(params_file) = &self.params_file {
1129            let overrides = ParameterOverrides::from_file(params_file)?;
1130            Some(overrides)
1131        } else {
1132            None
1133        };
1134
1135        // Generate stages from scenario
1136        let duration_secs = Self::parse_duration(&self.duration)?;
1137        let scenario =
1138            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1139        let stages = scenario.generate_stages(duration_secs, self.vus);
1140
1141        // Resolve base path (CLI option takes priority over spec's servers URL)
1142        let api_base_path = self.resolve_base_path(parser);
1143
1144        // Build headers JSON string for the template
1145        let mut all_headers = custom_headers.clone();
1146        if let Some(auth) = &self.auth {
1147            all_headers.insert("Authorization".to_string(), auth.clone());
1148        }
1149        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1150
1151        // Track all dynamic placeholders across all operations
1152        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1153
1154        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1155            let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1156            serde_json::json!({
1157                "name": sanitized_name.clone(),
1158                "display_name": f.name,
1159                "base_path": f.base_path,
1160                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1161                    // Parse operation to get method and path
1162                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1163                    let method_raw = if !parts.is_empty() {
1164                        parts[0].to_uppercase()
1165                    } else {
1166                        "GET".to_string()
1167                    };
1168                    let method = if !parts.is_empty() {
1169                        let m = parts[0].to_lowercase();
1170                        // k6 uses 'del' for DELETE
1171                        if m == "delete" { "del".to_string() } else { m }
1172                    } else {
1173                        "get".to_string()
1174                    };
1175                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1176                    // Prepend API base path if configured
1177                    let path = if let Some(ref bp) = api_base_path {
1178                        format!("{}{}", bp, raw_path)
1179                    } else {
1180                        raw_path.to_string()
1181                    };
1182                    let is_get_or_head = method == "get" || method == "head";
1183                    // POST, PUT, PATCH typically have bodies
1184                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1185
1186                    // Look up body from params file if available
1187                    let body_value = if has_body {
1188                        param_overrides.as_ref()
1189                            .map(|po| po.get_for_operation(None, &method_raw, &raw_path))
1190                            .and_then(|oo| oo.body)
1191                            .unwrap_or_else(|| serde_json::json!({}))
1192                    } else {
1193                        serde_json::json!({})
1194                    };
1195
1196                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1197                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1198
1199                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1200                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1201                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1202
1203                    serde_json::json!({
1204                        "operation": s.operation,
1205                        "method": method,
1206                        "path": path,
1207                        "extract": s.extract,
1208                        "use_values": s.use_values,
1209                        "use_body": s.use_body,
1210                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1211                        "inject_attacks": s.inject_attacks,
1212                        "attack_types": s.attack_types,
1213                        "description": s.description,
1214                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1215                        "is_get_or_head": is_get_or_head,
1216                        "has_body": has_body,
1217                        "body": processed_body.value,
1218                        "body_is_dynamic": body_is_dynamic,
1219                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1220                    })
1221                }).collect::<Vec<_>>(),
1222            })
1223        }).collect();
1224
1225        // Collect all placeholders from all steps
1226        for flow_data in &flows_data {
1227            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1228                for step in steps {
1229                    if let Some(placeholders_arr) =
1230                        step.get("_placeholders").and_then(|p| p.as_array())
1231                    {
1232                        for p_str in placeholders_arr {
1233                            if let Some(p_name) = p_str.as_str() {
1234                                match p_name {
1235                                    "VU" => {
1236                                        all_placeholders.insert(DynamicPlaceholder::VU);
1237                                    }
1238                                    "Iteration" => {
1239                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1240                                    }
1241                                    "Timestamp" => {
1242                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1243                                    }
1244                                    "UUID" => {
1245                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1246                                    }
1247                                    "Random" => {
1248                                        all_placeholders.insert(DynamicPlaceholder::Random);
1249                                    }
1250                                    "Counter" => {
1251                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1252                                    }
1253                                    "Date" => {
1254                                        all_placeholders.insert(DynamicPlaceholder::Date);
1255                                    }
1256                                    "VuIter" => {
1257                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1258                                    }
1259                                    _ => {}
1260                                }
1261                            }
1262                        }
1263                    }
1264                }
1265            }
1266        }
1267
1268        // Get required imports and globals based on placeholders used
1269        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1270        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1271
1272        let data = serde_json::json!({
1273            "base_url": self.target,
1274            "flows": flows_data,
1275            "extract_fields": config.default_extract_fields,
1276            "duration_secs": duration_secs,
1277            "max_vus": self.vus,
1278            "auth_header": self.auth,
1279            "custom_headers": custom_headers,
1280            "skip_tls_verify": self.skip_tls_verify,
1281            // Add missing template fields
1282            "stages": stages.iter().map(|s| serde_json::json!({
1283                "duration": s.duration,
1284                "target": s.target,
1285            })).collect::<Vec<_>>(),
1286            "threshold_percentile": self.threshold_percentile,
1287            "threshold_ms": self.threshold_ms,
1288            "max_error_rate": self.max_error_rate,
1289            "headers": headers_json,
1290            "dynamic_imports": required_imports,
1291            "dynamic_globals": required_globals,
1292        });
1293
1294        let script = handlebars
1295            .render_template(template, &data)
1296            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1297
1298        // Write and execute script
1299        let script_path =
1300            self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1301
1302        std::fs::create_dir_all(self.output.clone())?;
1303        std::fs::write(&script_path, &script)?;
1304
1305        if !self.generate_only {
1306            let executor = K6Executor::new()?;
1307            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1308            std::fs::create_dir_all(&output_dir)?;
1309
1310            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1311        }
1312
1313        // For now, return empty extracted values
1314        // TODO: Parse k6 output to extract actual values
1315        Ok(ExtractedValues::new())
1316    }
1317
1318    /// Execute standard (non-CRUD) spec benchmark
1319    async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1320        let mut operations = if let Some(filter) = &self.operations {
1321            parser.filter_operations(filter)?
1322        } else {
1323            parser.get_operations()
1324        };
1325
1326        if let Some(exclude) = &self.exclude_operations {
1327            operations = parser.exclude_operations(operations, exclude)?;
1328        }
1329
1330        if operations.is_empty() {
1331            TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1332            return Ok(());
1333        }
1334
1335        TerminalReporter::print_progress(&format!(
1336            "  {} operations in {}",
1337            operations.len(),
1338            spec_name
1339        ));
1340
1341        // Generate request templates
1342        let templates: Vec<_> = operations
1343            .iter()
1344            .map(RequestGenerator::generate_template)
1345            .collect::<Result<Vec<_>>>()?;
1346
1347        // Parse headers
1348        let custom_headers = self.parse_headers()?;
1349
1350        // Resolve base path
1351        let base_path = self.resolve_base_path(parser);
1352
1353        // Generate k6 script
1354        let scenario =
1355            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1356
1357        let k6_config = K6Config {
1358            target_url: self.target.clone(),
1359            base_path,
1360            scenario,
1361            duration_secs: Self::parse_duration(&self.duration)?,
1362            max_vus: self.vus,
1363            threshold_percentile: self.threshold_percentile.clone(),
1364            threshold_ms: self.threshold_ms,
1365            max_error_rate: self.max_error_rate,
1366            auth_header: self.auth.clone(),
1367            custom_headers,
1368            skip_tls_verify: self.skip_tls_verify,
1369        };
1370
1371        let generator = K6ScriptGenerator::new(k6_config, templates);
1372        let script = generator.generate()?;
1373
1374        // Write and execute script
1375        let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1376
1377        std::fs::create_dir_all(self.output.clone())?;
1378        std::fs::write(&script_path, &script)?;
1379
1380        if !self.generate_only {
1381            let executor = K6Executor::new()?;
1382            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1383            std::fs::create_dir_all(&output_dir)?;
1384
1385            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1386        }
1387
1388        Ok(())
1389    }
1390
1391    /// Execute CRUD flow testing mode
1392    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1393        // Check if a custom flow config is provided
1394        let config = self.build_crud_flow_config().unwrap_or_default();
1395
1396        // Use flows from config if provided, otherwise auto-detect
1397        let flows = if !config.flows.is_empty() {
1398            TerminalReporter::print_progress("Using custom flow configuration...");
1399            config.flows.clone()
1400        } else {
1401            TerminalReporter::print_progress("Detecting CRUD operations...");
1402            let operations = parser.get_operations();
1403            CrudFlowDetector::detect_flows(&operations)
1404        };
1405
1406        if flows.is_empty() {
1407            return Err(BenchError::Other(
1408                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1409            ));
1410        }
1411
1412        if config.flows.is_empty() {
1413            TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1414        } else {
1415            TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1416        }
1417
1418        for flow in &flows {
1419            TerminalReporter::print_progress(&format!(
1420                "  - {}: {} steps",
1421                flow.name,
1422                flow.steps.len()
1423            ));
1424        }
1425
1426        // Generate CRUD flow script
1427        let mut handlebars = handlebars::Handlebars::new();
1428        // Register json helper for serializing arrays/objects in templates
1429        handlebars.register_helper(
1430            "json",
1431            Box::new(
1432                |h: &handlebars::Helper,
1433                 _: &handlebars::Handlebars,
1434                 _: &handlebars::Context,
1435                 _: &mut handlebars::RenderContext,
1436                 out: &mut dyn handlebars::Output|
1437                 -> handlebars::HelperResult {
1438                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1439                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1440                    Ok(())
1441                },
1442            ),
1443        );
1444        let template = include_str!("templates/k6_crud_flow.hbs");
1445
1446        let custom_headers = self.parse_headers()?;
1447
1448        // Load parameter overrides if provided (for body configurations)
1449        let param_overrides = if let Some(params_file) = &self.params_file {
1450            TerminalReporter::print_progress("Loading parameter overrides...");
1451            let overrides = ParameterOverrides::from_file(params_file)?;
1452            TerminalReporter::print_success(&format!(
1453                "Loaded parameter overrides ({} operation-specific, {} defaults)",
1454                overrides.operations.len(),
1455                if overrides.defaults.is_empty() { 0 } else { 1 }
1456            ));
1457            Some(overrides)
1458        } else {
1459            None
1460        };
1461
1462        // Generate stages from scenario
1463        let duration_secs = Self::parse_duration(&self.duration)?;
1464        let scenario =
1465            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1466        let stages = scenario.generate_stages(duration_secs, self.vus);
1467
1468        // Resolve base path (CLI option takes priority over spec's servers URL)
1469        let api_base_path = self.resolve_base_path(parser);
1470        if let Some(ref bp) = api_base_path {
1471            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1472        }
1473
1474        // Build headers JSON string for the template
1475        let mut all_headers = custom_headers.clone();
1476        if let Some(auth) = &self.auth {
1477            all_headers.insert("Authorization".to_string(), auth.clone());
1478        }
1479        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1480
1481        // Track all dynamic placeholders across all operations
1482        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1483
1484        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1485            // Sanitize flow name for use as JavaScript variable and k6 metric names
1486            let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1487            serde_json::json!({
1488                "name": sanitized_name.clone(),  // Use sanitized name for variable names
1489                "display_name": f.name,          // Keep original for comments/display
1490                "base_path": f.base_path,
1491                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1492                    // Parse operation to get method and path
1493                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1494                    let method_raw = if !parts.is_empty() {
1495                        parts[0].to_uppercase()
1496                    } else {
1497                        "GET".to_string()
1498                    };
1499                    let method = if !parts.is_empty() {
1500                        let m = parts[0].to_lowercase();
1501                        // k6 uses 'del' for DELETE
1502                        if m == "delete" { "del".to_string() } else { m }
1503                    } else {
1504                        "get".to_string()
1505                    };
1506                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1507                    // Prepend API base path if configured
1508                    let path = if let Some(ref bp) = api_base_path {
1509                        format!("{}{}", bp, raw_path)
1510                    } else {
1511                        raw_path.to_string()
1512                    };
1513                    let is_get_or_head = method == "get" || method == "head";
1514                    // POST, PUT, PATCH typically have bodies
1515                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1516
1517                    // Look up body from params file if available (use raw_path for matching)
1518                    let body_value = if has_body {
1519                        param_overrides.as_ref()
1520                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1521                            .and_then(|oo| oo.body)
1522                            .unwrap_or_else(|| serde_json::json!({}))
1523                    } else {
1524                        serde_json::json!({})
1525                    };
1526
1527                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1528                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1529                    // Note: all_placeholders is captured by the closure but we can't mutate it directly
1530                    // We'll collect placeholders separately below
1531
1532                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1533                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1534                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1535
1536                    serde_json::json!({
1537                        "operation": s.operation,
1538                        "method": method,
1539                        "path": path,
1540                        "extract": s.extract,
1541                        "use_values": s.use_values,
1542                        "use_body": s.use_body,
1543                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1544                        "inject_attacks": s.inject_attacks,
1545                        "attack_types": s.attack_types,
1546                        "description": s.description,
1547                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1548                        "is_get_or_head": is_get_or_head,
1549                        "has_body": has_body,
1550                        "body": processed_body.value,
1551                        "body_is_dynamic": body_is_dynamic,
1552                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1553                    })
1554                }).collect::<Vec<_>>(),
1555            })
1556        }).collect();
1557
1558        // Collect all placeholders from all steps
1559        for flow_data in &flows_data {
1560            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1561                for step in steps {
1562                    if let Some(placeholders_arr) =
1563                        step.get("_placeholders").and_then(|p| p.as_array())
1564                    {
1565                        for p_str in placeholders_arr {
1566                            if let Some(p_name) = p_str.as_str() {
1567                                // Parse placeholder from debug string
1568                                match p_name {
1569                                    "VU" => {
1570                                        all_placeholders.insert(DynamicPlaceholder::VU);
1571                                    }
1572                                    "Iteration" => {
1573                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1574                                    }
1575                                    "Timestamp" => {
1576                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1577                                    }
1578                                    "UUID" => {
1579                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1580                                    }
1581                                    "Random" => {
1582                                        all_placeholders.insert(DynamicPlaceholder::Random);
1583                                    }
1584                                    "Counter" => {
1585                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1586                                    }
1587                                    "Date" => {
1588                                        all_placeholders.insert(DynamicPlaceholder::Date);
1589                                    }
1590                                    "VuIter" => {
1591                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1592                                    }
1593                                    _ => {}
1594                                }
1595                            }
1596                        }
1597                    }
1598                }
1599            }
1600        }
1601
1602        // Get required imports and globals based on placeholders used
1603        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1604        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1605
1606        // Build invalid data config if error injection is enabled
1607        let invalid_data_config = self.build_invalid_data_config();
1608        let error_injection_enabled = invalid_data_config.is_some();
1609        let error_rate = self.error_rate.unwrap_or(0.0);
1610        let error_types: Vec<String> = invalid_data_config
1611            .as_ref()
1612            .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1613            .unwrap_or_default();
1614
1615        if error_injection_enabled {
1616            TerminalReporter::print_progress(&format!(
1617                "Error injection enabled ({}% rate)",
1618                (error_rate * 100.0) as u32
1619            ));
1620        }
1621
1622        let data = serde_json::json!({
1623            "base_url": self.target,
1624            "flows": flows_data,
1625            "extract_fields": config.default_extract_fields,
1626            "duration_secs": duration_secs,
1627            "max_vus": self.vus,
1628            "auth_header": self.auth,
1629            "custom_headers": custom_headers,
1630            "skip_tls_verify": self.skip_tls_verify,
1631            // Add missing template fields
1632            "stages": stages.iter().map(|s| serde_json::json!({
1633                "duration": s.duration,
1634                "target": s.target,
1635            })).collect::<Vec<_>>(),
1636            "threshold_percentile": self.threshold_percentile,
1637            "threshold_ms": self.threshold_ms,
1638            "max_error_rate": self.max_error_rate,
1639            "headers": headers_json,
1640            "dynamic_imports": required_imports,
1641            "dynamic_globals": required_globals,
1642            // Error injection settings
1643            "error_injection_enabled": error_injection_enabled,
1644            "error_rate": error_rate,
1645            "error_types": error_types,
1646        });
1647
1648        let script = handlebars
1649            .render_template(template, &data)
1650            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1651
1652        // Validate the generated CRUD flow script
1653        TerminalReporter::print_progress("Validating CRUD flow script...");
1654        let validation_errors = K6ScriptGenerator::validate_script(&script);
1655        if !validation_errors.is_empty() {
1656            TerminalReporter::print_error("CRUD flow script validation failed");
1657            for error in &validation_errors {
1658                eprintln!("  {}", error);
1659            }
1660            return Err(BenchError::Other(format!(
1661                "CRUD flow script validation failed with {} error(s)",
1662                validation_errors.len()
1663            )));
1664        }
1665
1666        TerminalReporter::print_success("CRUD flow script generated");
1667
1668        // Write and execute script
1669        let script_path = if let Some(output) = &self.script_output {
1670            output.clone()
1671        } else {
1672            self.output.join("k6-crud-flow-script.js")
1673        };
1674
1675        if let Some(parent) = script_path.parent() {
1676            std::fs::create_dir_all(parent)?;
1677        }
1678        std::fs::write(&script_path, &script)?;
1679        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1680
1681        if self.generate_only {
1682            println!("\nScript generated successfully. Run it with:");
1683            println!("  k6 run {}", script_path.display());
1684            return Ok(());
1685        }
1686
1687        // Execute k6
1688        TerminalReporter::print_progress("Executing CRUD flow test...");
1689        let executor = K6Executor::new()?;
1690        std::fs::create_dir_all(&self.output)?;
1691
1692        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1693
1694        let duration_secs = Self::parse_duration(&self.duration)?;
1695        TerminalReporter::print_summary(&results, duration_secs);
1696
1697        Ok(())
1698    }
1699
1700    /// Execute OWASP API Security Top 10 testing mode
1701    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
1702        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
1703
1704        // Build OWASP configuration from CLI options
1705        let mut config = OwaspApiConfig::new()
1706            .with_auth_header(&self.owasp_auth_header)
1707            .with_verbose(self.verbose)
1708            .with_insecure(self.skip_tls_verify)
1709            .with_concurrency(self.vus as usize)
1710            .with_iterations(self.owasp_iterations as usize);
1711
1712        // Set valid auth token if provided
1713        if let Some(ref token) = self.owasp_auth_token {
1714            config = config.with_valid_auth_token(token);
1715        }
1716
1717        // Parse categories if provided
1718        if let Some(ref cats_str) = self.owasp_categories {
1719            let categories: Vec<OwaspCategory> = cats_str
1720                .split(',')
1721                .filter_map(|s| {
1722                    let trimmed = s.trim();
1723                    match trimmed.parse::<OwaspCategory>() {
1724                        Ok(cat) => Some(cat),
1725                        Err(e) => {
1726                            TerminalReporter::print_warning(&e);
1727                            None
1728                        }
1729                    }
1730                })
1731                .collect();
1732
1733            if !categories.is_empty() {
1734                config = config.with_categories(categories);
1735            }
1736        }
1737
1738        // Load admin paths from file if provided
1739        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
1740            config.admin_paths_file = Some(admin_paths_file.clone());
1741            if let Err(e) = config.load_admin_paths() {
1742                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
1743            }
1744        }
1745
1746        // Set ID fields if provided
1747        if let Some(ref id_fields_str) = self.owasp_id_fields {
1748            let id_fields: Vec<String> = id_fields_str
1749                .split(',')
1750                .map(|s| s.trim().to_string())
1751                .filter(|s| !s.is_empty())
1752                .collect();
1753            if !id_fields.is_empty() {
1754                config = config.with_id_fields(id_fields);
1755            }
1756        }
1757
1758        // Set report path and format
1759        if let Some(ref report_path) = self.owasp_report {
1760            config = config.with_report_path(report_path);
1761        }
1762        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
1763            config = config.with_report_format(format);
1764        }
1765
1766        // Print configuration summary
1767        let categories = config.categories_to_test();
1768        TerminalReporter::print_success(&format!(
1769            "Testing {} OWASP categories: {}",
1770            categories.len(),
1771            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
1772        ));
1773
1774        if config.valid_auth_token.is_some() {
1775            TerminalReporter::print_progress("Using provided auth token for baseline requests");
1776        }
1777
1778        // Create the OWASP generator
1779        TerminalReporter::print_progress("Generating OWASP security test script...");
1780        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
1781
1782        // Generate the script
1783        let script = generator.generate()?;
1784        TerminalReporter::print_success("OWASP security test script generated");
1785
1786        // Write script to file
1787        let script_path = if let Some(output) = &self.script_output {
1788            output.clone()
1789        } else {
1790            self.output.join("k6-owasp-security-test.js")
1791        };
1792
1793        if let Some(parent) = script_path.parent() {
1794            std::fs::create_dir_all(parent)?;
1795        }
1796        std::fs::write(&script_path, &script)?;
1797        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1798
1799        // If generate-only mode, exit here
1800        if self.generate_only {
1801            println!("\nOWASP security test script generated. Run it with:");
1802            println!("  k6 run {}", script_path.display());
1803            return Ok(());
1804        }
1805
1806        // Execute k6
1807        TerminalReporter::print_progress("Executing OWASP security tests...");
1808        let executor = K6Executor::new()?;
1809        std::fs::create_dir_all(&self.output)?;
1810
1811        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1812
1813        let duration_secs = Self::parse_duration(&self.duration)?;
1814        TerminalReporter::print_summary(&results, duration_secs);
1815
1816        println!("\nOWASP security test results saved to: {}", self.output.display());
1817
1818        Ok(())
1819    }
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824    use super::*;
1825
1826    #[test]
1827    fn test_parse_duration() {
1828        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
1829        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
1830        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
1831        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
1832    }
1833
1834    #[test]
1835    fn test_parse_duration_invalid() {
1836        assert!(BenchCommand::parse_duration("invalid").is_err());
1837        assert!(BenchCommand::parse_duration("30x").is_err());
1838    }
1839
1840    #[test]
1841    fn test_parse_headers() {
1842        let cmd = BenchCommand {
1843            spec: vec![PathBuf::from("test.yaml")],
1844            spec_dir: None,
1845            merge_conflicts: "error".to_string(),
1846            spec_mode: "merge".to_string(),
1847            dependency_config: None,
1848            target: "http://localhost".to_string(),
1849            base_path: None,
1850            duration: "1m".to_string(),
1851            vus: 10,
1852            scenario: "ramp-up".to_string(),
1853            operations: None,
1854            exclude_operations: None,
1855            auth: None,
1856            headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
1857            output: PathBuf::from("output"),
1858            generate_only: false,
1859            script_output: None,
1860            threshold_percentile: "p(95)".to_string(),
1861            threshold_ms: 500,
1862            max_error_rate: 0.05,
1863            verbose: false,
1864            skip_tls_verify: false,
1865            targets_file: None,
1866            max_concurrency: None,
1867            results_format: "both".to_string(),
1868            params_file: None,
1869            crud_flow: false,
1870            flow_config: None,
1871            extract_fields: None,
1872            parallel_create: None,
1873            data_file: None,
1874            data_distribution: "unique-per-vu".to_string(),
1875            data_mappings: None,
1876            per_uri_control: false,
1877            error_rate: None,
1878            error_types: None,
1879            security_test: false,
1880            security_payloads: None,
1881            security_categories: None,
1882            security_target_fields: None,
1883            wafbench_dir: None,
1884            wafbench_cycle_all: false,
1885            owasp_api_top10: false,
1886            owasp_categories: None,
1887            owasp_auth_header: "Authorization".to_string(),
1888            owasp_auth_token: None,
1889            owasp_admin_paths: None,
1890            owasp_id_fields: None,
1891            owasp_report: None,
1892            owasp_report_format: "json".to_string(),
1893            owasp_iterations: 1,
1894        };
1895
1896        let headers = cmd.parse_headers().unwrap();
1897        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
1898        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
1899    }
1900
1901    #[test]
1902    fn test_get_spec_display_name() {
1903        let cmd = BenchCommand {
1904            spec: vec![PathBuf::from("test.yaml")],
1905            spec_dir: None,
1906            merge_conflicts: "error".to_string(),
1907            spec_mode: "merge".to_string(),
1908            dependency_config: None,
1909            target: "http://localhost".to_string(),
1910            base_path: None,
1911            duration: "1m".to_string(),
1912            vus: 10,
1913            scenario: "ramp-up".to_string(),
1914            operations: None,
1915            exclude_operations: None,
1916            auth: None,
1917            headers: None,
1918            output: PathBuf::from("output"),
1919            generate_only: false,
1920            script_output: None,
1921            threshold_percentile: "p(95)".to_string(),
1922            threshold_ms: 500,
1923            max_error_rate: 0.05,
1924            verbose: false,
1925            skip_tls_verify: false,
1926            targets_file: None,
1927            max_concurrency: None,
1928            results_format: "both".to_string(),
1929            params_file: None,
1930            crud_flow: false,
1931            flow_config: None,
1932            extract_fields: None,
1933            parallel_create: None,
1934            data_file: None,
1935            data_distribution: "unique-per-vu".to_string(),
1936            data_mappings: None,
1937            per_uri_control: false,
1938            error_rate: None,
1939            error_types: None,
1940            security_test: false,
1941            security_payloads: None,
1942            security_categories: None,
1943            security_target_fields: None,
1944            wafbench_dir: None,
1945            wafbench_cycle_all: false,
1946            owasp_api_top10: false,
1947            owasp_categories: None,
1948            owasp_auth_header: "Authorization".to_string(),
1949            owasp_auth_token: None,
1950            owasp_admin_paths: None,
1951            owasp_id_fields: None,
1952            owasp_report: None,
1953            owasp_report_format: "json".to_string(),
1954            owasp_iterations: 1,
1955        };
1956
1957        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
1958
1959        // Test multiple specs
1960        let cmd_multi = BenchCommand {
1961            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
1962            spec_dir: None,
1963            merge_conflicts: "error".to_string(),
1964            spec_mode: "merge".to_string(),
1965            dependency_config: None,
1966            target: "http://localhost".to_string(),
1967            base_path: None,
1968            duration: "1m".to_string(),
1969            vus: 10,
1970            scenario: "ramp-up".to_string(),
1971            operations: None,
1972            exclude_operations: None,
1973            auth: None,
1974            headers: None,
1975            output: PathBuf::from("output"),
1976            generate_only: false,
1977            script_output: None,
1978            threshold_percentile: "p(95)".to_string(),
1979            threshold_ms: 500,
1980            max_error_rate: 0.05,
1981            verbose: false,
1982            skip_tls_verify: false,
1983            targets_file: None,
1984            max_concurrency: None,
1985            results_format: "both".to_string(),
1986            params_file: None,
1987            crud_flow: false,
1988            flow_config: None,
1989            extract_fields: None,
1990            parallel_create: None,
1991            data_file: None,
1992            data_distribution: "unique-per-vu".to_string(),
1993            data_mappings: None,
1994            per_uri_control: false,
1995            error_rate: None,
1996            error_types: None,
1997            security_test: false,
1998            security_payloads: None,
1999            security_categories: None,
2000            security_target_fields: None,
2001            wafbench_dir: None,
2002            wafbench_cycle_all: false,
2003            owasp_api_top10: false,
2004            owasp_categories: None,
2005            owasp_auth_header: "Authorization".to_string(),
2006            owasp_auth_token: None,
2007            owasp_admin_paths: None,
2008            owasp_id_fields: None,
2009            owasp_report: None,
2010            owasp_report_format: "json".to_string(),
2011            owasp_iterations: 1,
2012        };
2013
2014        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2015    }
2016}