mockforge_bench/
command.rs

1//! Bench command implementation
2
3use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::error::{BenchError, Result};
6use crate::executor::K6Executor;
7use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator, InvalidDataType};
8use crate::k6_gen::{K6Config, K6ScriptGenerator};
9use crate::mock_integration::{
10    MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
11};
12use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
13use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
14use crate::param_overrides::ParameterOverrides;
15use crate::reporter::TerminalReporter;
16use crate::request_gen::RequestGenerator;
17use crate::scenarios::LoadScenario;
18use crate::security_payloads::{
19    SecurityCategory, 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
34/// Bench command configuration
35pub struct BenchCommand {
36    /// OpenAPI spec file(s) - can specify multiple
37    pub spec: Vec<PathBuf>,
38    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
39    pub spec_dir: Option<PathBuf>,
40    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
41    pub merge_conflicts: String,
42    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
43    pub spec_mode: String,
44    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
45    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    /// Exclude operations from testing (comma-separated)
52    ///
53    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
54    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    /// Optional file containing multiple targets
66    pub targets_file: Option<PathBuf>,
67    /// Maximum number of parallel executions (for multi-target mode)
68    pub max_concurrency: Option<u32>,
69    /// Results format: "per-target", "aggregated", or "both"
70    pub results_format: String,
71    /// Optional file containing parameter value overrides (JSON or YAML)
72    ///
73    /// Allows users to provide custom values for path parameters, query parameters,
74    /// headers, and request bodies instead of auto-generated placeholder values.
75    pub params_file: Option<PathBuf>,
76
77    // === CRUD Flow Options ===
78    /// Enable CRUD flow mode
79    pub crud_flow: bool,
80    /// Custom CRUD flow configuration file
81    pub flow_config: Option<PathBuf>,
82    /// Fields to extract from responses
83    pub extract_fields: Option<String>,
84
85    // === Parallel Execution Options ===
86    /// Number of resources to create in parallel
87    pub parallel_create: Option<u32>,
88
89    // === Data-Driven Testing Options ===
90    /// Test data file (CSV or JSON)
91    pub data_file: Option<PathBuf>,
92    /// Data distribution strategy
93    pub data_distribution: String,
94    /// Data column to field mappings
95    pub data_mappings: Option<String>,
96
97    // === Invalid Data Testing Options ===
98    /// Percentage of requests with invalid data
99    pub error_rate: Option<f64>,
100    /// Types of invalid data to generate
101    pub error_types: Option<String>,
102
103    // === Security Testing Options ===
104    /// Enable security testing
105    pub security_test: bool,
106    /// Custom security payloads file
107    pub security_payloads: Option<PathBuf>,
108    /// Security test categories
109    pub security_categories: Option<String>,
110    /// Fields to target for security injection
111    pub security_target_fields: Option<String>,
112}
113
114impl BenchCommand {
115    /// Load and merge specs from --spec files and --spec-dir
116    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
117        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
118
119        // Load specs from --spec flags
120        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        // Load specs from --spec-dir if provided
128        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 only one spec, return it directly (extract just the OpenApiSpec)
142        if all_specs.len() == 1 {
143            return Ok(all_specs.into_iter().next().unwrap().1);
144        }
145
146        // Merge multiple specs
147        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    /// Get a display name for the spec(s)
158    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    /// Execute the bench command
171    pub async fn execute(&self) -> Result<()> {
172        // Check if we're in multi-target mode
173        if let Some(targets_file) = &self.targets_file {
174            return self.execute_multi_target(targets_file).await;
175        }
176
177        // Check if we're in sequential spec mode (for dependency handling)
178        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
179            return self.execute_sequential_specs().await;
180        }
181
182        // Single target mode (existing behavior)
183        // Print header
184        TerminalReporter::print_header(
185            &self.get_spec_display_name(),
186            &self.target,
187            0, // Will be updated later
188            &self.scenario,
189            Self::parse_duration(&self.duration)?,
190        );
191
192        // Validate k6 installation
193        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        // Load and parse spec(s)
202        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        // Check for mock server integration
215        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        // Check for CRUD flow mode
221        if self.crud_flow {
222            return self.execute_crud_flow(&parser).await;
223        }
224
225        // Get operations
226        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        // Apply exclusions if provided
234        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        // Load parameter overrides if provided
253        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        // Generate request templates
267        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        // Parse headers
280        let custom_headers = self.parse_headers()?;
281
282        // Generate k6 script
283        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        // Check if any advanced features are enabled
305        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        // Enhance script with advanced features
311        if has_advanced_features {
312            script = self.generate_enhanced_script(&script)?;
313        }
314
315        // Add mock server integration code
316        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            // Insert mock server code after imports
322            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        // Validate the generated script
334        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        // Write script to file
349        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 generate-only mode, exit here
360        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        // Execute k6
367        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        // Print results
375        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    /// Execute multi-target bench testing
384    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        // Determine max concurrency
395        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
396        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
397
398        // Print header for multi-target mode
399        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        // Create parallel executor
408        let executor = ParallelExecutor::new(
409            BenchCommand {
410                // Clone all fields except targets_file (we don't need it in the executor)
411                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(), // Not used in multi-target mode, but kept for compatibility
417                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        // Execute all targets
455        let aggregated_results = executor.execute_all().await?;
456
457        // Organize and report results
458        self.report_multi_target_results(&aggregated_results)?;
459
460        Ok(())
461    }
462
463    /// Report results for multi-target execution
464    fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
465        // Print summary
466        TerminalReporter::print_multi_target_summary(results);
467
468        // Save aggregated summary if requested
469        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    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
519    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            // Try parsing as seconds without suffix
536            duration
537                .parse::<u64>()
538                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
539        }
540    }
541
542    /// Parse headers from command line format (Key:Value,Key2:Value2)
543    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    /// Build mock server integration configuration
563    async fn build_mock_config(&self) -> MockIntegrationConfig {
564        // Check if target looks like a mock server
565        if MockServerDetector::looks_like_mock_server(&self.target) {
566            // Try to detect if it's actually a MockForge server
567            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    /// Build CRUD flow configuration
581    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
582        if !self.crud_flow {
583            return None;
584        }
585
586        // If flow_config file is provided, load it
587        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        // Parse extract fields
600        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(), // Will be auto-detected
608            default_extract_fields: extract_fields,
609        })
610    }
611
612    /// Build data-driven testing configuration
613    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    /// Build invalid data testing configuration
634    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    /// Build security testing configuration
651    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    /// Build parallel execution configuration
686    fn build_parallel_config(&self) -> Option<ParallelConfig> {
687        let count = self.parallel_create?;
688
689        Some(ParallelConfig::new(count))
690    }
691
692    /// Generate enhanced k6 script with advanced features
693    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        // Add data-driven testing code
698        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        // Add invalid data generation code
706        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        // Add security testing code
723        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        // Add parallel execution code
741        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        // Insert additional code after the imports section
752        if !additional_code.is_empty() {
753            // Find the end of the import section
754            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    /// Execute specs sequentially with dependency ordering and value passing
766    async fn execute_sequential_specs(&self) -> Result<()> {
767        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
768
769        // Load all specs (without merging)
770        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        // Load dependency config or auto-detect
795        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                // Auto-detect if config doesn't specify order
801                self.detect_and_sort_specs(&all_specs)?
802            } else {
803                // Use configured order
804                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
805            }
806        } else {
807            // Auto-detect dependencies
808            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        // Execute each spec in order
821        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            // Find the spec in our loaded specs
835            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            // Execute this spec with any extracted values from previous specs
844            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
845
846            // Merge extracted values for the next spec
847            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    /// Detect dependencies and return topologically sorted spec paths
867    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    /// Execute a single spec and extract values for dependent specs
896    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        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
905        if self.crud_flow {
906            // Execute CRUD flow and extract values
907            self.execute_crud_flow_with_extraction(&parser, spec_name).await
908        } else {
909            // Execute standard benchmark (no value extraction in non-CRUD mode)
910            self.execute_standard_spec(&parser, spec_name).await?;
911            Ok(ExtractedValues::new())
912        }
913    }
914
915    /// Execute CRUD flow with value extraction for sequential mode
916    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        // Generate and execute the CRUD flow script
936        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        // Generate stages from scenario
943        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        // Build headers JSON string for the template
949        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                        // Parse operation to get method and path
966                        let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
967                        let method = if parts.len() >= 1 {
968                            let m = parts[0].to_lowercase();
969                            // k6 uses 'del' for DELETE
970                            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                        // POST, PUT, PATCH typically have bodies
977                        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!({}),  // Default empty body for CRUD operations
990                            "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            // Add missing template fields
1002            "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        // Write and execute script
1019        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        // For now, return empty extracted values
1034        // TODO: Parse k6 output to extract actual values
1035        Ok(ExtractedValues::new())
1036    }
1037
1038    /// Execute standard (non-CRUD) spec benchmark
1039    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        // Generate request templates
1062        let templates: Vec<_> = operations
1063            .iter()
1064            .map(RequestGenerator::generate_template)
1065            .collect::<Result<Vec<_>>>()?;
1066
1067        // Parse headers
1068        let custom_headers = self.parse_headers()?;
1069
1070        // Generate k6 script
1071        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        // Write and execute script
1091        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    /// Execute CRUD flow testing mode
1108    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        // Generate CRUD flow script
1131        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        // Generate stages from scenario
1138        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        // Build headers JSON string for the template
1144        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                // Sanitize flow name for use as JavaScript variable and k6 metric names
1155                let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1156                serde_json::json!({
1157                    "name": sanitized_name.clone(),  // Use sanitized name for variable names
1158                    "display_name": f.name,          // Keep original for comments/display
1159                    "base_path": f.base_path,
1160                    "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1161                        // Parse operation to get method and path
1162                        let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1163                        let method = if parts.len() >= 1 {
1164                            let m = parts[0].to_lowercase();
1165                            // k6 uses 'del' for DELETE
1166                            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                        // POST, PUT, PATCH typically have bodies
1173                        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!({}),  // Default empty body for CRUD operations
1186                            "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            // Add missing template fields
1198            "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        // Validate the generated CRUD flow script
1215        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        // Write and execute script
1231        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        // Execute k6
1248        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        // Test multiple specs
1373        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}