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