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