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, 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
35pub struct BenchCommand {
37 pub spec: Vec<PathBuf>,
39 pub spec_dir: Option<PathBuf>,
41 pub merge_conflicts: String,
43 pub spec_mode: String,
45 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 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 pub targets_file: Option<PathBuf>,
68 pub max_concurrency: Option<u32>,
70 pub results_format: String,
72 pub params_file: Option<PathBuf>,
77
78 pub crud_flow: bool,
81 pub flow_config: Option<PathBuf>,
83 pub extract_fields: Option<String>,
85
86 pub parallel_create: Option<u32>,
89
90 pub data_file: Option<PathBuf>,
93 pub data_distribution: String,
95 pub data_mappings: Option<String>,
97 pub per_uri_control: bool,
99
100 pub error_rate: Option<f64>,
103 pub error_types: Option<String>,
105
106 pub security_test: bool,
109 pub security_payloads: Option<PathBuf>,
111 pub security_categories: Option<String>,
113 pub security_target_fields: Option<String>,
115
116 pub wafbench_dir: Option<String>,
119}
120
121impl BenchCommand {
122 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
124 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
125
126 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 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 all_specs.len() == 1 {
150 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
152 }
153
154 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 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 pub async fn execute(&self) -> Result<()> {
180 if let Some(targets_file) = &self.targets_file {
182 return self.execute_multi_target(targets_file).await;
183 }
184
185 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
187 return self.execute_sequential_specs().await;
188 }
189
190 TerminalReporter::print_header(
193 &self.get_spec_display_name(),
194 &self.target,
195 0, &self.scenario,
197 Self::parse_duration(&self.duration)?,
198 );
199
200 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 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 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 if self.crud_flow {
230 return self.execute_crud_flow(&parser).await;
231 }
232
233 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 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 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 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 let custom_headers = self.parse_headers()?;
289
290 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 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 if has_advanced_features {
320 script = self.generate_enhanced_script(&script)?;
321 }
322
323 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 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 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 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 self.generate_only {
371 println!("\nScript generated successfully. Run it with:");
372 println!(" k6 run {}", script_path.display());
373 return Ok(());
374 }
375
376 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 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 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 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
406 let max_concurrency = max_concurrency.min(num_targets); 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 let executor = ParallelExecutor::new(
419 BenchCommand {
420 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(), 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 let aggregated_results = executor.execute_all().await?;
468
469 self.report_multi_target_results(&aggregated_results)?;
471
472 Ok(())
473 }
474
475 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
477 TerminalReporter::print_multi_target_summary(results);
479
480 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 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 duration
549 .parse::<u64>()
550 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
551 }
552 }
553
554 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 async fn build_mock_config(&self) -> MockIntegrationConfig {
576 if MockServerDetector::looks_like_mock_server(&self.target) {
578 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 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
594 if !self.crud_flow {
595 return None;
596 }
597
598 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 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(), default_extract_fields: extract_fields,
621 })
622 }
623
624 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 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 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 fn build_parallel_config(&self) -> Option<ParallelConfig> {
701 let count = self.parallel_create?;
702
703 Some(ParallelConfig::new(count))
704 }
705
706 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 for (category, count) in &stats.by_category {
736 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
737 }
738
739 for error in &stats.parse_errors {
741 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
742 }
743
744 loader.to_security_payloads()
745 }
746
747 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 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 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 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 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 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 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 if !additional_code.is_empty() {
830 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 async fn execute_sequential_specs(&self) -> Result<()> {
844 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
845
846 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 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 self.detect_and_sort_specs(&all_specs)?
879 } else {
880 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
882 }
883 } else {
884 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 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 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 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
922
923 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 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 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 if self.crud_flow {
983 self.execute_crud_flow_with_extraction(&parser, spec_name).await
985 } else {
986 self.execute_standard_spec(&parser, spec_name).await?;
988 Ok(ExtractedValues::new())
989 }
990 }
991
992 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 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 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 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 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 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 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 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1067
1068 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 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_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 "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 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 Ok(ExtractedValues::new())
1139 }
1140
1141 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 let templates: Vec<_> = operations
1166 .iter()
1167 .map(RequestGenerator::generate_template)
1168 .collect::<Result<Vec<_>>>()?;
1169
1170 let custom_headers = self.parse_headers()?;
1172
1173 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 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 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 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 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 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 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 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1272 serde_json::json!({
1273 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1276 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1277 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 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 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1295
1296 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 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_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 "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 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 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 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 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}