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::{MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector};
10use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
11use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
12use crate::param_overrides::ParameterOverrides;
13use crate::reporter::TerminalReporter;
14use crate::request_gen::RequestGenerator;
15use crate::scenarios::LoadScenario;
16use crate::security_payloads::{SecurityCategory, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator};
17use crate::spec_parser::SpecParser;
18use crate::target_parser::parse_targets_file;
19use std::collections::HashMap;
20use std::path::PathBuf;
21use std::str::FromStr;
22
23/// Bench command configuration
24pub struct BenchCommand {
25    pub spec: PathBuf,
26    pub target: String,
27    pub duration: String,
28    pub vus: u32,
29    pub scenario: String,
30    pub operations: Option<String>,
31    /// Exclude operations from testing (comma-separated)
32    ///
33    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
34    pub exclude_operations: Option<String>,
35    pub auth: Option<String>,
36    pub headers: Option<String>,
37    pub output: PathBuf,
38    pub generate_only: bool,
39    pub script_output: Option<PathBuf>,
40    pub threshold_percentile: String,
41    pub threshold_ms: u64,
42    pub max_error_rate: f64,
43    pub verbose: bool,
44    pub skip_tls_verify: bool,
45    /// Optional file containing multiple targets
46    pub targets_file: Option<PathBuf>,
47    /// Maximum number of parallel executions (for multi-target mode)
48    pub max_concurrency: Option<u32>,
49    /// Results format: "per-target", "aggregated", or "both"
50    pub results_format: String,
51    /// Optional file containing parameter value overrides (JSON or YAML)
52    ///
53    /// Allows users to provide custom values for path parameters, query parameters,
54    /// headers, and request bodies instead of auto-generated placeholder values.
55    pub params_file: Option<PathBuf>,
56
57    // === CRUD Flow Options ===
58    /// Enable CRUD flow mode
59    pub crud_flow: bool,
60    /// Custom CRUD flow configuration file
61    pub flow_config: Option<PathBuf>,
62    /// Fields to extract from responses
63    pub extract_fields: Option<String>,
64
65    // === Parallel Execution Options ===
66    /// Number of resources to create in parallel
67    pub parallel_create: Option<u32>,
68
69    // === Data-Driven Testing Options ===
70    /// Test data file (CSV or JSON)
71    pub data_file: Option<PathBuf>,
72    /// Data distribution strategy
73    pub data_distribution: String,
74    /// Data column to field mappings
75    pub data_mappings: Option<String>,
76
77    // === Invalid Data Testing Options ===
78    /// Percentage of requests with invalid data
79    pub error_rate: Option<f64>,
80    /// Types of invalid data to generate
81    pub error_types: Option<String>,
82
83    // === Security Testing Options ===
84    /// Enable security testing
85    pub security_test: bool,
86    /// Custom security payloads file
87    pub security_payloads: Option<PathBuf>,
88    /// Security test categories
89    pub security_categories: Option<String>,
90    /// Fields to target for security injection
91    pub security_target_fields: Option<String>,
92}
93
94impl BenchCommand {
95    /// Execute the bench command
96    pub async fn execute(&self) -> Result<()> {
97        // Check if we're in multi-target mode
98        if let Some(targets_file) = &self.targets_file {
99            return self.execute_multi_target(targets_file).await;
100        }
101
102        // Single target mode (existing behavior)
103        // Print header
104        TerminalReporter::print_header(
105            self.spec.to_str().unwrap(),
106            &self.target,
107            0, // Will be updated later
108            &self.scenario,
109            Self::parse_duration(&self.duration)?,
110        );
111
112        // Validate k6 installation
113        if !K6Executor::is_k6_installed() {
114            TerminalReporter::print_error("k6 is not installed");
115            TerminalReporter::print_warning(
116                "Install k6 from: https://k6.io/docs/get-started/installation/",
117            );
118            return Err(BenchError::K6NotFound);
119        }
120
121        // Load and parse spec
122        TerminalReporter::print_progress("Loading OpenAPI specification...");
123        let parser = SpecParser::from_file(&self.spec).await?;
124        TerminalReporter::print_success("Specification loaded");
125
126        // Check for mock server integration
127        let mock_config = self.build_mock_config().await;
128        if mock_config.is_mock_server {
129            TerminalReporter::print_progress("Mock server integration enabled");
130        }
131
132        // Check for CRUD flow mode
133        if self.crud_flow {
134            return self.execute_crud_flow(&parser).await;
135        }
136
137        // Get operations
138        TerminalReporter::print_progress("Extracting API operations...");
139        let mut operations = if let Some(filter) = &self.operations {
140            parser.filter_operations(filter)?
141        } else {
142            parser.get_operations()
143        };
144
145        // Apply exclusions if provided
146        if let Some(exclude) = &self.exclude_operations {
147            let before_count = operations.len();
148            operations = parser.exclude_operations(operations, exclude)?;
149            let excluded_count = before_count - operations.len();
150            if excluded_count > 0 {
151                TerminalReporter::print_progress(&format!(
152                    "Excluded {} operations matching '{}'",
153                    excluded_count, exclude
154                ));
155            }
156        }
157
158        if operations.is_empty() {
159            return Err(BenchError::Other("No operations found in spec".to_string()));
160        }
161
162        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
163
164        // Load parameter overrides if provided
165        let param_overrides = if let Some(params_file) = &self.params_file {
166            TerminalReporter::print_progress("Loading parameter overrides...");
167            let overrides = ParameterOverrides::from_file(params_file)?;
168            TerminalReporter::print_success(&format!(
169                "Loaded parameter overrides ({} operation-specific, {} defaults)",
170                overrides.operations.len(),
171                if overrides.defaults.is_empty() { 0 } else { 1 }
172            ));
173            Some(overrides)
174        } else {
175            None
176        };
177
178        // Generate request templates
179        TerminalReporter::print_progress("Generating request templates...");
180        let templates: Vec<_> = operations
181            .iter()
182            .map(|op| {
183                let op_overrides = param_overrides.as_ref().map(|po| {
184                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
185                });
186                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
187            })
188            .collect::<Result<Vec<_>>>()?;
189        TerminalReporter::print_success("Request templates generated");
190
191        // Parse headers
192        let custom_headers = self.parse_headers()?;
193
194        // Generate k6 script
195        TerminalReporter::print_progress("Generating k6 load test script...");
196        let scenario =
197            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
198
199        let k6_config = K6Config {
200            target_url: self.target.clone(),
201            scenario,
202            duration_secs: Self::parse_duration(&self.duration)?,
203            max_vus: self.vus,
204            threshold_percentile: self.threshold_percentile.clone(),
205            threshold_ms: self.threshold_ms,
206            max_error_rate: self.max_error_rate,
207            auth_header: self.auth.clone(),
208            custom_headers,
209            skip_tls_verify: self.skip_tls_verify,
210        };
211
212        let generator = K6ScriptGenerator::new(k6_config, templates);
213        let mut script = generator.generate()?;
214        TerminalReporter::print_success("k6 script generated");
215
216        // Check if any advanced features are enabled
217        let has_advanced_features = self.data_file.is_some()
218            || self.error_rate.is_some()
219            || self.security_test
220            || self.parallel_create.is_some();
221
222        // Enhance script with advanced features
223        if has_advanced_features {
224            script = self.generate_enhanced_script(&script)?;
225        }
226
227        // Add mock server integration code
228        if mock_config.is_mock_server {
229            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
230            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
231            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
232
233            // Insert mock server code after imports
234            if let Some(import_end) = script.find("export const options") {
235                script.insert_str(
236                    import_end,
237                    &format!("\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
238                        helper_code, setup_code, teardown_code),
239                );
240            }
241        }
242
243        // Validate the generated script
244        TerminalReporter::print_progress("Validating k6 script...");
245        let validation_errors = K6ScriptGenerator::validate_script(&script);
246        if !validation_errors.is_empty() {
247            TerminalReporter::print_error("Script validation failed");
248            for error in &validation_errors {
249                eprintln!("  {}", error);
250            }
251            return Err(BenchError::Other(format!(
252                "Generated k6 script has {} validation error(s). Please check the output above.",
253                validation_errors.len()
254            )));
255        }
256        TerminalReporter::print_success("Script validation passed");
257
258        // Write script to file
259        let script_path = if let Some(output) = &self.script_output {
260            output.clone()
261        } else {
262            self.output.join("k6-script.js")
263        };
264
265        std::fs::create_dir_all(script_path.parent().unwrap())?;
266        std::fs::write(&script_path, &script)?;
267        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
268
269        // If generate-only mode, exit here
270        if self.generate_only {
271            println!("\nScript generated successfully. Run it with:");
272            println!("  k6 run {}", script_path.display());
273            return Ok(());
274        }
275
276        // Execute k6
277        TerminalReporter::print_progress("Executing load test...");
278        let executor = K6Executor::new()?;
279
280        std::fs::create_dir_all(&self.output)?;
281
282        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
283
284        // Print results
285        let duration_secs = Self::parse_duration(&self.duration)?;
286        TerminalReporter::print_summary(&results, duration_secs);
287
288        println!("\nResults saved to: {}", self.output.display());
289
290        Ok(())
291    }
292
293    /// Execute multi-target bench testing
294    async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
295        TerminalReporter::print_progress("Parsing targets file...");
296        let targets = parse_targets_file(targets_file)?;
297        let num_targets = targets.len();
298        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
299
300        if targets.is_empty() {
301            return Err(BenchError::Other("No targets found in file".to_string()));
302        }
303
304        // Determine max concurrency
305        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
306        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
307
308        // Print header for multi-target mode
309        TerminalReporter::print_header(
310            self.spec.to_str().unwrap(),
311            &format!("{} targets", num_targets),
312            0,
313            &self.scenario,
314            Self::parse_duration(&self.duration)?,
315        );
316
317        // Create parallel executor
318        let executor = ParallelExecutor::new(
319            BenchCommand {
320                // Clone all fields except targets_file (we don't need it in the executor)
321                spec: self.spec.clone(),
322                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
323                duration: self.duration.clone(),
324                vus: self.vus,
325                scenario: self.scenario.clone(),
326                operations: self.operations.clone(),
327                exclude_operations: self.exclude_operations.clone(),
328                auth: self.auth.clone(),
329                headers: self.headers.clone(),
330                output: self.output.clone(),
331                generate_only: self.generate_only,
332                script_output: self.script_output.clone(),
333                threshold_percentile: self.threshold_percentile.clone(),
334                threshold_ms: self.threshold_ms,
335                max_error_rate: self.max_error_rate,
336                verbose: self.verbose,
337                skip_tls_verify: self.skip_tls_verify,
338                targets_file: None,
339                max_concurrency: None,
340                results_format: self.results_format.clone(),
341                params_file: self.params_file.clone(),
342                crud_flow: self.crud_flow,
343                flow_config: self.flow_config.clone(),
344                extract_fields: self.extract_fields.clone(),
345                parallel_create: self.parallel_create,
346                data_file: self.data_file.clone(),
347                data_distribution: self.data_distribution.clone(),
348                data_mappings: self.data_mappings.clone(),
349                error_rate: self.error_rate,
350                error_types: self.error_types.clone(),
351                security_test: self.security_test,
352                security_payloads: self.security_payloads.clone(),
353                security_categories: self.security_categories.clone(),
354                security_target_fields: self.security_target_fields.clone(),
355            },
356            targets,
357            max_concurrency,
358        );
359
360        // Execute all targets
361        let aggregated_results = executor.execute_all().await?;
362
363        // Organize and report results
364        self.report_multi_target_results(&aggregated_results)?;
365
366        Ok(())
367    }
368
369    /// Report results for multi-target execution
370    fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
371        // Print summary
372        TerminalReporter::print_multi_target_summary(results);
373
374        // Save aggregated summary if requested
375        if self.results_format == "aggregated" || self.results_format == "both" {
376            let summary_path = self.output.join("aggregated_summary.json");
377            let summary_json = serde_json::json!({
378                "total_targets": results.total_targets,
379                "successful_targets": results.successful_targets,
380                "failed_targets": results.failed_targets,
381                "aggregated_metrics": {
382                    "total_requests": results.aggregated_metrics.total_requests,
383                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
384                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
385                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
386                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
387                    "error_rate": results.aggregated_metrics.error_rate,
388                },
389                "target_results": results.target_results.iter().map(|r| {
390                    serde_json::json!({
391                        "target_url": r.target_url,
392                        "target_index": r.target_index,
393                        "success": r.success,
394                        "error": r.error,
395                        "total_requests": r.results.total_requests,
396                        "failed_requests": r.results.failed_requests,
397                        "avg_duration_ms": r.results.avg_duration_ms,
398                        "p95_duration_ms": r.results.p95_duration_ms,
399                        "p99_duration_ms": r.results.p99_duration_ms,
400                        "output_dir": r.output_dir.to_string_lossy(),
401                    })
402                }).collect::<Vec<_>>(),
403            });
404
405            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
406            TerminalReporter::print_success(&format!(
407                "Aggregated summary saved to: {}",
408                summary_path.display()
409            ));
410        }
411
412        println!("\nResults saved to: {}", self.output.display());
413        println!("  - Per-target results: {}", self.output.join("target_*").display());
414        if self.results_format == "aggregated" || self.results_format == "both" {
415            println!(
416                "  - Aggregated summary: {}",
417                self.output.join("aggregated_summary.json").display()
418            );
419        }
420
421        Ok(())
422    }
423
424    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
425    pub fn parse_duration(duration: &str) -> Result<u64> {
426        let duration = duration.trim();
427
428        if let Some(secs) = duration.strip_suffix('s') {
429            secs.parse::<u64>()
430                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
431        } else if let Some(mins) = duration.strip_suffix('m') {
432            mins.parse::<u64>()
433                .map(|m| m * 60)
434                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
435        } else if let Some(hours) = duration.strip_suffix('h') {
436            hours
437                .parse::<u64>()
438                .map(|h| h * 3600)
439                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
440        } else {
441            // Try parsing as seconds without suffix
442            duration
443                .parse::<u64>()
444                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
445        }
446    }
447
448    /// Parse headers from command line format (Key:Value,Key2:Value2)
449    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
450        let mut headers = HashMap::new();
451
452        if let Some(header_str) = &self.headers {
453            for pair in header_str.split(',') {
454                let parts: Vec<&str> = pair.splitn(2, ':').collect();
455                if parts.len() != 2 {
456                    return Err(BenchError::Other(format!(
457                        "Invalid header format: '{}'. Expected 'Key:Value'",
458                        pair
459                    )));
460                }
461                headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
462            }
463        }
464
465        Ok(headers)
466    }
467
468    /// Build mock server integration configuration
469    async fn build_mock_config(&self) -> MockIntegrationConfig {
470        // Check if target looks like a mock server
471        if MockServerDetector::looks_like_mock_server(&self.target) {
472            // Try to detect if it's actually a MockForge server
473            if let Ok(info) = MockServerDetector::detect(&self.target).await {
474                if info.is_mockforge {
475                    TerminalReporter::print_success(&format!(
476                        "Detected MockForge server (version: {})",
477                        info.version.as_deref().unwrap_or("unknown")
478                    ));
479                    return MockIntegrationConfig::mock_server();
480                }
481            }
482        }
483        MockIntegrationConfig::real_api()
484    }
485
486    /// Build CRUD flow configuration
487    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
488        if !self.crud_flow {
489            return None;
490        }
491
492        // If flow_config file is provided, load it
493        if let Some(config_path) = &self.flow_config {
494            match CrudFlowConfig::from_file(config_path) {
495                Ok(config) => return Some(config),
496                Err(e) => {
497                    TerminalReporter::print_warning(&format!(
498                        "Failed to load flow config: {}. Using auto-detection.",
499                        e
500                    ));
501                }
502            }
503        }
504
505        // Parse extract fields
506        let extract_fields = self
507            .extract_fields
508            .as_ref()
509            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
510            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
511
512        Some(CrudFlowConfig {
513            flows: Vec::new(), // Will be auto-detected
514            default_extract_fields: extract_fields,
515        })
516    }
517
518    /// Build data-driven testing configuration
519    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
520        let data_file = self.data_file.as_ref()?;
521
522        let distribution = DataDistribution::from_str(&self.data_distribution)
523            .unwrap_or(DataDistribution::UniquePerVu);
524
525        let mappings = self.data_mappings.as_ref().map(|m| {
526            DataMapping::parse_mappings(m).unwrap_or_default()
527        }).unwrap_or_default();
528
529        Some(DataDrivenConfig {
530            file_path: data_file.to_string_lossy().to_string(),
531            distribution,
532            mappings,
533            csv_has_header: true,
534        })
535    }
536
537    /// Build invalid data testing configuration
538    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
539        let error_rate = self.error_rate?;
540
541        let error_types = self.error_types.as_ref()
542            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
543            .unwrap_or_default();
544
545        Some(InvalidDataConfig {
546            error_rate,
547            error_types,
548            target_fields: Vec::new(),
549        })
550    }
551
552    /// Build security testing configuration
553    fn build_security_config(&self) -> Option<SecurityTestConfig> {
554        if !self.security_test {
555            return None;
556        }
557
558        let categories = self.security_categories.as_ref()
559            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
560            .unwrap_or_else(|| {
561                let mut default = std::collections::HashSet::new();
562                default.insert(SecurityCategory::SqlInjection);
563                default.insert(SecurityCategory::Xss);
564                default
565            });
566
567        let target_fields = self.security_target_fields.as_ref()
568            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
569            .unwrap_or_default();
570
571        let custom_payloads_file = self.security_payloads.as_ref()
572            .map(|p| p.to_string_lossy().to_string());
573
574        Some(SecurityTestConfig {
575            enabled: true,
576            categories,
577            target_fields,
578            custom_payloads_file,
579            include_high_risk: false,
580        })
581    }
582
583    /// Build parallel execution configuration
584    fn build_parallel_config(&self) -> Option<ParallelConfig> {
585        let count = self.parallel_create?;
586
587        Some(ParallelConfig::new(count))
588    }
589
590    /// Generate enhanced k6 script with advanced features
591    fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
592        let mut enhanced_script = base_script.to_string();
593        let mut additional_code = String::new();
594
595        // Add data-driven testing code
596        if let Some(config) = self.build_data_driven_config() {
597            TerminalReporter::print_progress("Adding data-driven testing support...");
598            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
599            additional_code.push('\n');
600            TerminalReporter::print_success("Data-driven testing enabled");
601        }
602
603        // Add invalid data generation code
604        if let Some(config) = self.build_invalid_data_config() {
605            TerminalReporter::print_progress("Adding invalid data testing support...");
606            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
607            additional_code.push('\n');
608            additional_code.push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
609            additional_code.push('\n');
610            additional_code.push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
611            additional_code.push('\n');
612            TerminalReporter::print_success(&format!(
613                "Invalid data testing enabled ({}% error rate)",
614                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
615            ));
616        }
617
618        // Add security testing code
619        if let Some(config) = self.build_security_config() {
620            TerminalReporter::print_progress("Adding security testing support...");
621            let payload_list = SecurityPayloads::get_payloads(&config);
622            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(&payload_list));
623            additional_code.push('\n');
624            additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&config.target_fields));
625            additional_code.push('\n');
626            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
627            additional_code.push('\n');
628            TerminalReporter::print_success(&format!(
629                "Security testing enabled ({} payloads)",
630                payload_list.len()
631            ));
632        }
633
634        // Add parallel execution code
635        if let Some(config) = self.build_parallel_config() {
636            TerminalReporter::print_progress("Adding parallel execution support...");
637            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
638            additional_code.push('\n');
639            TerminalReporter::print_success(&format!(
640                "Parallel execution enabled (count: {})",
641                config.count
642            ));
643        }
644
645        // Insert additional code after the imports section
646        if !additional_code.is_empty() {
647            // Find the end of the import section
648            if let Some(import_end) = enhanced_script.find("export const options") {
649                enhanced_script.insert_str(
650                    import_end,
651                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
652                );
653            }
654        }
655
656        Ok(enhanced_script)
657    }
658
659    /// Execute CRUD flow testing mode
660    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
661        TerminalReporter::print_progress("Detecting CRUD operations...");
662
663        let operations = parser.get_operations();
664        let flows = CrudFlowDetector::detect_flows(&operations);
665
666        if flows.is_empty() {
667            return Err(BenchError::Other(
668                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
669            ));
670        }
671
672        TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
673
674        for flow in &flows {
675            TerminalReporter::print_progress(&format!(
676                "  - {}: {} steps",
677                flow.name,
678                flow.steps.len()
679            ));
680        }
681
682        // Generate CRUD flow script
683        let handlebars = handlebars::Handlebars::new();
684        let template = include_str!("templates/k6_crud_flow.hbs");
685
686        let custom_headers = self.parse_headers()?;
687        let config = self.build_crud_flow_config().unwrap_or_default();
688
689        let data = serde_json::json!({
690            "base_url": self.target,
691            "flows": flows.iter().map(|f| {
692                serde_json::json!({
693                    "name": f.name,
694                    "base_path": f.base_path,
695                    "steps": f.steps.iter().map(|s| {
696                        serde_json::json!({
697                            "operation": s.operation,
698                            "extract": s.extract,
699                            "use_values": s.use_values,
700                            "description": s.description,
701                        })
702                    }).collect::<Vec<_>>(),
703                })
704            }).collect::<Vec<_>>(),
705            "extract_fields": config.default_extract_fields,
706            "duration_secs": Self::parse_duration(&self.duration)?,
707            "max_vus": self.vus,
708            "auth_header": self.auth,
709            "custom_headers": custom_headers,
710            "skip_tls_verify": self.skip_tls_verify,
711        });
712
713        let script = handlebars
714            .render_template(template, &data)
715            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
716
717        TerminalReporter::print_success("CRUD flow script generated");
718
719        // Write and execute script
720        let script_path = if let Some(output) = &self.script_output {
721            output.clone()
722        } else {
723            self.output.join("k6-crud-flow-script.js")
724        };
725
726        std::fs::create_dir_all(script_path.parent().unwrap())?;
727        std::fs::write(&script_path, &script)?;
728        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
729
730        if self.generate_only {
731            println!("\nScript generated successfully. Run it with:");
732            println!("  k6 run {}", script_path.display());
733            return Ok(());
734        }
735
736        // Execute k6
737        TerminalReporter::print_progress("Executing CRUD flow test...");
738        let executor = K6Executor::new()?;
739        std::fs::create_dir_all(&self.output)?;
740
741        let results = executor
742            .execute(&script_path, Some(&self.output), self.verbose)
743            .await?;
744
745        let duration_secs = Self::parse_duration(&self.duration)?;
746        TerminalReporter::print_summary(&results, duration_secs);
747
748        Ok(())
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn test_parse_duration() {
758        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
759        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
760        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
761        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
762    }
763
764    #[test]
765    fn test_parse_duration_invalid() {
766        assert!(BenchCommand::parse_duration("invalid").is_err());
767        assert!(BenchCommand::parse_duration("30x").is_err());
768    }
769
770    #[test]
771    fn test_parse_headers() {
772        let cmd = BenchCommand {
773            spec: PathBuf::from("test.yaml"),
774            target: "http://localhost".to_string(),
775            duration: "1m".to_string(),
776            vus: 10,
777            scenario: "ramp-up".to_string(),
778            operations: None,
779            exclude_operations: None,
780            auth: None,
781            headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
782            output: PathBuf::from("output"),
783            generate_only: false,
784            script_output: None,
785            threshold_percentile: "p(95)".to_string(),
786            threshold_ms: 500,
787            max_error_rate: 0.05,
788            verbose: false,
789            skip_tls_verify: false,
790            targets_file: None,
791            max_concurrency: None,
792            results_format: "both".to_string(),
793            params_file: None,
794            crud_flow: false,
795            flow_config: None,
796            extract_fields: None,
797            parallel_create: None,
798            data_file: None,
799            data_distribution: "unique-per-vu".to_string(),
800            data_mappings: None,
801            error_rate: None,
802            error_types: None,
803            security_test: false,
804            security_payloads: None,
805            security_categories: None,
806            security_target_fields: None,
807        };
808
809        let headers = cmd.parse_headers().unwrap();
810        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
811        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
812    }
813}