1use 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, 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 mockforge_core::openapi::multi_spec::{
27 load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
28};
29use mockforge_core::openapi::spec::OpenApiSpec;
30use std::collections::HashMap;
31use std::path::PathBuf;
32use std::str::FromStr;
33
34pub struct BenchCommand {
36 pub spec: Vec<PathBuf>,
38 pub spec_dir: Option<PathBuf>,
40 pub merge_conflicts: String,
42 pub spec_mode: String,
44 pub dependency_config: Option<PathBuf>,
46 pub target: String,
47 pub duration: String,
48 pub vus: u32,
49 pub scenario: String,
50 pub operations: Option<String>,
51 pub exclude_operations: Option<String>,
55 pub auth: Option<String>,
56 pub headers: Option<String>,
57 pub output: PathBuf,
58 pub generate_only: bool,
59 pub script_output: Option<PathBuf>,
60 pub threshold_percentile: String,
61 pub threshold_ms: u64,
62 pub max_error_rate: f64,
63 pub verbose: bool,
64 pub skip_tls_verify: bool,
65 pub targets_file: Option<PathBuf>,
67 pub max_concurrency: Option<u32>,
69 pub results_format: String,
71 pub params_file: Option<PathBuf>,
76
77 pub crud_flow: bool,
80 pub flow_config: Option<PathBuf>,
82 pub extract_fields: Option<String>,
84
85 pub parallel_create: Option<u32>,
88
89 pub data_file: Option<PathBuf>,
92 pub data_distribution: String,
94 pub data_mappings: Option<String>,
96
97 pub error_rate: Option<f64>,
100 pub error_types: Option<String>,
102
103 pub security_test: bool,
106 pub security_payloads: Option<PathBuf>,
108 pub security_categories: Option<String>,
110 pub security_target_fields: Option<String>,
112}
113
114impl BenchCommand {
115 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
117 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
118
119 if !self.spec.is_empty() {
121 let specs = load_specs_from_files(self.spec.clone())
122 .await
123 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
124 all_specs.extend(specs);
125 }
126
127 if let Some(spec_dir) = &self.spec_dir {
129 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
130 BenchError::Other(format!("Failed to load specs from directory: {}", e))
131 })?;
132 all_specs.extend(dir_specs);
133 }
134
135 if all_specs.is_empty() {
136 return Err(BenchError::Other(
137 "No spec files provided. Use --spec or --spec-dir.".to_string(),
138 ));
139 }
140
141 if all_specs.len() == 1 {
143 return Ok(all_specs.into_iter().next().unwrap().1);
144 }
145
146 let conflict_strategy = match self.merge_conflicts.as_str() {
148 "first" => ConflictStrategy::First,
149 "last" => ConflictStrategy::Last,
150 _ => ConflictStrategy::Error,
151 };
152
153 merge_specs(all_specs, conflict_strategy)
154 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
155 }
156
157 fn get_spec_display_name(&self) -> String {
159 if self.spec.len() == 1 {
160 self.spec[0].to_string_lossy().to_string()
161 } else if !self.spec.is_empty() {
162 format!("{} spec files", self.spec.len())
163 } else if let Some(dir) = &self.spec_dir {
164 format!("specs from {}", dir.display())
165 } else {
166 "no specs".to_string()
167 }
168 }
169
170 pub async fn execute(&self) -> Result<()> {
172 if let Some(targets_file) = &self.targets_file {
174 return self.execute_multi_target(targets_file).await;
175 }
176
177 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
179 return self.execute_sequential_specs().await;
180 }
181
182 TerminalReporter::print_header(
185 &self.get_spec_display_name(),
186 &self.target,
187 0, &self.scenario,
189 Self::parse_duration(&self.duration)?,
190 );
191
192 if !K6Executor::is_k6_installed() {
194 TerminalReporter::print_error("k6 is not installed");
195 TerminalReporter::print_warning(
196 "Install k6 from: https://k6.io/docs/get-started/installation/",
197 );
198 return Err(BenchError::K6NotFound);
199 }
200
201 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
203 let merged_spec = self.load_and_merge_specs().await?;
204 let parser = SpecParser::from_spec(merged_spec);
205 if self.spec.len() > 1 || self.spec_dir.is_some() {
206 TerminalReporter::print_success(&format!(
207 "Loaded and merged {} specification(s)",
208 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
209 ));
210 } else {
211 TerminalReporter::print_success("Specification loaded");
212 }
213
214 let mock_config = self.build_mock_config().await;
216 if mock_config.is_mock_server {
217 TerminalReporter::print_progress("Mock server integration enabled");
218 }
219
220 if self.crud_flow {
222 return self.execute_crud_flow(&parser).await;
223 }
224
225 TerminalReporter::print_progress("Extracting API operations...");
227 let mut operations = if let Some(filter) = &self.operations {
228 parser.filter_operations(filter)?
229 } else {
230 parser.get_operations()
231 };
232
233 if let Some(exclude) = &self.exclude_operations {
235 let before_count = operations.len();
236 operations = parser.exclude_operations(operations, exclude)?;
237 let excluded_count = before_count - operations.len();
238 if excluded_count > 0 {
239 TerminalReporter::print_progress(&format!(
240 "Excluded {} operations matching '{}'",
241 excluded_count, exclude
242 ));
243 }
244 }
245
246 if operations.is_empty() {
247 return Err(BenchError::Other("No operations found in spec".to_string()));
248 }
249
250 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
251
252 let param_overrides = if let Some(params_file) = &self.params_file {
254 TerminalReporter::print_progress("Loading parameter overrides...");
255 let overrides = ParameterOverrides::from_file(params_file)?;
256 TerminalReporter::print_success(&format!(
257 "Loaded parameter overrides ({} operation-specific, {} defaults)",
258 overrides.operations.len(),
259 if overrides.defaults.is_empty() { 0 } else { 1 }
260 ));
261 Some(overrides)
262 } else {
263 None
264 };
265
266 TerminalReporter::print_progress("Generating request templates...");
268 let templates: Vec<_> = operations
269 .iter()
270 .map(|op| {
271 let op_overrides = param_overrides.as_ref().map(|po| {
272 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
273 });
274 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
275 })
276 .collect::<Result<Vec<_>>>()?;
277 TerminalReporter::print_success("Request templates generated");
278
279 let custom_headers = self.parse_headers()?;
281
282 TerminalReporter::print_progress("Generating k6 load test script...");
284 let scenario =
285 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
286
287 let k6_config = K6Config {
288 target_url: self.target.clone(),
289 scenario,
290 duration_secs: Self::parse_duration(&self.duration)?,
291 max_vus: self.vus,
292 threshold_percentile: self.threshold_percentile.clone(),
293 threshold_ms: self.threshold_ms,
294 max_error_rate: self.max_error_rate,
295 auth_header: self.auth.clone(),
296 custom_headers,
297 skip_tls_verify: self.skip_tls_verify,
298 };
299
300 let generator = K6ScriptGenerator::new(k6_config, templates);
301 let mut script = generator.generate()?;
302 TerminalReporter::print_success("k6 script generated");
303
304 let has_advanced_features = self.data_file.is_some()
306 || self.error_rate.is_some()
307 || self.security_test
308 || self.parallel_create.is_some();
309
310 if has_advanced_features {
312 script = self.generate_enhanced_script(&script)?;
313 }
314
315 if mock_config.is_mock_server {
317 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
318 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
319 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
320
321 if let Some(import_end) = script.find("export const options") {
323 script.insert_str(
324 import_end,
325 &format!(
326 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
327 helper_code, setup_code, teardown_code
328 ),
329 );
330 }
331 }
332
333 TerminalReporter::print_progress("Validating k6 script...");
335 let validation_errors = K6ScriptGenerator::validate_script(&script);
336 if !validation_errors.is_empty() {
337 TerminalReporter::print_error("Script validation failed");
338 for error in &validation_errors {
339 eprintln!(" {}", error);
340 }
341 return Err(BenchError::Other(format!(
342 "Generated k6 script has {} validation error(s). Please check the output above.",
343 validation_errors.len()
344 )));
345 }
346 TerminalReporter::print_success("Script validation passed");
347
348 let script_path = if let Some(output) = &self.script_output {
350 output.clone()
351 } else {
352 self.output.join("k6-script.js")
353 };
354
355 std::fs::create_dir_all(script_path.parent().unwrap())?;
356 std::fs::write(&script_path, &script)?;
357 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
358
359 if self.generate_only {
361 println!("\nScript generated successfully. Run it with:");
362 println!(" k6 run {}", script_path.display());
363 return Ok(());
364 }
365
366 TerminalReporter::print_progress("Executing load test...");
368 let executor = K6Executor::new()?;
369
370 std::fs::create_dir_all(&self.output)?;
371
372 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
373
374 let duration_secs = Self::parse_duration(&self.duration)?;
376 TerminalReporter::print_summary(&results, duration_secs);
377
378 println!("\nResults saved to: {}", self.output.display());
379
380 Ok(())
381 }
382
383 async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
385 TerminalReporter::print_progress("Parsing targets file...");
386 let targets = parse_targets_file(targets_file)?;
387 let num_targets = targets.len();
388 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
389
390 if targets.is_empty() {
391 return Err(BenchError::Other("No targets found in file".to_string()));
392 }
393
394 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
396 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
400 &self.get_spec_display_name(),
401 &format!("{} targets", num_targets),
402 0,
403 &self.scenario,
404 Self::parse_duration(&self.duration)?,
405 );
406
407 let executor = ParallelExecutor::new(
409 BenchCommand {
410 spec: self.spec.clone(),
412 spec_dir: self.spec_dir.clone(),
413 merge_conflicts: self.merge_conflicts.clone(),
414 spec_mode: self.spec_mode.clone(),
415 dependency_config: self.dependency_config.clone(),
416 target: self.target.clone(), duration: self.duration.clone(),
418 vus: self.vus,
419 scenario: self.scenario.clone(),
420 operations: self.operations.clone(),
421 exclude_operations: self.exclude_operations.clone(),
422 auth: self.auth.clone(),
423 headers: self.headers.clone(),
424 output: self.output.clone(),
425 generate_only: self.generate_only,
426 script_output: self.script_output.clone(),
427 threshold_percentile: self.threshold_percentile.clone(),
428 threshold_ms: self.threshold_ms,
429 max_error_rate: self.max_error_rate,
430 verbose: self.verbose,
431 skip_tls_verify: self.skip_tls_verify,
432 targets_file: None,
433 max_concurrency: None,
434 results_format: self.results_format.clone(),
435 params_file: self.params_file.clone(),
436 crud_flow: self.crud_flow,
437 flow_config: self.flow_config.clone(),
438 extract_fields: self.extract_fields.clone(),
439 parallel_create: self.parallel_create,
440 data_file: self.data_file.clone(),
441 data_distribution: self.data_distribution.clone(),
442 data_mappings: self.data_mappings.clone(),
443 error_rate: self.error_rate,
444 error_types: self.error_types.clone(),
445 security_test: self.security_test,
446 security_payloads: self.security_payloads.clone(),
447 security_categories: self.security_categories.clone(),
448 security_target_fields: self.security_target_fields.clone(),
449 },
450 targets,
451 max_concurrency,
452 );
453
454 let aggregated_results = executor.execute_all().await?;
456
457 self.report_multi_target_results(&aggregated_results)?;
459
460 Ok(())
461 }
462
463 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
465 TerminalReporter::print_multi_target_summary(results);
467
468 if self.results_format == "aggregated" || self.results_format == "both" {
470 let summary_path = self.output.join("aggregated_summary.json");
471 let summary_json = serde_json::json!({
472 "total_targets": results.total_targets,
473 "successful_targets": results.successful_targets,
474 "failed_targets": results.failed_targets,
475 "aggregated_metrics": {
476 "total_requests": results.aggregated_metrics.total_requests,
477 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
478 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
479 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
480 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
481 "error_rate": results.aggregated_metrics.error_rate,
482 },
483 "target_results": results.target_results.iter().map(|r| {
484 serde_json::json!({
485 "target_url": r.target_url,
486 "target_index": r.target_index,
487 "success": r.success,
488 "error": r.error,
489 "total_requests": r.results.total_requests,
490 "failed_requests": r.results.failed_requests,
491 "avg_duration_ms": r.results.avg_duration_ms,
492 "p95_duration_ms": r.results.p95_duration_ms,
493 "p99_duration_ms": r.results.p99_duration_ms,
494 "output_dir": r.output_dir.to_string_lossy(),
495 })
496 }).collect::<Vec<_>>(),
497 });
498
499 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
500 TerminalReporter::print_success(&format!(
501 "Aggregated summary saved to: {}",
502 summary_path.display()
503 ));
504 }
505
506 println!("\nResults saved to: {}", self.output.display());
507 println!(" - Per-target results: {}", self.output.join("target_*").display());
508 if self.results_format == "aggregated" || self.results_format == "both" {
509 println!(
510 " - Aggregated summary: {}",
511 self.output.join("aggregated_summary.json").display()
512 );
513 }
514
515 Ok(())
516 }
517
518 pub fn parse_duration(duration: &str) -> Result<u64> {
520 let duration = duration.trim();
521
522 if let Some(secs) = duration.strip_suffix('s') {
523 secs.parse::<u64>()
524 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
525 } else if let Some(mins) = duration.strip_suffix('m') {
526 mins.parse::<u64>()
527 .map(|m| m * 60)
528 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
529 } else if let Some(hours) = duration.strip_suffix('h') {
530 hours
531 .parse::<u64>()
532 .map(|h| h * 3600)
533 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
534 } else {
535 duration
537 .parse::<u64>()
538 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
539 }
540 }
541
542 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
544 let mut headers = HashMap::new();
545
546 if let Some(header_str) = &self.headers {
547 for pair in header_str.split(',') {
548 let parts: Vec<&str> = pair.splitn(2, ':').collect();
549 if parts.len() != 2 {
550 return Err(BenchError::Other(format!(
551 "Invalid header format: '{}'. Expected 'Key:Value'",
552 pair
553 )));
554 }
555 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
556 }
557 }
558
559 Ok(headers)
560 }
561
562 async fn build_mock_config(&self) -> MockIntegrationConfig {
564 if MockServerDetector::looks_like_mock_server(&self.target) {
566 if let Ok(info) = MockServerDetector::detect(&self.target).await {
568 if info.is_mockforge {
569 TerminalReporter::print_success(&format!(
570 "Detected MockForge server (version: {})",
571 info.version.as_deref().unwrap_or("unknown")
572 ));
573 return MockIntegrationConfig::mock_server();
574 }
575 }
576 }
577 MockIntegrationConfig::real_api()
578 }
579
580 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
582 if !self.crud_flow {
583 return None;
584 }
585
586 if let Some(config_path) = &self.flow_config {
588 match CrudFlowConfig::from_file(config_path) {
589 Ok(config) => return Some(config),
590 Err(e) => {
591 TerminalReporter::print_warning(&format!(
592 "Failed to load flow config: {}. Using auto-detection.",
593 e
594 ));
595 }
596 }
597 }
598
599 let extract_fields = self
601 .extract_fields
602 .as_ref()
603 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
604 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
605
606 Some(CrudFlowConfig {
607 flows: Vec::new(), default_extract_fields: extract_fields,
609 })
610 }
611
612 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
614 let data_file = self.data_file.as_ref()?;
615
616 let distribution = DataDistribution::from_str(&self.data_distribution)
617 .unwrap_or(DataDistribution::UniquePerVu);
618
619 let mappings = self
620 .data_mappings
621 .as_ref()
622 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
623 .unwrap_or_default();
624
625 Some(DataDrivenConfig {
626 file_path: data_file.to_string_lossy().to_string(),
627 distribution,
628 mappings,
629 csv_has_header: true,
630 })
631 }
632
633 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
635 let error_rate = self.error_rate?;
636
637 let error_types = self
638 .error_types
639 .as_ref()
640 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
641 .unwrap_or_default();
642
643 Some(InvalidDataConfig {
644 error_rate,
645 error_types,
646 target_fields: Vec::new(),
647 })
648 }
649
650 fn build_security_config(&self) -> Option<SecurityTestConfig> {
652 if !self.security_test {
653 return None;
654 }
655
656 let categories = self
657 .security_categories
658 .as_ref()
659 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
660 .unwrap_or_else(|| {
661 let mut default = std::collections::HashSet::new();
662 default.insert(SecurityCategory::SqlInjection);
663 default.insert(SecurityCategory::Xss);
664 default
665 });
666
667 let target_fields = self
668 .security_target_fields
669 .as_ref()
670 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
671 .unwrap_or_default();
672
673 let custom_payloads_file =
674 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
675
676 Some(SecurityTestConfig {
677 enabled: true,
678 categories,
679 target_fields,
680 custom_payloads_file,
681 include_high_risk: false,
682 })
683 }
684
685 fn build_parallel_config(&self) -> Option<ParallelConfig> {
687 let count = self.parallel_create?;
688
689 Some(ParallelConfig::new(count))
690 }
691
692 fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
694 let mut enhanced_script = base_script.to_string();
695 let mut additional_code = String::new();
696
697 if let Some(config) = self.build_data_driven_config() {
699 TerminalReporter::print_progress("Adding data-driven testing support...");
700 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
701 additional_code.push('\n');
702 TerminalReporter::print_success("Data-driven testing enabled");
703 }
704
705 if let Some(config) = self.build_invalid_data_config() {
707 TerminalReporter::print_progress("Adding invalid data testing support...");
708 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
709 additional_code.push('\n');
710 additional_code
711 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
712 additional_code.push('\n');
713 additional_code
714 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
715 additional_code.push('\n');
716 TerminalReporter::print_success(&format!(
717 "Invalid data testing enabled ({}% error rate)",
718 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
719 ));
720 }
721
722 if let Some(config) = self.build_security_config() {
724 TerminalReporter::print_progress("Adding security testing support...");
725 let payload_list = SecurityPayloads::get_payloads(&config);
726 additional_code
727 .push_str(&SecurityTestGenerator::generate_payload_selection(&payload_list));
728 additional_code.push('\n');
729 additional_code
730 .push_str(&SecurityTestGenerator::generate_apply_payload(&config.target_fields));
731 additional_code.push('\n');
732 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
733 additional_code.push('\n');
734 TerminalReporter::print_success(&format!(
735 "Security testing enabled ({} payloads)",
736 payload_list.len()
737 ));
738 }
739
740 if let Some(config) = self.build_parallel_config() {
742 TerminalReporter::print_progress("Adding parallel execution support...");
743 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
744 additional_code.push('\n');
745 TerminalReporter::print_success(&format!(
746 "Parallel execution enabled (count: {})",
747 config.count
748 ));
749 }
750
751 if !additional_code.is_empty() {
753 if let Some(import_end) = enhanced_script.find("export const options") {
755 enhanced_script.insert_str(
756 import_end,
757 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
758 );
759 }
760 }
761
762 Ok(enhanced_script)
763 }
764
765 async fn execute_sequential_specs(&self) -> Result<()> {
767 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
768
769 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
771
772 if !self.spec.is_empty() {
773 let specs = load_specs_from_files(self.spec.clone())
774 .await
775 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
776 all_specs.extend(specs);
777 }
778
779 if let Some(spec_dir) = &self.spec_dir {
780 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
781 BenchError::Other(format!("Failed to load specs from directory: {}", e))
782 })?;
783 all_specs.extend(dir_specs);
784 }
785
786 if all_specs.is_empty() {
787 return Err(BenchError::Other(
788 "No spec files found for sequential execution".to_string(),
789 ));
790 }
791
792 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
793
794 let execution_order = if let Some(config_path) = &self.dependency_config {
796 TerminalReporter::print_progress("Loading dependency configuration...");
797 let config = SpecDependencyConfig::from_file(config_path)?;
798
799 if !config.disable_auto_detect && config.execution_order.is_empty() {
800 self.detect_and_sort_specs(&all_specs)?
802 } else {
803 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
805 }
806 } else {
807 self.detect_and_sort_specs(&all_specs)?
809 };
810
811 TerminalReporter::print_success(&format!(
812 "Execution order: {}",
813 execution_order
814 .iter()
815 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
816 .collect::<Vec<_>>()
817 .join(" → ")
818 ));
819
820 let mut extracted_values = ExtractedValues::new();
822 let total_specs = execution_order.len();
823
824 for (index, spec_path) in execution_order.iter().enumerate() {
825 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
826
827 TerminalReporter::print_progress(&format!(
828 "[{}/{}] Executing spec: {}",
829 index + 1,
830 total_specs,
831 spec_name
832 ));
833
834 let spec = all_specs
836 .iter()
837 .find(|(p, _)| p == spec_path)
838 .map(|(_, s)| s.clone())
839 .ok_or_else(|| {
840 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
841 })?;
842
843 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
845
846 extracted_values.merge(&new_values);
848
849 TerminalReporter::print_success(&format!(
850 "[{}/{}] Completed: {} (extracted {} values)",
851 index + 1,
852 total_specs,
853 spec_name,
854 new_values.values.len()
855 ));
856 }
857
858 TerminalReporter::print_success(&format!(
859 "Sequential execution complete: {} specs executed",
860 total_specs
861 ));
862
863 Ok(())
864 }
865
866 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
868 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
869
870 let mut detector = DependencyDetector::new();
871 let dependencies = detector.detect_dependencies(specs);
872
873 if dependencies.is_empty() {
874 TerminalReporter::print_progress("No dependencies detected, using file order");
875 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
876 }
877
878 TerminalReporter::print_progress(&format!(
879 "Detected {} cross-spec dependencies",
880 dependencies.len()
881 ));
882
883 for dep in &dependencies {
884 TerminalReporter::print_progress(&format!(
885 " {} → {} (via field '{}')",
886 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
887 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
888 dep.field_name
889 ));
890 }
891
892 topological_sort(specs, &dependencies)
893 }
894
895 async fn execute_single_spec(
897 &self,
898 spec: &OpenApiSpec,
899 spec_name: &str,
900 _external_values: &ExtractedValues,
901 ) -> Result<ExtractedValues> {
902 let parser = SpecParser::from_spec(spec.clone());
903
904 if self.crud_flow {
906 self.execute_crud_flow_with_extraction(&parser, spec_name).await
908 } else {
909 self.execute_standard_spec(&parser, spec_name).await?;
911 Ok(ExtractedValues::new())
912 }
913 }
914
915 async fn execute_crud_flow_with_extraction(
917 &self,
918 parser: &SpecParser,
919 spec_name: &str,
920 ) -> Result<ExtractedValues> {
921 let operations = parser.get_operations();
922 let flows = CrudFlowDetector::detect_flows(&operations);
923
924 if flows.is_empty() {
925 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
926 return Ok(ExtractedValues::new());
927 }
928
929 TerminalReporter::print_progress(&format!(
930 " {} CRUD flow(s) in {}",
931 flows.len(),
932 spec_name
933 ));
934
935 let handlebars = handlebars::Handlebars::new();
937 let template = include_str!("templates/k6_crud_flow.hbs");
938
939 let custom_headers = self.parse_headers()?;
940 let config = self.build_crud_flow_config().unwrap_or_default();
941
942 let duration_secs = Self::parse_duration(&self.duration)?;
944 let scenario =
945 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
946 let stages = scenario.generate_stages(duration_secs, self.vus);
947
948 let mut all_headers = custom_headers.clone();
950 if let Some(auth) = &self.auth {
951 all_headers.insert("Authorization".to_string(), auth.clone());
952 }
953 let headers_json =
954 serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
955
956 let data = serde_json::json!({
957 "base_url": self.target,
958 "flows": flows.iter().map(|f| {
959 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
960 serde_json::json!({
961 "name": sanitized_name.clone(),
962 "display_name": f.name,
963 "base_path": f.base_path,
964 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
965 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
967 let method = if parts.len() >= 1 {
968 let m = parts[0].to_lowercase();
969 if m == "delete" { "del".to_string() } else { m }
971 } else {
972 "get".to_string()
973 };
974 let path = if parts.len() >= 2 { parts[1] } else { "/" };
975 let is_get_or_head = method == "get" || method == "head";
976 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
978
979 serde_json::json!({
980 "operation": s.operation,
981 "method": method,
982 "path": path,
983 "extract": s.extract,
984 "use_values": s.use_values,
985 "description": s.description,
986 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
987 "is_get_or_head": is_get_or_head,
988 "has_body": has_body,
989 "body": serde_json::json!({}), "body_is_dynamic": false,
991 })
992 }).collect::<Vec<_>>(),
993 })
994 }).collect::<Vec<_>>(),
995 "extract_fields": config.default_extract_fields,
996 "duration_secs": duration_secs,
997 "max_vus": self.vus,
998 "auth_header": self.auth,
999 "custom_headers": custom_headers,
1000 "skip_tls_verify": self.skip_tls_verify,
1001 "stages": stages.iter().map(|s| serde_json::json!({
1003 "duration": s.duration,
1004 "target": s.target,
1005 })).collect::<Vec<_>>(),
1006 "threshold_percentile": self.threshold_percentile,
1007 "threshold_ms": self.threshold_ms,
1008 "max_error_rate": self.max_error_rate,
1009 "headers": headers_json,
1010 "dynamic_imports": Vec::<String>::new(),
1011 "dynamic_globals": Vec::<String>::new(),
1012 });
1013
1014 let script = handlebars
1015 .render_template(template, &data)
1016 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1017
1018 let script_path =
1020 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1021
1022 std::fs::create_dir_all(self.output.clone())?;
1023 std::fs::write(&script_path, &script)?;
1024
1025 if !self.generate_only {
1026 let executor = K6Executor::new()?;
1027 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1028 std::fs::create_dir_all(&output_dir)?;
1029
1030 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1031 }
1032
1033 Ok(ExtractedValues::new())
1036 }
1037
1038 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1040 let mut operations = if let Some(filter) = &self.operations {
1041 parser.filter_operations(filter)?
1042 } else {
1043 parser.get_operations()
1044 };
1045
1046 if let Some(exclude) = &self.exclude_operations {
1047 operations = parser.exclude_operations(operations, exclude)?;
1048 }
1049
1050 if operations.is_empty() {
1051 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1052 return Ok(());
1053 }
1054
1055 TerminalReporter::print_progress(&format!(
1056 " {} operations in {}",
1057 operations.len(),
1058 spec_name
1059 ));
1060
1061 let templates: Vec<_> = operations
1063 .iter()
1064 .map(RequestGenerator::generate_template)
1065 .collect::<Result<Vec<_>>>()?;
1066
1067 let custom_headers = self.parse_headers()?;
1069
1070 let scenario =
1072 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1073
1074 let k6_config = K6Config {
1075 target_url: self.target.clone(),
1076 scenario,
1077 duration_secs: Self::parse_duration(&self.duration)?,
1078 max_vus: self.vus,
1079 threshold_percentile: self.threshold_percentile.clone(),
1080 threshold_ms: self.threshold_ms,
1081 max_error_rate: self.max_error_rate,
1082 auth_header: self.auth.clone(),
1083 custom_headers,
1084 skip_tls_verify: self.skip_tls_verify,
1085 };
1086
1087 let generator = K6ScriptGenerator::new(k6_config, templates);
1088 let script = generator.generate()?;
1089
1090 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1092
1093 std::fs::create_dir_all(self.output.clone())?;
1094 std::fs::write(&script_path, &script)?;
1095
1096 if !self.generate_only {
1097 let executor = K6Executor::new()?;
1098 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1099 std::fs::create_dir_all(&output_dir)?;
1100
1101 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1102 }
1103
1104 Ok(())
1105 }
1106
1107 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1109 TerminalReporter::print_progress("Detecting CRUD operations...");
1110
1111 let operations = parser.get_operations();
1112 let flows = CrudFlowDetector::detect_flows(&operations);
1113
1114 if flows.is_empty() {
1115 return Err(BenchError::Other(
1116 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1117 ));
1118 }
1119
1120 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1121
1122 for flow in &flows {
1123 TerminalReporter::print_progress(&format!(
1124 " - {}: {} steps",
1125 flow.name,
1126 flow.steps.len()
1127 ));
1128 }
1129
1130 let handlebars = handlebars::Handlebars::new();
1132 let template = include_str!("templates/k6_crud_flow.hbs");
1133
1134 let custom_headers = self.parse_headers()?;
1135 let config = self.build_crud_flow_config().unwrap_or_default();
1136
1137 let duration_secs = Self::parse_duration(&self.duration)?;
1139 let scenario =
1140 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1141 let stages = scenario.generate_stages(duration_secs, self.vus);
1142
1143 let mut all_headers = custom_headers.clone();
1145 if let Some(auth) = &self.auth {
1146 all_headers.insert("Authorization".to_string(), auth.clone());
1147 }
1148 let headers_json =
1149 serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1150
1151 let data = serde_json::json!({
1152 "base_url": self.target,
1153 "flows": flows.iter().map(|f| {
1154 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1156 serde_json::json!({
1157 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1160 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1161 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1163 let method = if parts.len() >= 1 {
1164 let m = parts[0].to_lowercase();
1165 if m == "delete" { "del".to_string() } else { m }
1167 } else {
1168 "get".to_string()
1169 };
1170 let path = if parts.len() >= 2 { parts[1] } else { "/" };
1171 let is_get_or_head = method == "get" || method == "head";
1172 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1174
1175 serde_json::json!({
1176 "operation": s.operation,
1177 "method": method,
1178 "path": path,
1179 "extract": s.extract,
1180 "use_values": s.use_values,
1181 "description": s.description,
1182 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1183 "is_get_or_head": is_get_or_head,
1184 "has_body": has_body,
1185 "body": serde_json::json!({}), "body_is_dynamic": false,
1187 })
1188 }).collect::<Vec<_>>(),
1189 })
1190 }).collect::<Vec<_>>(),
1191 "extract_fields": config.default_extract_fields,
1192 "duration_secs": duration_secs,
1193 "max_vus": self.vus,
1194 "auth_header": self.auth,
1195 "custom_headers": custom_headers,
1196 "skip_tls_verify": self.skip_tls_verify,
1197 "stages": stages.iter().map(|s| serde_json::json!({
1199 "duration": s.duration,
1200 "target": s.target,
1201 })).collect::<Vec<_>>(),
1202 "threshold_percentile": self.threshold_percentile,
1203 "threshold_ms": self.threshold_ms,
1204 "max_error_rate": self.max_error_rate,
1205 "headers": headers_json,
1206 "dynamic_imports": Vec::<String>::new(),
1207 "dynamic_globals": Vec::<String>::new(),
1208 });
1209
1210 let script = handlebars
1211 .render_template(template, &data)
1212 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1213
1214 TerminalReporter::print_progress("Validating CRUD flow script...");
1216 let validation_errors = K6ScriptGenerator::validate_script(&script);
1217 if !validation_errors.is_empty() {
1218 TerminalReporter::print_error("CRUD flow script validation failed");
1219 for error in &validation_errors {
1220 eprintln!(" {}", error);
1221 }
1222 return Err(BenchError::Other(format!(
1223 "CRUD flow script validation failed with {} error(s)",
1224 validation_errors.len()
1225 )));
1226 }
1227
1228 TerminalReporter::print_success("CRUD flow script generated");
1229
1230 let script_path = if let Some(output) = &self.script_output {
1232 output.clone()
1233 } else {
1234 self.output.join("k6-crud-flow-script.js")
1235 };
1236
1237 std::fs::create_dir_all(script_path.parent().unwrap())?;
1238 std::fs::write(&script_path, &script)?;
1239 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1240
1241 if self.generate_only {
1242 println!("\nScript generated successfully. Run it with:");
1243 println!(" k6 run {}", script_path.display());
1244 return Ok(());
1245 }
1246
1247 TerminalReporter::print_progress("Executing CRUD flow test...");
1249 let executor = K6Executor::new()?;
1250 std::fs::create_dir_all(&self.output)?;
1251
1252 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1253
1254 let duration_secs = Self::parse_duration(&self.duration)?;
1255 TerminalReporter::print_summary(&results, duration_secs);
1256
1257 Ok(())
1258 }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264
1265 #[test]
1266 fn test_parse_duration() {
1267 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
1268 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
1269 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
1270 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
1271 }
1272
1273 #[test]
1274 fn test_parse_duration_invalid() {
1275 assert!(BenchCommand::parse_duration("invalid").is_err());
1276 assert!(BenchCommand::parse_duration("30x").is_err());
1277 }
1278
1279 #[test]
1280 fn test_parse_headers() {
1281 let cmd = BenchCommand {
1282 spec: vec![PathBuf::from("test.yaml")],
1283 spec_dir: None,
1284 merge_conflicts: "error".to_string(),
1285 spec_mode: "merge".to_string(),
1286 dependency_config: None,
1287 target: "http://localhost".to_string(),
1288 duration: "1m".to_string(),
1289 vus: 10,
1290 scenario: "ramp-up".to_string(),
1291 operations: None,
1292 exclude_operations: None,
1293 auth: None,
1294 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
1295 output: PathBuf::from("output"),
1296 generate_only: false,
1297 script_output: None,
1298 threshold_percentile: "p(95)".to_string(),
1299 threshold_ms: 500,
1300 max_error_rate: 0.05,
1301 verbose: false,
1302 skip_tls_verify: false,
1303 targets_file: None,
1304 max_concurrency: None,
1305 results_format: "both".to_string(),
1306 params_file: None,
1307 crud_flow: false,
1308 flow_config: None,
1309 extract_fields: None,
1310 parallel_create: None,
1311 data_file: None,
1312 data_distribution: "unique-per-vu".to_string(),
1313 data_mappings: None,
1314 error_rate: None,
1315 error_types: None,
1316 security_test: false,
1317 security_payloads: None,
1318 security_categories: None,
1319 security_target_fields: None,
1320 };
1321
1322 let headers = cmd.parse_headers().unwrap();
1323 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
1324 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
1325 }
1326
1327 #[test]
1328 fn test_get_spec_display_name() {
1329 let cmd = BenchCommand {
1330 spec: vec![PathBuf::from("test.yaml")],
1331 spec_dir: None,
1332 merge_conflicts: "error".to_string(),
1333 spec_mode: "merge".to_string(),
1334 dependency_config: None,
1335 target: "http://localhost".to_string(),
1336 duration: "1m".to_string(),
1337 vus: 10,
1338 scenario: "ramp-up".to_string(),
1339 operations: None,
1340 exclude_operations: None,
1341 auth: None,
1342 headers: None,
1343 output: PathBuf::from("output"),
1344 generate_only: false,
1345 script_output: None,
1346 threshold_percentile: "p(95)".to_string(),
1347 threshold_ms: 500,
1348 max_error_rate: 0.05,
1349 verbose: false,
1350 skip_tls_verify: false,
1351 targets_file: None,
1352 max_concurrency: None,
1353 results_format: "both".to_string(),
1354 params_file: None,
1355 crud_flow: false,
1356 flow_config: None,
1357 extract_fields: None,
1358 parallel_create: None,
1359 data_file: None,
1360 data_distribution: "unique-per-vu".to_string(),
1361 data_mappings: None,
1362 error_rate: None,
1363 error_types: None,
1364 security_test: false,
1365 security_payloads: None,
1366 security_categories: None,
1367 security_target_fields: None,
1368 };
1369
1370 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
1371
1372 let cmd_multi = BenchCommand {
1374 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
1375 spec_dir: None,
1376 merge_conflicts: "error".to_string(),
1377 spec_mode: "merge".to_string(),
1378 dependency_config: None,
1379 target: "http://localhost".to_string(),
1380 duration: "1m".to_string(),
1381 vus: 10,
1382 scenario: "ramp-up".to_string(),
1383 operations: None,
1384 exclude_operations: None,
1385 auth: None,
1386 headers: None,
1387 output: PathBuf::from("output"),
1388 generate_only: false,
1389 script_output: None,
1390 threshold_percentile: "p(95)".to_string(),
1391 threshold_ms: 500,
1392 max_error_rate: 0.05,
1393 verbose: false,
1394 skip_tls_verify: false,
1395 targets_file: None,
1396 max_concurrency: None,
1397 results_format: "both".to_string(),
1398 params_file: None,
1399 crud_flow: false,
1400 flow_config: None,
1401 extract_fields: None,
1402 parallel_create: None,
1403 data_file: None,
1404 data_distribution: "unique-per-vu".to_string(),
1405 data_mappings: None,
1406 error_rate: None,
1407 error_types: None,
1408 security_test: false,
1409 security_payloads: None,
1410 security_categories: None,
1411 security_target_fields: None,
1412 };
1413
1414 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
1415 }
1416}