Skip to main content

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::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11    MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21    SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24    topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_openapi::multi_spec::{
30    load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37/// Parse a comma-separated header string into a `HashMap`.
38///
39/// Format: `Key:Value,Key2:Value2`
40///
41/// **Known limitation**: Header values containing commas will be incorrectly
42/// split. Cookie headers with semicolons work fine, but Cookie values with
43/// commas (e.g. `expires=Thu, 01 Jan 2099`) will break.
44pub fn parse_header_string(input: &str) -> Result<HashMap<String, String>> {
45    let mut headers = HashMap::new();
46
47    for pair in input.split(',') {
48        let parts: Vec<&str> = pair.splitn(2, ':').collect();
49        if parts.len() != 2 {
50            return Err(BenchError::Other(format!(
51                "Invalid header format: '{}'. Expected 'Key:Value'",
52                pair
53            )));
54        }
55        headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
56    }
57
58    Ok(headers)
59}
60
61/// Bench command configuration
62pub struct BenchCommand {
63    /// OpenAPI spec file(s) - can specify multiple
64    pub spec: Vec<PathBuf>,
65    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
66    pub spec_dir: Option<PathBuf>,
67    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
68    pub merge_conflicts: String,
69    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
70    pub spec_mode: String,
71    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
72    pub dependency_config: Option<PathBuf>,
73    pub target: String,
74    /// API base path prefix (e.g., "/api" or "/v2/api")
75    /// If None, extracts from OpenAPI spec's servers URL
76    pub base_path: Option<String>,
77    pub duration: String,
78    pub vus: u32,
79    pub scenario: String,
80    pub operations: Option<String>,
81    /// Exclude operations from testing (comma-separated)
82    ///
83    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
84    pub exclude_operations: Option<String>,
85    pub auth: Option<String>,
86    pub headers: Option<String>,
87    pub output: PathBuf,
88    pub generate_only: bool,
89    pub script_output: Option<PathBuf>,
90    pub threshold_percentile: String,
91    pub threshold_ms: u64,
92    pub max_error_rate: f64,
93    pub verbose: bool,
94    pub skip_tls_verify: bool,
95    /// When true, set `Transfer-Encoding: chunked` on every k6 request body so
96    /// the server experiences chunked-encoded traffic. See
97    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats — k6's Go
98    /// transport may still send Content-Length in some cases.
99    pub chunked_request_bodies: bool,
100    /// Optional file containing multiple targets
101    pub targets_file: Option<PathBuf>,
102    /// Maximum number of parallel executions (for multi-target mode)
103    pub max_concurrency: Option<u32>,
104    /// Results format: "per-target", "aggregated", or "both"
105    pub results_format: String,
106    /// Optional file containing parameter value overrides (JSON or YAML)
107    ///
108    /// Allows users to provide custom values for path parameters, query parameters,
109    /// headers, and request bodies instead of auto-generated placeholder values.
110    pub params_file: Option<PathBuf>,
111
112    // === CRUD Flow Options ===
113    /// Enable CRUD flow mode
114    pub crud_flow: bool,
115    /// Custom CRUD flow configuration file
116    pub flow_config: Option<PathBuf>,
117    /// Fields to extract from responses
118    pub extract_fields: Option<String>,
119
120    // === Parallel Execution Options ===
121    /// Number of resources to create in parallel
122    pub parallel_create: Option<u32>,
123
124    // === Data-Driven Testing Options ===
125    /// Test data file (CSV or JSON)
126    pub data_file: Option<PathBuf>,
127    /// Data distribution strategy
128    pub data_distribution: String,
129    /// Data column to field mappings
130    pub data_mappings: Option<String>,
131    /// Enable per-URI control mode (each row specifies method, uri, body, etc.)
132    pub per_uri_control: bool,
133
134    // === Invalid Data Testing Options ===
135    /// Percentage of requests with invalid data
136    pub error_rate: Option<f64>,
137    /// Types of invalid data to generate
138    pub error_types: Option<String>,
139
140    // === Security Testing Options ===
141    /// Enable security testing
142    pub security_test: bool,
143    /// Custom security payloads file
144    pub security_payloads: Option<PathBuf>,
145    /// Security test categories
146    pub security_categories: Option<String>,
147    /// Fields to target for security injection
148    pub security_target_fields: Option<String>,
149
150    // === WAFBench Integration ===
151    /// WAFBench test directory or glob pattern for loading CRS attack patterns
152    pub wafbench_dir: Option<String>,
153    /// Cycle through ALL WAFBench payloads instead of random sampling
154    pub wafbench_cycle_all: bool,
155
156    // === OpenAPI 3.0.0 Conformance Testing ===
157    /// Enable conformance testing mode
158    pub conformance: bool,
159    /// API key for conformance security tests
160    pub conformance_api_key: Option<String>,
161    /// Basic auth credentials for conformance security tests (user:pass)
162    pub conformance_basic_auth: Option<String>,
163    /// Conformance report output file
164    pub conformance_report: PathBuf,
165    /// Conformance categories to test (comma-separated, e.g. "parameters,security")
166    pub conformance_categories: Option<String>,
167    /// Conformance report format: "json" or "sarif"
168    pub conformance_report_format: String,
169    /// Custom headers to inject into every conformance request (for authentication).
170    /// Each entry is "Header-Name: value" format.
171    pub conformance_headers: Vec<String>,
172    /// When true, test ALL operations for method/response/body categories
173    /// instead of just one representative per feature check.
174    pub conformance_all_operations: bool,
175    /// Optional YAML file with custom conformance checks
176    pub conformance_custom: Option<PathBuf>,
177    /// Delay in milliseconds between consecutive conformance requests.
178    /// Useful when testing against rate-limited APIs.
179    pub conformance_delay_ms: u64,
180    /// Use k6 for conformance test execution instead of the native Rust executor
181    pub use_k6: bool,
182    /// Regex filter for custom conformance checks — only checks whose name or
183    /// path matches the pattern are included. Example: "wafcrs|ssl" to test
184    /// only checks with "wafcrs" or "ssl" in the name/path.
185    pub conformance_custom_filter: Option<String>,
186    /// When true, export all request/response pairs to
187    /// `conformance-requests.json` in the output directory.
188    pub export_requests: bool,
189    /// When true, validate each request against the OpenAPI spec and report
190    /// violations to `conformance-request-violations.json`.
191    pub validate_requests: bool,
192
193    // === OWASP API Security Top 10 Testing ===
194    /// Enable OWASP API Security Top 10 testing mode
195    pub owasp_api_top10: bool,
196    /// OWASP API categories to test (comma-separated)
197    pub owasp_categories: Option<String>,
198    /// Authorization header name for OWASP auth tests
199    pub owasp_auth_header: String,
200    /// Valid authorization token for OWASP baseline requests
201    pub owasp_auth_token: Option<String>,
202    /// File containing admin/privileged paths to test
203    pub owasp_admin_paths: Option<PathBuf>,
204    /// Fields containing resource IDs for BOLA testing
205    pub owasp_id_fields: Option<String>,
206    /// OWASP report output file
207    pub owasp_report: Option<PathBuf>,
208    /// OWASP report format (json, sarif)
209    pub owasp_report_format: String,
210    /// Number of iterations per VU for OWASP tests (default: 1)
211    pub owasp_iterations: u32,
212}
213
214impl BenchCommand {
215    /// Load and merge specs from --spec files and --spec-dir
216    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
217        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
218
219        // Load specs from --spec flags
220        if !self.spec.is_empty() {
221            let specs = load_specs_from_files(self.spec.clone())
222                .await
223                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
224            all_specs.extend(specs);
225        }
226
227        // Load specs from --spec-dir if provided
228        if let Some(spec_dir) = &self.spec_dir {
229            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
230                BenchError::Other(format!("Failed to load specs from directory: {}", e))
231            })?;
232            all_specs.extend(dir_specs);
233        }
234
235        if all_specs.is_empty() {
236            return Err(BenchError::Other(
237                "No spec files provided. Use --spec or --spec-dir.".to_string(),
238            ));
239        }
240
241        // If only one spec, return it directly (extract just the OpenApiSpec)
242        if all_specs.len() == 1 {
243            // Safe to unwrap because we just checked len() == 1
244            return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
245        }
246
247        // Merge multiple specs
248        let conflict_strategy = match self.merge_conflicts.as_str() {
249            "first" => ConflictStrategy::First,
250            "last" => ConflictStrategy::Last,
251            _ => ConflictStrategy::Error,
252        };
253
254        merge_specs(all_specs, conflict_strategy)
255            .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
256    }
257
258    /// Get a display name for the spec(s)
259    fn get_spec_display_name(&self) -> String {
260        if self.spec.len() == 1 {
261            self.spec[0].to_string_lossy().to_string()
262        } else if !self.spec.is_empty() {
263            format!("{} spec files", self.spec.len())
264        } else if let Some(dir) = &self.spec_dir {
265            format!("specs from {}", dir.display())
266        } else {
267            "no specs".to_string()
268        }
269    }
270
271    /// Execute the bench command
272    pub async fn execute(&self) -> Result<()> {
273        // Check if we're in multi-target mode
274        if let Some(targets_file) = &self.targets_file {
275            if self.conformance {
276                return self.execute_multi_target_conformance(targets_file).await;
277            }
278            return self.execute_multi_target(targets_file).await;
279        }
280
281        // Check if we're in sequential spec mode (for dependency handling)
282        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
283            return self.execute_sequential_specs().await;
284        }
285
286        // Single target mode (existing behavior)
287        // Print header
288        TerminalReporter::print_header(
289            &self.get_spec_display_name(),
290            &self.target,
291            0, // Will be updated later
292            &self.scenario,
293            Self::parse_duration(&self.duration)?,
294        );
295
296        // Validate k6 installation
297        if !K6Executor::is_k6_installed() {
298            TerminalReporter::print_error("k6 is not installed");
299            TerminalReporter::print_warning(
300                "Install k6 from: https://k6.io/docs/get-started/installation/",
301            );
302            return Err(BenchError::K6NotFound);
303        }
304
305        // Check for conformance testing mode (before spec loading — conformance doesn't need a user spec)
306        if self.conformance {
307            return self.execute_conformance_test().await;
308        }
309
310        // Load and parse spec(s)
311        TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
312        let merged_spec = self.load_and_merge_specs().await?;
313        let parser = SpecParser::from_spec(merged_spec);
314        if self.spec.len() > 1 || self.spec_dir.is_some() {
315            TerminalReporter::print_success(&format!(
316                "Loaded and merged {} specification(s)",
317                self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
318            ));
319        } else {
320            TerminalReporter::print_success("Specification loaded");
321        }
322
323        // Check for mock server integration
324        let mock_config = self.build_mock_config().await;
325        if mock_config.is_mock_server {
326            TerminalReporter::print_progress("Mock server integration enabled");
327        }
328
329        // Check for CRUD flow mode
330        if self.crud_flow {
331            return self.execute_crud_flow(&parser).await;
332        }
333
334        // Check for OWASP API Top 10 testing mode
335        if self.owasp_api_top10 {
336            return self.execute_owasp_test(&parser).await;
337        }
338
339        // Get operations
340        TerminalReporter::print_progress("Extracting API operations...");
341        let mut operations = if let Some(filter) = &self.operations {
342            parser.filter_operations(filter)?
343        } else {
344            parser.get_operations()
345        };
346
347        // Apply exclusions if provided
348        if let Some(exclude) = &self.exclude_operations {
349            let before_count = operations.len();
350            operations = parser.exclude_operations(operations, exclude)?;
351            let excluded_count = before_count - operations.len();
352            if excluded_count > 0 {
353                TerminalReporter::print_progress(&format!(
354                    "Excluded {} operations matching '{}'",
355                    excluded_count, exclude
356                ));
357            }
358        }
359
360        if operations.is_empty() {
361            return Err(BenchError::Other("No operations found in spec".to_string()));
362        }
363
364        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
365
366        // Load parameter overrides if provided
367        let param_overrides = if let Some(params_file) = &self.params_file {
368            TerminalReporter::print_progress("Loading parameter overrides...");
369            let overrides = ParameterOverrides::from_file(params_file)?;
370            TerminalReporter::print_success(&format!(
371                "Loaded parameter overrides ({} operation-specific, {} defaults)",
372                overrides.operations.len(),
373                if overrides.defaults.is_empty() { 0 } else { 1 }
374            ));
375            Some(overrides)
376        } else {
377            None
378        };
379
380        // Generate request templates
381        TerminalReporter::print_progress("Generating request templates...");
382        let templates: Vec<_> = operations
383            .iter()
384            .map(|op| {
385                let op_overrides = param_overrides.as_ref().map(|po| {
386                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
387                });
388                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
389            })
390            .collect::<Result<Vec<_>>>()?;
391        TerminalReporter::print_success("Request templates generated");
392
393        // Parse headers
394        let custom_headers = self.parse_headers()?;
395
396        // Resolve base path (CLI option takes priority over spec's servers URL)
397        let base_path = self.resolve_base_path(&parser);
398        if let Some(ref bp) = base_path {
399            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
400        }
401
402        // Generate k6 script
403        TerminalReporter::print_progress("Generating k6 load test script...");
404        let scenario =
405            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
406
407        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
408
409        let k6_config = K6Config {
410            target_url: self.target.clone(),
411            base_path,
412            scenario,
413            duration_secs: Self::parse_duration(&self.duration)?,
414            max_vus: self.vus,
415            threshold_percentile: self.threshold_percentile.clone(),
416            threshold_ms: self.threshold_ms,
417            max_error_rate: self.max_error_rate,
418            auth_header: self.auth.clone(),
419            custom_headers,
420            skip_tls_verify: self.skip_tls_verify,
421            security_testing_enabled,
422            chunked_request_bodies: self.chunked_request_bodies,
423        };
424
425        let generator = K6ScriptGenerator::new(k6_config, templates);
426        let mut script = generator.generate()?;
427        TerminalReporter::print_success("k6 script generated");
428
429        // Check if any advanced features are enabled
430        let has_advanced_features = self.data_file.is_some()
431            || self.error_rate.is_some()
432            || self.security_test
433            || self.parallel_create.is_some()
434            || self.wafbench_dir.is_some();
435
436        // Enhance script with advanced features
437        if has_advanced_features {
438            script = self.generate_enhanced_script(&script)?;
439        }
440
441        // Add mock server integration code
442        if mock_config.is_mock_server {
443            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
444            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
445            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
446
447            // Insert mock server code after imports
448            if let Some(import_end) = script.find("export const options") {
449                script.insert_str(
450                    import_end,
451                    &format!(
452                        "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
453                        helper_code, setup_code, teardown_code
454                    ),
455                );
456            }
457        }
458
459        // Validate the generated script
460        TerminalReporter::print_progress("Validating k6 script...");
461        let validation_errors = K6ScriptGenerator::validate_script(&script);
462        if !validation_errors.is_empty() {
463            TerminalReporter::print_error("Script validation failed");
464            for error in &validation_errors {
465                eprintln!("  {}", error);
466            }
467            return Err(BenchError::Other(format!(
468                "Generated k6 script has {} validation error(s). Please check the output above.",
469                validation_errors.len()
470            )));
471        }
472        TerminalReporter::print_success("Script validation passed");
473
474        // Write script to file
475        let script_path = if let Some(output) = &self.script_output {
476            output.clone()
477        } else {
478            self.output.join("k6-script.js")
479        };
480
481        if let Some(parent) = script_path.parent() {
482            std::fs::create_dir_all(parent)?;
483        }
484        std::fs::write(&script_path, &script)?;
485        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
486
487        // If generate-only mode, exit here
488        if self.generate_only {
489            println!("\nScript generated successfully. Run it with:");
490            println!("  k6 run {}", script_path.display());
491            return Ok(());
492        }
493
494        // Execute k6
495        TerminalReporter::print_progress("Executing load test...");
496        let executor = K6Executor::new()?;
497
498        std::fs::create_dir_all(&self.output)?;
499
500        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
501
502        // Print results
503        let duration_secs = Self::parse_duration(&self.duration)?;
504        TerminalReporter::print_summary(&results, duration_secs);
505
506        println!("\nResults saved to: {}", self.output.display());
507
508        Ok(())
509    }
510
511    /// Execute multi-target bench testing
512    async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
513        TerminalReporter::print_progress("Parsing targets file...");
514        let targets = parse_targets_file(targets_file)?;
515        let num_targets = targets.len();
516        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
517
518        if targets.is_empty() {
519            return Err(BenchError::Other("No targets found in file".to_string()));
520        }
521
522        // Determine max concurrency
523        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
524        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
525
526        // Print header for multi-target mode
527        TerminalReporter::print_header(
528            &self.get_spec_display_name(),
529            &format!("{} targets", num_targets),
530            0,
531            &self.scenario,
532            Self::parse_duration(&self.duration)?,
533        );
534
535        // Create parallel executor
536        let executor = ParallelExecutor::new(
537            BenchCommand {
538                // Clone all fields except targets_file (we don't need it in the executor)
539                spec: self.spec.clone(),
540                spec_dir: self.spec_dir.clone(),
541                merge_conflicts: self.merge_conflicts.clone(),
542                spec_mode: self.spec_mode.clone(),
543                dependency_config: self.dependency_config.clone(),
544                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
545                base_path: self.base_path.clone(),
546                duration: self.duration.clone(),
547                vus: self.vus,
548                scenario: self.scenario.clone(),
549                operations: self.operations.clone(),
550                exclude_operations: self.exclude_operations.clone(),
551                auth: self.auth.clone(),
552                headers: self.headers.clone(),
553                output: self.output.clone(),
554                generate_only: self.generate_only,
555                script_output: self.script_output.clone(),
556                threshold_percentile: self.threshold_percentile.clone(),
557                threshold_ms: self.threshold_ms,
558                max_error_rate: self.max_error_rate,
559                verbose: self.verbose,
560                skip_tls_verify: self.skip_tls_verify,
561                chunked_request_bodies: self.chunked_request_bodies,
562                targets_file: None,
563                max_concurrency: None,
564                results_format: self.results_format.clone(),
565                params_file: self.params_file.clone(),
566                crud_flow: self.crud_flow,
567                flow_config: self.flow_config.clone(),
568                extract_fields: self.extract_fields.clone(),
569                parallel_create: self.parallel_create,
570                data_file: self.data_file.clone(),
571                data_distribution: self.data_distribution.clone(),
572                data_mappings: self.data_mappings.clone(),
573                per_uri_control: self.per_uri_control,
574                error_rate: self.error_rate,
575                error_types: self.error_types.clone(),
576                security_test: self.security_test,
577                security_payloads: self.security_payloads.clone(),
578                security_categories: self.security_categories.clone(),
579                security_target_fields: self.security_target_fields.clone(),
580                wafbench_dir: self.wafbench_dir.clone(),
581                wafbench_cycle_all: self.wafbench_cycle_all,
582                owasp_api_top10: self.owasp_api_top10,
583                owasp_categories: self.owasp_categories.clone(),
584                owasp_auth_header: self.owasp_auth_header.clone(),
585                owasp_auth_token: self.owasp_auth_token.clone(),
586                owasp_admin_paths: self.owasp_admin_paths.clone(),
587                owasp_id_fields: self.owasp_id_fields.clone(),
588                owasp_report: self.owasp_report.clone(),
589                owasp_report_format: self.owasp_report_format.clone(),
590                owasp_iterations: self.owasp_iterations,
591                conformance: false,
592                conformance_api_key: None,
593                conformance_basic_auth: None,
594                conformance_report: PathBuf::from("conformance-report.json"),
595                conformance_categories: None,
596                conformance_report_format: "json".to_string(),
597                conformance_headers: vec![],
598                conformance_all_operations: false,
599                conformance_custom: None,
600                conformance_delay_ms: 0,
601                use_k6: false,
602                conformance_custom_filter: None,
603                export_requests: false,
604                validate_requests: false,
605            },
606            targets,
607            max_concurrency,
608        );
609
610        // Execute all targets
611        let start_time = std::time::Instant::now();
612        let aggregated_results = executor.execute_all().await?;
613        let elapsed = start_time.elapsed();
614
615        // Organize and report results
616        self.report_multi_target_results(&aggregated_results, elapsed)?;
617
618        Ok(())
619    }
620
621    /// Report results for multi-target execution
622    fn report_multi_target_results(
623        &self,
624        results: &AggregatedResults,
625        elapsed: std::time::Duration,
626    ) -> Result<()> {
627        // Print summary
628        TerminalReporter::print_multi_target_summary(results);
629
630        // Print elapsed time
631        let total_secs = elapsed.as_secs();
632        let hours = total_secs / 3600;
633        let minutes = (total_secs % 3600) / 60;
634        let seconds = total_secs % 60;
635        if hours > 0 {
636            println!("\n  Total Elapsed Time:   {}h {}m {}s", hours, minutes, seconds);
637        } else if minutes > 0 {
638            println!("\n  Total Elapsed Time:   {}m {}s", minutes, seconds);
639        } else {
640            println!("\n  Total Elapsed Time:   {}s", seconds);
641        }
642
643        // Save aggregated summary if requested
644        if self.results_format == "aggregated" || self.results_format == "both" {
645            let summary_path = self.output.join("aggregated_summary.json");
646            let summary_json = serde_json::json!({
647                "total_elapsed_seconds": elapsed.as_secs(),
648                "total_targets": results.total_targets,
649                "successful_targets": results.successful_targets,
650                "failed_targets": results.failed_targets,
651                "aggregated_metrics": {
652                    "total_requests": results.aggregated_metrics.total_requests,
653                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
654                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
655                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
656                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
657                    "error_rate": results.aggregated_metrics.error_rate,
658                    "total_rps": results.aggregated_metrics.total_rps,
659                    "avg_rps": results.aggregated_metrics.avg_rps,
660                    "total_vus_max": results.aggregated_metrics.total_vus_max,
661                },
662                "target_results": results.target_results.iter().map(|r| {
663                    serde_json::json!({
664                        "target_url": r.target_url,
665                        "target_index": r.target_index,
666                        "success": r.success,
667                        "error": r.error,
668                        "total_requests": r.results.total_requests,
669                        "failed_requests": r.results.failed_requests,
670                        "avg_duration_ms": r.results.avg_duration_ms,
671                        "min_duration_ms": r.results.min_duration_ms,
672                        "med_duration_ms": r.results.med_duration_ms,
673                        "p90_duration_ms": r.results.p90_duration_ms,
674                        "p95_duration_ms": r.results.p95_duration_ms,
675                        "p99_duration_ms": r.results.p99_duration_ms,
676                        "max_duration_ms": r.results.max_duration_ms,
677                        "rps": r.results.rps,
678                        "vus_max": r.results.vus_max,
679                        "output_dir": r.output_dir.to_string_lossy(),
680                    })
681                }).collect::<Vec<_>>(),
682            });
683
684            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
685            TerminalReporter::print_success(&format!(
686                "Aggregated summary saved to: {}",
687                summary_path.display()
688            ));
689        }
690
691        // Write CSV with all per-target results for easy parsing
692        let csv_path = self.output.join("all_targets.csv");
693        let mut csv = String::from(
694            "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
695        );
696        for r in &results.target_results {
697            csv.push_str(&format!(
698                "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
699                r.target_url,
700                r.success,
701                r.results.total_requests,
702                r.results.failed_requests,
703                r.results.rps,
704                r.results.vus_max,
705                r.results.min_duration_ms,
706                r.results.avg_duration_ms,
707                r.results.med_duration_ms,
708                r.results.p90_duration_ms,
709                r.results.p95_duration_ms,
710                r.results.p99_duration_ms,
711                r.results.max_duration_ms,
712                r.error.as_deref().unwrap_or(""),
713            ));
714        }
715        let _ = std::fs::write(&csv_path, &csv);
716
717        println!("\nResults saved to: {}", self.output.display());
718        println!("  - Per-target results: {}", self.output.join("target_*").display());
719        println!("  - All targets CSV:    {}", csv_path.display());
720        if self.results_format == "aggregated" || self.results_format == "both" {
721            println!(
722                "  - Aggregated summary: {}",
723                self.output.join("aggregated_summary.json").display()
724            );
725        }
726
727        Ok(())
728    }
729
730    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
731    pub fn parse_duration(duration: &str) -> Result<u64> {
732        let duration = duration.trim();
733
734        if let Some(secs) = duration.strip_suffix('s') {
735            secs.parse::<u64>()
736                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
737        } else if let Some(mins) = duration.strip_suffix('m') {
738            mins.parse::<u64>()
739                .map(|m| m * 60)
740                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
741        } else if let Some(hours) = duration.strip_suffix('h') {
742            hours
743                .parse::<u64>()
744                .map(|h| h * 3600)
745                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
746        } else {
747            // Try parsing as seconds without suffix
748            duration
749                .parse::<u64>()
750                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
751        }
752    }
753
754    /// Parse headers from command line format (Key:Value,Key2:Value2)
755    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
756        match &self.headers {
757            Some(s) => parse_header_string(s),
758            None => Ok(HashMap::new()),
759        }
760    }
761
762    fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
763        let extracted_path = output_dir.join("extracted_values.json");
764        if !extracted_path.exists() {
765            return Ok(ExtractedValues::new());
766        }
767
768        let content = std::fs::read_to_string(&extracted_path)
769            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
770        let parsed: serde_json::Value = serde_json::from_str(&content)
771            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
772
773        let mut extracted = ExtractedValues::new();
774        if let Some(values) = parsed.as_object() {
775            for (key, value) in values {
776                extracted.set(key.clone(), value.clone());
777            }
778        }
779
780        Ok(extracted)
781    }
782
783    /// Resolve the effective base path for API endpoints
784    ///
785    /// Priority:
786    /// 1. CLI --base-path option (if provided, even if empty string)
787    /// 2. Base path extracted from OpenAPI spec's servers URL
788    /// 3. None (no base path)
789    ///
790    /// An empty string from CLI explicitly disables base path.
791    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
792        // CLI option takes priority (including empty string to disable)
793        if let Some(cli_base_path) = &self.base_path {
794            if cli_base_path.is_empty() {
795                // Empty string explicitly means "no base path"
796                return None;
797            }
798            return Some(cli_base_path.clone());
799        }
800
801        // Fall back to spec's base path
802        parser.get_base_path()
803    }
804
805    /// Build mock server integration configuration
806    async fn build_mock_config(&self) -> MockIntegrationConfig {
807        // Check if target looks like a mock server
808        if MockServerDetector::looks_like_mock_server(&self.target) {
809            // Try to detect if it's actually a MockForge server
810            if let Ok(info) = MockServerDetector::detect(&self.target).await {
811                if info.is_mockforge {
812                    TerminalReporter::print_success(&format!(
813                        "Detected MockForge server (version: {})",
814                        info.version.as_deref().unwrap_or("unknown")
815                    ));
816                    return MockIntegrationConfig::mock_server();
817                }
818            }
819        }
820        MockIntegrationConfig::real_api()
821    }
822
823    /// Build CRUD flow configuration
824    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
825        if !self.crud_flow {
826            return None;
827        }
828
829        // If flow_config file is provided, load it
830        if let Some(config_path) = &self.flow_config {
831            match CrudFlowConfig::from_file(config_path) {
832                Ok(config) => return Some(config),
833                Err(e) => {
834                    TerminalReporter::print_warning(&format!(
835                        "Failed to load flow config: {}. Using auto-detection.",
836                        e
837                    ));
838                }
839            }
840        }
841
842        // Parse extract fields
843        let extract_fields = self
844            .extract_fields
845            .as_ref()
846            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
847            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
848
849        Some(CrudFlowConfig {
850            flows: Vec::new(), // Will be auto-detected
851            default_extract_fields: extract_fields,
852        })
853    }
854
855    /// Build data-driven testing configuration
856    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
857        let data_file = self.data_file.as_ref()?;
858
859        let distribution = DataDistribution::from_str(&self.data_distribution)
860            .unwrap_or(DataDistribution::UniquePerVu);
861
862        let mappings = self
863            .data_mappings
864            .as_ref()
865            .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
866            .unwrap_or_default();
867
868        Some(DataDrivenConfig {
869            file_path: data_file.to_string_lossy().to_string(),
870            distribution,
871            mappings,
872            csv_has_header: true,
873            per_uri_control: self.per_uri_control,
874            per_uri_columns: crate::data_driven::PerUriColumns::default(),
875        })
876    }
877
878    /// Build invalid data testing configuration
879    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
880        let error_rate = self.error_rate?;
881
882        let error_types = self
883            .error_types
884            .as_ref()
885            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
886            .unwrap_or_default();
887
888        Some(InvalidDataConfig {
889            error_rate,
890            error_types,
891            target_fields: Vec::new(),
892        })
893    }
894
895    /// Build security testing configuration
896    fn build_security_config(&self) -> Option<SecurityTestConfig> {
897        if !self.security_test {
898            return None;
899        }
900
901        let categories = self
902            .security_categories
903            .as_ref()
904            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
905            .unwrap_or_else(|| {
906                let mut default = HashSet::new();
907                default.insert(SecurityCategory::SqlInjection);
908                default.insert(SecurityCategory::Xss);
909                default
910            });
911
912        let target_fields = self
913            .security_target_fields
914            .as_ref()
915            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
916            .unwrap_or_default();
917
918        let custom_payloads_file =
919            self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
920
921        Some(SecurityTestConfig {
922            enabled: true,
923            categories,
924            target_fields,
925            custom_payloads_file,
926            include_high_risk: false,
927        })
928    }
929
930    /// Build parallel execution configuration
931    fn build_parallel_config(&self) -> Option<ParallelConfig> {
932        let count = self.parallel_create?;
933
934        Some(ParallelConfig::new(count))
935    }
936
937    /// Load WAFBench payloads from the specified directory or pattern
938    fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
939        let Some(ref wafbench_dir) = self.wafbench_dir else {
940            return Vec::new();
941        };
942
943        let mut loader = WafBenchLoader::new();
944
945        if let Err(e) = loader.load_from_pattern(wafbench_dir) {
946            TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
947            return Vec::new();
948        }
949
950        let stats = loader.stats();
951
952        if stats.files_processed == 0 {
953            TerminalReporter::print_warning(&format!(
954                "No WAFBench YAML files found matching '{}'",
955                wafbench_dir
956            ));
957            // Also report any parse errors that may explain why no files were processed
958            if !stats.parse_errors.is_empty() {
959                TerminalReporter::print_warning("Some files were found but failed to parse:");
960                for error in &stats.parse_errors {
961                    TerminalReporter::print_warning(&format!("  - {}", error));
962                }
963            }
964            return Vec::new();
965        }
966
967        TerminalReporter::print_progress(&format!(
968            "Loaded {} WAFBench files, {} test cases, {} payloads",
969            stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
970        ));
971
972        // Print category breakdown
973        for (category, count) in &stats.by_category {
974            TerminalReporter::print_progress(&format!("  - {}: {} tests", category, count));
975        }
976
977        // Report any parse errors
978        for error in &stats.parse_errors {
979            TerminalReporter::print_warning(&format!("  Parse error: {}", error));
980        }
981
982        loader.to_security_payloads()
983    }
984
985    /// Generate enhanced k6 script with advanced features
986    pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
987        let mut enhanced_script = base_script.to_string();
988        let mut additional_code = String::new();
989
990        // Add data-driven testing code
991        if let Some(config) = self.build_data_driven_config() {
992            TerminalReporter::print_progress("Adding data-driven testing support...");
993            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
994            additional_code.push('\n');
995            TerminalReporter::print_success("Data-driven testing enabled");
996        }
997
998        // Add invalid data generation code
999        if let Some(config) = self.build_invalid_data_config() {
1000            TerminalReporter::print_progress("Adding invalid data testing support...");
1001            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
1002            additional_code.push('\n');
1003            additional_code
1004                .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
1005            additional_code.push('\n');
1006            additional_code
1007                .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
1008            additional_code.push('\n');
1009            TerminalReporter::print_success(&format!(
1010                "Invalid data testing enabled ({}% error rate)",
1011                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1012            ));
1013        }
1014
1015        // Add security testing code
1016        let security_config = self.build_security_config();
1017        let wafbench_payloads = self.load_wafbench_payloads();
1018        let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
1019
1020        if security_config.is_some() || !wafbench_payloads.is_empty() {
1021            TerminalReporter::print_progress("Adding security testing support...");
1022
1023            // Combine built-in payloads with WAFBench payloads
1024            let mut payload_list: Vec<SecurityPayload> = Vec::new();
1025
1026            if let Some(ref config) = security_config {
1027                payload_list.extend(SecurityPayloads::get_payloads(config));
1028            }
1029
1030            // Add WAFBench payloads
1031            if !wafbench_payloads.is_empty() {
1032                TerminalReporter::print_progress(&format!(
1033                    "Loading {} WAFBench attack patterns...",
1034                    wafbench_payloads.len()
1035                ));
1036                payload_list.extend(wafbench_payloads);
1037            }
1038
1039            let target_fields =
1040                security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1041
1042            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1043                &payload_list,
1044                self.wafbench_cycle_all,
1045            ));
1046            additional_code.push('\n');
1047            additional_code
1048                .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1049            additional_code.push('\n');
1050            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1051            additional_code.push('\n');
1052
1053            let mode = if self.wafbench_cycle_all {
1054                "cycle-all"
1055            } else {
1056                "random"
1057            };
1058            TerminalReporter::print_success(&format!(
1059                "Security testing enabled ({} payloads, {} mode)",
1060                payload_list.len(),
1061                mode
1062            ));
1063        } else if security_requested {
1064            // User requested security testing (e.g., --wafbench-dir) but no payloads were loaded.
1065            // The template has security_testing_enabled=true so it renders calling code.
1066            // We must inject stub definitions to avoid undefined function references.
1067            TerminalReporter::print_warning(
1068                "Security testing was requested but no payloads were loaded. \
1069                 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1070            );
1071            additional_code
1072                .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1073            additional_code.push('\n');
1074            additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1075            additional_code.push('\n');
1076        }
1077
1078        // Add parallel execution code
1079        if let Some(config) = self.build_parallel_config() {
1080            TerminalReporter::print_progress("Adding parallel execution support...");
1081            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1082            additional_code.push('\n');
1083            TerminalReporter::print_success(&format!(
1084                "Parallel execution enabled (count: {})",
1085                config.count
1086            ));
1087        }
1088
1089        // Insert additional code after the imports section
1090        if !additional_code.is_empty() {
1091            // Find the end of the import section
1092            if let Some(import_end) = enhanced_script.find("export const options") {
1093                enhanced_script.insert_str(
1094                    import_end,
1095                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1096                );
1097            }
1098        }
1099
1100        Ok(enhanced_script)
1101    }
1102
1103    /// Execute specs sequentially with dependency ordering and value passing
1104    async fn execute_sequential_specs(&self) -> Result<()> {
1105        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1106
1107        // Load all specs (without merging)
1108        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1109
1110        if !self.spec.is_empty() {
1111            let specs = load_specs_from_files(self.spec.clone())
1112                .await
1113                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1114            all_specs.extend(specs);
1115        }
1116
1117        if let Some(spec_dir) = &self.spec_dir {
1118            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1119                BenchError::Other(format!("Failed to load specs from directory: {}", e))
1120            })?;
1121            all_specs.extend(dir_specs);
1122        }
1123
1124        if all_specs.is_empty() {
1125            return Err(BenchError::Other(
1126                "No spec files found for sequential execution".to_string(),
1127            ));
1128        }
1129
1130        TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1131
1132        // Load dependency config or auto-detect
1133        let execution_order = if let Some(config_path) = &self.dependency_config {
1134            TerminalReporter::print_progress("Loading dependency configuration...");
1135            let config = SpecDependencyConfig::from_file(config_path)?;
1136
1137            if !config.disable_auto_detect && config.execution_order.is_empty() {
1138                // Auto-detect if config doesn't specify order
1139                self.detect_and_sort_specs(&all_specs)?
1140            } else {
1141                // Use configured order
1142                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1143            }
1144        } else {
1145            // Auto-detect dependencies
1146            self.detect_and_sort_specs(&all_specs)?
1147        };
1148
1149        TerminalReporter::print_success(&format!(
1150            "Execution order: {}",
1151            execution_order
1152                .iter()
1153                .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1154                .collect::<Vec<_>>()
1155                .join(" → ")
1156        ));
1157
1158        // Execute each spec in order
1159        let mut extracted_values = ExtractedValues::new();
1160        let total_specs = execution_order.len();
1161
1162        for (index, spec_path) in execution_order.iter().enumerate() {
1163            let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1164
1165            TerminalReporter::print_progress(&format!(
1166                "[{}/{}] Executing spec: {}",
1167                index + 1,
1168                total_specs,
1169                spec_name
1170            ));
1171
1172            // Find the spec in our loaded specs (match by full path or filename)
1173            let spec = all_specs
1174                .iter()
1175                .find(|(p, _)| {
1176                    p == spec_path
1177                        || p.file_name() == spec_path.file_name()
1178                        || p.file_name() == Some(spec_path.as_os_str())
1179                })
1180                .map(|(_, s)| s.clone())
1181                .ok_or_else(|| {
1182                    BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1183                })?;
1184
1185            // Execute this spec with any extracted values from previous specs
1186            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1187
1188            // Merge extracted values for the next spec
1189            extracted_values.merge(&new_values);
1190
1191            TerminalReporter::print_success(&format!(
1192                "[{}/{}] Completed: {} (extracted {} values)",
1193                index + 1,
1194                total_specs,
1195                spec_name,
1196                new_values.values.len()
1197            ));
1198        }
1199
1200        TerminalReporter::print_success(&format!(
1201            "Sequential execution complete: {} specs executed",
1202            total_specs
1203        ));
1204
1205        Ok(())
1206    }
1207
1208    /// Detect dependencies and return topologically sorted spec paths
1209    fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1210        TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1211
1212        let mut detector = DependencyDetector::new();
1213        let dependencies = detector.detect_dependencies(specs);
1214
1215        if dependencies.is_empty() {
1216            TerminalReporter::print_progress("No dependencies detected, using file order");
1217            return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1218        }
1219
1220        TerminalReporter::print_progress(&format!(
1221            "Detected {} cross-spec dependencies",
1222            dependencies.len()
1223        ));
1224
1225        for dep in &dependencies {
1226            TerminalReporter::print_progress(&format!(
1227                "  {} → {} (via field '{}')",
1228                dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1229                dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1230                dep.field_name
1231            ));
1232        }
1233
1234        topological_sort(specs, &dependencies)
1235    }
1236
1237    /// Execute a single spec and extract values for dependent specs
1238    async fn execute_single_spec(
1239        &self,
1240        spec: &OpenApiSpec,
1241        spec_name: &str,
1242        _external_values: &ExtractedValues,
1243    ) -> Result<ExtractedValues> {
1244        let parser = SpecParser::from_spec(spec.clone());
1245
1246        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
1247        if self.crud_flow {
1248            // Execute CRUD flow and extract values
1249            self.execute_crud_flow_with_extraction(&parser, spec_name).await
1250        } else {
1251            // Execute standard benchmark (no value extraction in non-CRUD mode)
1252            self.execute_standard_spec(&parser, spec_name).await?;
1253            Ok(ExtractedValues::new())
1254        }
1255    }
1256
1257    /// Execute CRUD flow with value extraction for sequential mode
1258    async fn execute_crud_flow_with_extraction(
1259        &self,
1260        parser: &SpecParser,
1261        spec_name: &str,
1262    ) -> Result<ExtractedValues> {
1263        let operations = parser.get_operations();
1264        let flows = CrudFlowDetector::detect_flows(&operations);
1265
1266        if flows.is_empty() {
1267            TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1268            return Ok(ExtractedValues::new());
1269        }
1270
1271        TerminalReporter::print_progress(&format!(
1272            "  {} CRUD flow(s) in {}",
1273            flows.len(),
1274            spec_name
1275        ));
1276
1277        // Generate and execute the CRUD flow script
1278        let mut handlebars = handlebars::Handlebars::new();
1279        // Register json helper for serializing arrays/objects in templates
1280        handlebars.register_helper(
1281            "json",
1282            Box::new(
1283                |h: &handlebars::Helper,
1284                 _: &handlebars::Handlebars,
1285                 _: &handlebars::Context,
1286                 _: &mut handlebars::RenderContext,
1287                 out: &mut dyn handlebars::Output|
1288                 -> handlebars::HelperResult {
1289                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1290                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1291                    Ok(())
1292                },
1293            ),
1294        );
1295        let template = include_str!("templates/k6_crud_flow.hbs");
1296        let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1297
1298        let custom_headers = self.parse_headers()?;
1299        let config = self.build_crud_flow_config().unwrap_or_default();
1300
1301        // Load parameter overrides if provided (for body configurations)
1302        let param_overrides = if let Some(params_file) = &self.params_file {
1303            let overrides = ParameterOverrides::from_file(params_file)?;
1304            Some(overrides)
1305        } else {
1306            None
1307        };
1308
1309        // Generate stages from scenario
1310        let duration_secs = Self::parse_duration(&self.duration)?;
1311        let scenario =
1312            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1313        let stages = scenario.generate_stages(duration_secs, self.vus);
1314
1315        // Resolve base path (CLI option takes priority over spec's servers URL)
1316        let api_base_path = self.resolve_base_path(parser);
1317
1318        // Build headers JSON string for the template
1319        let mut all_headers = custom_headers.clone();
1320        if let Some(auth) = &self.auth {
1321            all_headers.insert("Authorization".to_string(), auth.clone());
1322        }
1323        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1324
1325        // Track all dynamic placeholders across all operations
1326        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1327
1328        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1329            // Use the metric-name sanitizer (caps at 112 chars + hash suffix)
1330            // so deeply nested flow names don't blow past k6's 128-char limit
1331            // when concatenated with `_step{i}_latency`. See issue #79.
1332            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1333            serde_json::json!({
1334                "name": sanitized_name.clone(),
1335                "display_name": f.name,
1336                "base_path": f.base_path,
1337                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1338                    // Parse operation to get method and path
1339                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1340                    let method_raw = if !parts.is_empty() {
1341                        parts[0].to_uppercase()
1342                    } else {
1343                        "GET".to_string()
1344                    };
1345                    let method = if !parts.is_empty() {
1346                        let m = parts[0].to_lowercase();
1347                        // k6 uses 'del' for DELETE
1348                        if m == "delete" { "del".to_string() } else { m }
1349                    } else {
1350                        "get".to_string()
1351                    };
1352                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1353                    // Prepend API base path if configured
1354                    let path = if let Some(ref bp) = api_base_path {
1355                        format!("{}{}", bp, raw_path)
1356                    } else {
1357                        raw_path.to_string()
1358                    };
1359                    let is_get_or_head = method == "get" || method == "head";
1360                    // POST, PUT, PATCH typically have bodies
1361                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1362
1363                    // Look up body from params file if available
1364                    let body_value = if has_body {
1365                        param_overrides.as_ref()
1366                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1367                            .and_then(|oo| oo.body)
1368                            .unwrap_or_else(|| serde_json::json!({}))
1369                    } else {
1370                        serde_json::json!({})
1371                    };
1372
1373                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1374                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1375
1376                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1377                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1378                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1379
1380                    serde_json::json!({
1381                        "operation": s.operation,
1382                        "method": method,
1383                        "path": path,
1384                        "extract": s.extract,
1385                        "use_values": s.use_values,
1386                        "use_body": s.use_body,
1387                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1388                        "inject_attacks": s.inject_attacks,
1389                        "attack_types": s.attack_types,
1390                        "description": s.description,
1391                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1392                        "is_get_or_head": is_get_or_head,
1393                        "has_body": has_body,
1394                        "body": processed_body.value,
1395                        "body_is_dynamic": body_is_dynamic,
1396                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1397                    })
1398                }).collect::<Vec<_>>(),
1399            })
1400        }).collect();
1401
1402        // Collect all placeholders from all steps
1403        for flow_data in &flows_data {
1404            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1405                for step in steps {
1406                    if let Some(placeholders_arr) =
1407                        step.get("_placeholders").and_then(|p| p.as_array())
1408                    {
1409                        for p_str in placeholders_arr {
1410                            if let Some(p_name) = p_str.as_str() {
1411                                match p_name {
1412                                    "VU" => {
1413                                        all_placeholders.insert(DynamicPlaceholder::VU);
1414                                    }
1415                                    "Iteration" => {
1416                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1417                                    }
1418                                    "Timestamp" => {
1419                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1420                                    }
1421                                    "UUID" => {
1422                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1423                                    }
1424                                    "Random" => {
1425                                        all_placeholders.insert(DynamicPlaceholder::Random);
1426                                    }
1427                                    "Counter" => {
1428                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1429                                    }
1430                                    "Date" => {
1431                                        all_placeholders.insert(DynamicPlaceholder::Date);
1432                                    }
1433                                    "VuIter" => {
1434                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1435                                    }
1436                                    _ => {}
1437                                }
1438                            }
1439                        }
1440                    }
1441                }
1442            }
1443        }
1444
1445        // Get required imports and globals based on placeholders used
1446        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1447        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1448
1449        // Check if security testing is enabled
1450        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1451
1452        let data = serde_json::json!({
1453            "base_url": self.target,
1454            "flows": flows_data,
1455            "extract_fields": config.default_extract_fields,
1456            "duration_secs": duration_secs,
1457            "max_vus": self.vus,
1458            "auth_header": self.auth,
1459            "custom_headers": custom_headers,
1460            "skip_tls_verify": self.skip_tls_verify,
1461            // Add missing template fields
1462            "stages": stages.iter().map(|s| serde_json::json!({
1463                "duration": s.duration,
1464                "target": s.target,
1465            })).collect::<Vec<_>>(),
1466            "threshold_percentile": self.threshold_percentile,
1467            "threshold_ms": self.threshold_ms,
1468            "max_error_rate": self.max_error_rate,
1469            "headers": headers_json,
1470            "dynamic_imports": required_imports,
1471            "dynamic_globals": required_globals,
1472            "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
1473            // Security testing settings
1474            "security_testing_enabled": security_testing_enabled,
1475            "has_custom_headers": !custom_headers.is_empty(),
1476        });
1477
1478        let mut script = handlebars
1479            .render_template(template, &data)
1480            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1481
1482        // Enhance script with security testing support if enabled
1483        if security_testing_enabled {
1484            script = self.generate_enhanced_script(&script)?;
1485        }
1486
1487        // Write and execute script
1488        let script_path =
1489            self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1490
1491        std::fs::create_dir_all(self.output.clone())?;
1492        std::fs::write(&script_path, &script)?;
1493
1494        if !self.generate_only {
1495            let executor = K6Executor::new()?;
1496            std::fs::create_dir_all(&output_dir)?;
1497
1498            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1499
1500            let extracted = Self::parse_extracted_values(&output_dir)?;
1501            TerminalReporter::print_progress(&format!(
1502                "  Extracted {} value(s) from {}",
1503                extracted.values.len(),
1504                spec_name
1505            ));
1506            return Ok(extracted);
1507        }
1508
1509        Ok(ExtractedValues::new())
1510    }
1511
1512    /// Execute standard (non-CRUD) spec benchmark
1513    async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1514        let mut operations = if let Some(filter) = &self.operations {
1515            parser.filter_operations(filter)?
1516        } else {
1517            parser.get_operations()
1518        };
1519
1520        if let Some(exclude) = &self.exclude_operations {
1521            operations = parser.exclude_operations(operations, exclude)?;
1522        }
1523
1524        if operations.is_empty() {
1525            TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1526            return Ok(());
1527        }
1528
1529        TerminalReporter::print_progress(&format!(
1530            "  {} operations in {}",
1531            operations.len(),
1532            spec_name
1533        ));
1534
1535        // Generate request templates
1536        let templates: Vec<_> = operations
1537            .iter()
1538            .map(RequestGenerator::generate_template)
1539            .collect::<Result<Vec<_>>>()?;
1540
1541        // Parse headers
1542        let custom_headers = self.parse_headers()?;
1543
1544        // Resolve base path
1545        let base_path = self.resolve_base_path(parser);
1546
1547        // Generate k6 script
1548        let scenario =
1549            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1550
1551        let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1552
1553        let k6_config = K6Config {
1554            target_url: self.target.clone(),
1555            base_path,
1556            scenario,
1557            duration_secs: Self::parse_duration(&self.duration)?,
1558            max_vus: self.vus,
1559            threshold_percentile: self.threshold_percentile.clone(),
1560            threshold_ms: self.threshold_ms,
1561            max_error_rate: self.max_error_rate,
1562            auth_header: self.auth.clone(),
1563            custom_headers,
1564            skip_tls_verify: self.skip_tls_verify,
1565            security_testing_enabled,
1566            chunked_request_bodies: self.chunked_request_bodies,
1567        };
1568
1569        let generator = K6ScriptGenerator::new(k6_config, templates);
1570        let mut script = generator.generate()?;
1571
1572        // Enhance script with advanced features (security testing, etc.)
1573        let has_advanced_features = self.data_file.is_some()
1574            || self.error_rate.is_some()
1575            || self.security_test
1576            || self.parallel_create.is_some()
1577            || self.wafbench_dir.is_some();
1578
1579        if has_advanced_features {
1580            script = self.generate_enhanced_script(&script)?;
1581        }
1582
1583        // Write and execute script
1584        let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1585
1586        std::fs::create_dir_all(self.output.clone())?;
1587        std::fs::write(&script_path, &script)?;
1588
1589        if !self.generate_only {
1590            let executor = K6Executor::new()?;
1591            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1592            std::fs::create_dir_all(&output_dir)?;
1593
1594            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1595        }
1596
1597        Ok(())
1598    }
1599
1600    /// Execute CRUD flow testing mode
1601    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1602        // Check if a custom flow config is provided
1603        let config = self.build_crud_flow_config().unwrap_or_default();
1604
1605        // Use flows from config if provided, otherwise auto-detect
1606        let flows = if !config.flows.is_empty() {
1607            TerminalReporter::print_progress("Using custom flow configuration...");
1608            config.flows.clone()
1609        } else {
1610            TerminalReporter::print_progress("Detecting CRUD operations...");
1611            let operations = parser.get_operations();
1612            CrudFlowDetector::detect_flows(&operations)
1613        };
1614
1615        if flows.is_empty() {
1616            return Err(BenchError::Other(
1617                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1618            ));
1619        }
1620
1621        if config.flows.is_empty() {
1622            TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1623        } else {
1624            TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1625        }
1626
1627        for flow in &flows {
1628            TerminalReporter::print_progress(&format!(
1629                "  - {}: {} steps",
1630                flow.name,
1631                flow.steps.len()
1632            ));
1633        }
1634
1635        // Generate CRUD flow script
1636        let mut handlebars = handlebars::Handlebars::new();
1637        // Register json helper for serializing arrays/objects in templates
1638        handlebars.register_helper(
1639            "json",
1640            Box::new(
1641                |h: &handlebars::Helper,
1642                 _: &handlebars::Handlebars,
1643                 _: &handlebars::Context,
1644                 _: &mut handlebars::RenderContext,
1645                 out: &mut dyn handlebars::Output|
1646                 -> handlebars::HelperResult {
1647                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1648                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1649                    Ok(())
1650                },
1651            ),
1652        );
1653        let template = include_str!("templates/k6_crud_flow.hbs");
1654
1655        let custom_headers = self.parse_headers()?;
1656
1657        // Load parameter overrides if provided (for body configurations)
1658        let param_overrides = if let Some(params_file) = &self.params_file {
1659            TerminalReporter::print_progress("Loading parameter overrides...");
1660            let overrides = ParameterOverrides::from_file(params_file)?;
1661            TerminalReporter::print_success(&format!(
1662                "Loaded parameter overrides ({} operation-specific, {} defaults)",
1663                overrides.operations.len(),
1664                if overrides.defaults.is_empty() { 0 } else { 1 }
1665            ));
1666            Some(overrides)
1667        } else {
1668            None
1669        };
1670
1671        // Generate stages from scenario
1672        let duration_secs = Self::parse_duration(&self.duration)?;
1673        let scenario =
1674            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1675        let stages = scenario.generate_stages(duration_secs, self.vus);
1676
1677        // Resolve base path (CLI option takes priority over spec's servers URL)
1678        let api_base_path = self.resolve_base_path(parser);
1679        if let Some(ref bp) = api_base_path {
1680            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1681        }
1682
1683        // Build headers JSON string for the template
1684        let mut all_headers = custom_headers.clone();
1685        if let Some(auth) = &self.auth {
1686            all_headers.insert("Authorization".to_string(), auth.clone());
1687        }
1688        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1689
1690        // Track all dynamic placeholders across all operations
1691        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1692
1693        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1694            // Sanitize flow name for use as JavaScript variable and k6 metric names.
1695            // Use the metric-name sanitizer (caps at 112 chars + hash suffix) so
1696            // deeply nested flow names don't blow past k6's 128-char limit when
1697            // concatenated with `_step{i}_latency`. See issue #79.
1698            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1699            serde_json::json!({
1700                "name": sanitized_name.clone(),  // Use sanitized name for variable names
1701                "display_name": f.name,          // Keep original for comments/display
1702                "base_path": f.base_path,
1703                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1704                    // Parse operation to get method and path
1705                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1706                    let method_raw = if !parts.is_empty() {
1707                        parts[0].to_uppercase()
1708                    } else {
1709                        "GET".to_string()
1710                    };
1711                    let method = if !parts.is_empty() {
1712                        let m = parts[0].to_lowercase();
1713                        // k6 uses 'del' for DELETE
1714                        if m == "delete" { "del".to_string() } else { m }
1715                    } else {
1716                        "get".to_string()
1717                    };
1718                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1719                    // Prepend API base path if configured
1720                    let path = if let Some(ref bp) = api_base_path {
1721                        format!("{}{}", bp, raw_path)
1722                    } else {
1723                        raw_path.to_string()
1724                    };
1725                    let is_get_or_head = method == "get" || method == "head";
1726                    // POST, PUT, PATCH typically have bodies
1727                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1728
1729                    // Look up body from params file if available (use raw_path for matching)
1730                    let body_value = if has_body {
1731                        param_overrides.as_ref()
1732                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1733                            .and_then(|oo| oo.body)
1734                            .unwrap_or_else(|| serde_json::json!({}))
1735                    } else {
1736                        serde_json::json!({})
1737                    };
1738
1739                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
1740                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1741                    // Note: all_placeholders is captured by the closure but we can't mutate it directly
1742                    // We'll collect placeholders separately below
1743
1744                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
1745                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1746                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1747
1748                    serde_json::json!({
1749                        "operation": s.operation,
1750                        "method": method,
1751                        "path": path,
1752                        "extract": s.extract,
1753                        "use_values": s.use_values,
1754                        "use_body": s.use_body,
1755                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1756                        "inject_attacks": s.inject_attacks,
1757                        "attack_types": s.attack_types,
1758                        "description": s.description,
1759                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1760                        "is_get_or_head": is_get_or_head,
1761                        "has_body": has_body,
1762                        "body": processed_body.value,
1763                        "body_is_dynamic": body_is_dynamic,
1764                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1765                    })
1766                }).collect::<Vec<_>>(),
1767            })
1768        }).collect();
1769
1770        // Collect all placeholders from all steps
1771        for flow_data in &flows_data {
1772            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1773                for step in steps {
1774                    if let Some(placeholders_arr) =
1775                        step.get("_placeholders").and_then(|p| p.as_array())
1776                    {
1777                        for p_str in placeholders_arr {
1778                            if let Some(p_name) = p_str.as_str() {
1779                                // Parse placeholder from debug string
1780                                match p_name {
1781                                    "VU" => {
1782                                        all_placeholders.insert(DynamicPlaceholder::VU);
1783                                    }
1784                                    "Iteration" => {
1785                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
1786                                    }
1787                                    "Timestamp" => {
1788                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
1789                                    }
1790                                    "UUID" => {
1791                                        all_placeholders.insert(DynamicPlaceholder::UUID);
1792                                    }
1793                                    "Random" => {
1794                                        all_placeholders.insert(DynamicPlaceholder::Random);
1795                                    }
1796                                    "Counter" => {
1797                                        all_placeholders.insert(DynamicPlaceholder::Counter);
1798                                    }
1799                                    "Date" => {
1800                                        all_placeholders.insert(DynamicPlaceholder::Date);
1801                                    }
1802                                    "VuIter" => {
1803                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
1804                                    }
1805                                    _ => {}
1806                                }
1807                            }
1808                        }
1809                    }
1810                }
1811            }
1812        }
1813
1814        // Get required imports and globals based on placeholders used
1815        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1816        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1817
1818        // Build invalid data config if error injection is enabled
1819        let invalid_data_config = self.build_invalid_data_config();
1820        let error_injection_enabled = invalid_data_config.is_some();
1821        let error_rate = self.error_rate.unwrap_or(0.0);
1822        let error_types: Vec<String> = invalid_data_config
1823            .as_ref()
1824            .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1825            .unwrap_or_default();
1826
1827        if error_injection_enabled {
1828            TerminalReporter::print_progress(&format!(
1829                "Error injection enabled ({}% rate)",
1830                (error_rate * 100.0) as u32
1831            ));
1832        }
1833
1834        // Check if security testing is enabled
1835        let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1836
1837        let data = serde_json::json!({
1838            "base_url": self.target,
1839            "flows": flows_data,
1840            "extract_fields": config.default_extract_fields,
1841            "duration_secs": duration_secs,
1842            "max_vus": self.vus,
1843            "auth_header": self.auth,
1844            "custom_headers": custom_headers,
1845            "skip_tls_verify": self.skip_tls_verify,
1846            // Add missing template fields
1847            "stages": stages.iter().map(|s| serde_json::json!({
1848                "duration": s.duration,
1849                "target": s.target,
1850            })).collect::<Vec<_>>(),
1851            "threshold_percentile": self.threshold_percentile,
1852            "threshold_ms": self.threshold_ms,
1853            "max_error_rate": self.max_error_rate,
1854            "headers": headers_json,
1855            "dynamic_imports": required_imports,
1856            "dynamic_globals": required_globals,
1857            "extracted_values_output_path": self
1858                .output
1859                .join("crud_flow_extracted_values.json")
1860                .to_string_lossy(),
1861            // Error injection settings
1862            "error_injection_enabled": error_injection_enabled,
1863            "error_rate": error_rate,
1864            "error_types": error_types,
1865            // Security testing settings
1866            "security_testing_enabled": security_testing_enabled,
1867            "has_custom_headers": !custom_headers.is_empty(),
1868        });
1869
1870        let mut script = handlebars
1871            .render_template(template, &data)
1872            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1873
1874        // Enhance script with security testing support if enabled
1875        if security_testing_enabled {
1876            script = self.generate_enhanced_script(&script)?;
1877        }
1878
1879        // Validate the generated CRUD flow script
1880        TerminalReporter::print_progress("Validating CRUD flow script...");
1881        let validation_errors = K6ScriptGenerator::validate_script(&script);
1882        if !validation_errors.is_empty() {
1883            TerminalReporter::print_error("CRUD flow script validation failed");
1884            for error in &validation_errors {
1885                eprintln!("  {}", error);
1886            }
1887            return Err(BenchError::Other(format!(
1888                "CRUD flow script validation failed with {} error(s)",
1889                validation_errors.len()
1890            )));
1891        }
1892
1893        TerminalReporter::print_success("CRUD flow script generated");
1894
1895        // Write and execute script
1896        let script_path = if let Some(output) = &self.script_output {
1897            output.clone()
1898        } else {
1899            self.output.join("k6-crud-flow-script.js")
1900        };
1901
1902        if let Some(parent) = script_path.parent() {
1903            std::fs::create_dir_all(parent)?;
1904        }
1905        std::fs::write(&script_path, &script)?;
1906        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1907
1908        if self.generate_only {
1909            println!("\nScript generated successfully. Run it with:");
1910            println!("  k6 run {}", script_path.display());
1911            return Ok(());
1912        }
1913
1914        // Execute k6
1915        TerminalReporter::print_progress("Executing CRUD flow test...");
1916        let executor = K6Executor::new()?;
1917        std::fs::create_dir_all(&self.output)?;
1918
1919        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1920
1921        let duration_secs = Self::parse_duration(&self.duration)?;
1922        TerminalReporter::print_summary(&results, duration_secs);
1923
1924        Ok(())
1925    }
1926
1927    /// Execute OpenAPI 3.0.0 conformance testing mode
1928    async fn execute_conformance_test(&self) -> Result<()> {
1929        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
1930        use crate::conformance::report::ConformanceReport;
1931        use crate::conformance::spec::ConformanceFeature;
1932
1933        TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
1934
1935        // Conformance testing is a functional correctness check (1 VU, 1 iteration).
1936        // --vus and -d flags are always ignored in this mode.
1937        TerminalReporter::print_progress(
1938            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
1939        );
1940
1941        // Parse category filter
1942        let categories = self.conformance_categories.as_ref().map(|cats_str| {
1943            cats_str
1944                .split(',')
1945                .filter_map(|s| {
1946                    let trimmed = s.trim();
1947                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
1948                        Some(canonical.to_string())
1949                    } else {
1950                        TerminalReporter::print_warning(&format!(
1951                            "Unknown conformance category: '{}'. Valid categories: {}",
1952                            trimmed,
1953                            ConformanceFeature::cli_category_names()
1954                                .iter()
1955                                .map(|(cli, _)| *cli)
1956                                .collect::<Vec<_>>()
1957                                .join(", ")
1958                        ));
1959                        None
1960                    }
1961                })
1962                .collect::<Vec<String>>()
1963        });
1964
1965        // Parse custom headers from "Key: Value" format
1966        let custom_headers: Vec<(String, String)> = self
1967            .conformance_headers
1968            .iter()
1969            .filter_map(|h| {
1970                let (name, value) = h.split_once(':')?;
1971                Some((name.trim().to_string(), value.trim().to_string()))
1972            })
1973            .collect();
1974
1975        if !custom_headers.is_empty() {
1976            TerminalReporter::print_progress(&format!(
1977                "Using {} custom header(s) for authentication",
1978                custom_headers.len()
1979            ));
1980        }
1981
1982        if self.conformance_delay_ms > 0 {
1983            TerminalReporter::print_progress(&format!(
1984                "Using {}ms delay between conformance requests",
1985                self.conformance_delay_ms
1986            ));
1987        }
1988
1989        // Ensure output dir exists so canonicalize works for the report path
1990        std::fs::create_dir_all(&self.output)?;
1991
1992        let config = ConformanceConfig {
1993            target_url: self.target.clone(),
1994            api_key: self.conformance_api_key.clone(),
1995            basic_auth: self.conformance_basic_auth.clone(),
1996            skip_tls_verify: self.skip_tls_verify,
1997            categories,
1998            base_path: self.base_path.clone(),
1999            custom_headers,
2000            output_dir: Some(self.output.clone()),
2001            all_operations: self.conformance_all_operations,
2002            custom_checks_file: self.conformance_custom.clone(),
2003            request_delay_ms: self.conformance_delay_ms,
2004            custom_filter: self.conformance_custom_filter.clone(),
2005            export_requests: self.export_requests,
2006            validate_requests: self.validate_requests,
2007        };
2008
2009        // Branch: spec-driven mode vs reference mode
2010        // Annotate operations if spec is provided (used by both native and k6 paths)
2011        let annotated_ops = if !self.spec.is_empty() {
2012            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2013            let parser = SpecParser::from_file(&self.spec[0]).await?;
2014            let operations = parser.get_operations();
2015
2016            let annotated =
2017                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2018                    &operations,
2019                    parser.spec(),
2020                );
2021            TerminalReporter::print_success(&format!(
2022                "Analyzed {} operations, found {} feature annotations",
2023                operations.len(),
2024                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2025            ));
2026            Some(annotated)
2027        } else {
2028            None
2029        };
2030
2031        // Request validation against OpenAPI spec (if --validate-requests is set)
2032        if self.validate_requests && !self.spec.is_empty() {
2033            TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
2034            let violation_count = crate::conformance::request_validator::run_request_validation(
2035                &self.spec,
2036                self.conformance_custom.as_deref(),
2037                self.base_path.as_deref(),
2038                &self.output,
2039            )
2040            .await?;
2041            if violation_count > 0 {
2042                TerminalReporter::print_warning(&format!(
2043                    "{} request validation violation(s) found — see conformance-request-violations.json",
2044                    violation_count
2045                ));
2046            } else {
2047                TerminalReporter::print_success("All requests conform to the OpenAPI spec");
2048            }
2049        }
2050
2051        // If generate-only OR --use-k6, use the k6 script generation path
2052        if self.generate_only || self.use_k6 {
2053            let script = if let Some(annotated) = &annotated_ops {
2054                let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2055                    config,
2056                    annotated.clone(),
2057                );
2058                let op_count = gen.operation_count();
2059                let (script, check_count) = gen.generate()?;
2060                TerminalReporter::print_success(&format!(
2061                    "Conformance: {} operations analyzed, {} unique checks generated",
2062                    op_count, check_count
2063                ));
2064                script
2065            } else {
2066                let generator = ConformanceGenerator::new(config);
2067                generator.generate()?
2068            };
2069
2070            let script_path = self.output.join("k6-conformance.js");
2071            std::fs::write(&script_path, &script).map_err(|e| {
2072                BenchError::Other(format!("Failed to write conformance script: {}", e))
2073            })?;
2074            TerminalReporter::print_success(&format!(
2075                "Conformance script generated: {}",
2076                script_path.display()
2077            ));
2078
2079            if self.generate_only {
2080                println!("\nScript generated. Run with:");
2081                println!("  k6 run {}", script_path.display());
2082                return Ok(());
2083            }
2084
2085            // --use-k6: execute via k6
2086            if !K6Executor::is_k6_installed() {
2087                TerminalReporter::print_error("k6 is not installed");
2088                TerminalReporter::print_warning(
2089                    "Install k6 from: https://k6.io/docs/get-started/installation/",
2090                );
2091                return Err(BenchError::K6NotFound);
2092            }
2093
2094            TerminalReporter::print_progress("Running conformance tests via k6...");
2095            let executor = K6Executor::new()?;
2096            executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2097
2098            let report_path = self.output.join("conformance-report.json");
2099            if report_path.exists() {
2100                let report = ConformanceReport::from_file(&report_path)?;
2101                report.print_report_with_options(self.conformance_all_operations);
2102                self.save_conformance_report(&report, &report_path)?;
2103            } else {
2104                TerminalReporter::print_warning(
2105                    "Conformance report not generated (k6 handleSummary may not have run)",
2106                );
2107            }
2108
2109            return Ok(());
2110        }
2111
2112        // Default: Native Rust executor (no k6 dependency)
2113        TerminalReporter::print_progress("Running conformance tests (native executor)...");
2114
2115        let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2116
2117        executor = if let Some(annotated) = &annotated_ops {
2118            executor.with_spec_driven_checks(annotated)
2119        } else {
2120            executor.with_reference_checks()
2121        };
2122        executor = executor.with_custom_checks()?;
2123
2124        TerminalReporter::print_success(&format!(
2125            "Executing {} conformance checks...",
2126            executor.check_count()
2127        ));
2128
2129        let report = executor.execute().await?;
2130        report.print_report_with_options(self.conformance_all_operations);
2131
2132        // Save failure details to a separate file for easy debugging
2133        let failure_details = report.failure_details();
2134        if !failure_details.is_empty() {
2135            let details_path = self.output.join("conformance-failure-details.json");
2136            if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2137                let _ = std::fs::write(&details_path, json);
2138                TerminalReporter::print_success(&format!(
2139                    "Failure details saved to: {}",
2140                    details_path.display()
2141                ));
2142            }
2143        }
2144
2145        // Save report
2146        let report_path = self.output.join("conformance-report.json");
2147        let report_json = serde_json::to_string_pretty(&report.to_json())
2148            .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2149        std::fs::write(&report_path, &report_json)
2150            .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2151        TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
2152
2153        self.save_conformance_report(&report, &report_path)?;
2154
2155        Ok(())
2156    }
2157
2158    /// Save conformance report in the requested format (SARIF or JSON copy)
2159    fn save_conformance_report(
2160        &self,
2161        report: &crate::conformance::report::ConformanceReport,
2162        report_path: &Path,
2163    ) -> Result<()> {
2164        if self.conformance_report_format == "sarif" {
2165            use crate::conformance::sarif::ConformanceSarifReport;
2166            ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
2167            TerminalReporter::print_success(&format!(
2168                "SARIF report saved to: {}",
2169                self.conformance_report.display()
2170            ));
2171        } else if self.conformance_report != *report_path {
2172            std::fs::copy(report_path, &self.conformance_report)?;
2173            TerminalReporter::print_success(&format!(
2174                "Report saved to: {}",
2175                self.conformance_report.display()
2176            ));
2177        }
2178        Ok(())
2179    }
2180
2181    /// Execute conformance tests against multiple targets from a targets file.
2182    ///
2183    /// Uses the native `NativeConformanceExecutor` (no k6 dependency). Targets are
2184    /// tested sequentially to avoid overwhelming them, and per-target headers from
2185    /// the targets file are merged with the base `--conformance-header` headers.
2186    async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
2187        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2188        use crate::conformance::report::ConformanceReport;
2189        use crate::conformance::spec::ConformanceFeature;
2190
2191        TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
2192
2193        // Parse targets file
2194        TerminalReporter::print_progress("Parsing targets file...");
2195        let targets = parse_targets_file(targets_file)?;
2196        let num_targets = targets.len();
2197        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
2198
2199        if targets.is_empty() {
2200            return Err(BenchError::Other("No targets found in file".to_string()));
2201        }
2202
2203        TerminalReporter::print_progress(
2204            "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
2205        );
2206
2207        // Parse category filter (shared across all targets)
2208        let categories = self.conformance_categories.as_ref().map(|cats_str| {
2209            cats_str
2210                .split(',')
2211                .filter_map(|s| {
2212                    let trimmed = s.trim();
2213                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2214                        Some(canonical.to_string())
2215                    } else {
2216                        TerminalReporter::print_warning(&format!(
2217                            "Unknown conformance category: '{}'. Valid categories: {}",
2218                            trimmed,
2219                            ConformanceFeature::cli_category_names()
2220                                .iter()
2221                                .map(|(cli, _)| *cli)
2222                                .collect::<Vec<_>>()
2223                                .join(", ")
2224                        ));
2225                        None
2226                    }
2227                })
2228                .collect::<Vec<String>>()
2229        });
2230
2231        // Parse base custom headers from --conformance-header flags
2232        let base_custom_headers: Vec<(String, String)> = self
2233            .conformance_headers
2234            .iter()
2235            .filter_map(|h| {
2236                let (name, value) = h.split_once(':')?;
2237                Some((name.trim().to_string(), value.trim().to_string()))
2238            })
2239            .collect();
2240
2241        if !base_custom_headers.is_empty() {
2242            TerminalReporter::print_progress(&format!(
2243                "Using {} base custom header(s) for authentication",
2244                base_custom_headers.len()
2245            ));
2246        }
2247
2248        // Load spec once if provided (shared across all targets)
2249        let annotated_ops = if !self.spec.is_empty() {
2250            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2251            let parser = SpecParser::from_file(&self.spec[0]).await?;
2252            let operations = parser.get_operations();
2253            let annotated =
2254                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2255                    &operations,
2256                    parser.spec(),
2257                );
2258            TerminalReporter::print_success(&format!(
2259                "Analyzed {} operations, found {} feature annotations",
2260                operations.len(),
2261                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2262            ));
2263            Some(annotated)
2264        } else {
2265            None
2266        };
2267
2268        // Ensure output dir exists
2269        std::fs::create_dir_all(&self.output)?;
2270
2271        // Collect per-target results for the summary
2272        struct TargetResult {
2273            url: String,
2274            passed: usize,
2275            failed: usize,
2276            elapsed: std::time::Duration,
2277            report_json: serde_json::Value,
2278            owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
2279        }
2280
2281        let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
2282        let total_start = std::time::Instant::now();
2283
2284        for (idx, target) in targets.iter().enumerate() {
2285            tracing::info!(
2286                "Running conformance tests against target {}/{}: {}",
2287                idx + 1,
2288                num_targets,
2289                target.url
2290            );
2291            TerminalReporter::print_progress(&format!(
2292                "\n--- Target {}/{}: {} ---",
2293                idx + 1,
2294                num_targets,
2295                target.url
2296            ));
2297
2298            // Merge base headers with per-target headers
2299            let mut merged_headers = base_custom_headers.clone();
2300            if let Some(ref target_headers) = target.headers {
2301                for (name, value) in target_headers {
2302                    // Per-target headers override base headers with the same name
2303                    if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
2304                        existing.1 = value.clone();
2305                    } else {
2306                        merged_headers.push((name.clone(), value.clone()));
2307                    }
2308                }
2309            }
2310            // Add auth header if present on target
2311            if let Some(ref auth) = target.auth {
2312                if let Some(existing) =
2313                    merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
2314                {
2315                    existing.1 = auth.clone();
2316                } else {
2317                    merged_headers.push(("Authorization".to_string(), auth.clone()));
2318                }
2319            }
2320
2321            // Per-target output dir (used by both native and k6 paths).
2322            // Created before the config so we can point the k6 script's
2323            // handleSummary at the per-target directory rather than the shared
2324            // parent output dir (otherwise every target would overwrite the
2325            // same conformance-report.json).
2326            let target_dir = self.output.join(format!("target_{}", idx));
2327            std::fs::create_dir_all(&target_dir)?;
2328
2329            let config = ConformanceConfig {
2330                target_url: target.url.clone(),
2331                api_key: self.conformance_api_key.clone(),
2332                basic_auth: self.conformance_basic_auth.clone(),
2333                skip_tls_verify: self.skip_tls_verify,
2334                categories: categories.clone(),
2335                base_path: self.base_path.clone(),
2336                custom_headers: merged_headers,
2337                output_dir: Some(target_dir.clone()),
2338                all_operations: self.conformance_all_operations,
2339                custom_checks_file: self.conformance_custom.clone(),
2340                request_delay_ms: self.conformance_delay_ms,
2341                custom_filter: self.conformance_custom_filter.clone(),
2342                export_requests: self.export_requests,
2343                validate_requests: self.validate_requests,
2344            };
2345
2346            let target_start = std::time::Instant::now();
2347            let report = if self.use_k6 {
2348                if !K6Executor::is_k6_installed() {
2349                    TerminalReporter::print_error("k6 is not installed");
2350                    TerminalReporter::print_warning(
2351                        "Install k6 from: https://k6.io/docs/get-started/installation/",
2352                    );
2353                    return Err(BenchError::K6NotFound);
2354                }
2355
2356                let script = if let Some(ref annotated) = annotated_ops {
2357                    let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2358                        config.clone(),
2359                        annotated.clone(),
2360                    );
2361                    let (script, _check_count) = gen.generate()?;
2362                    script
2363                } else {
2364                    let generator = ConformanceGenerator::new(config.clone());
2365                    generator.generate()?
2366                };
2367
2368                let script_path = target_dir.join("k6-conformance.js");
2369                std::fs::write(&script_path, &script).map_err(|e| {
2370                    BenchError::Other(format!("Failed to write conformance script: {}", e))
2371                })?;
2372                TerminalReporter::print_success(&format!(
2373                    "Conformance script generated: {}",
2374                    script_path.display()
2375                ));
2376
2377                TerminalReporter::print_progress(&format!(
2378                    "Running conformance tests via k6 against {}...",
2379                    target.url
2380                ));
2381                let k6 = K6Executor::new()?;
2382                // Unique k6 API port per target to avoid collisions.
2383                let api_port = 6565u16.saturating_add(idx as u16);
2384                k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
2385                    .await?;
2386
2387                let report_path = target_dir.join("conformance-report.json");
2388                if report_path.exists() {
2389                    ConformanceReport::from_file(&report_path)?
2390                } else {
2391                    TerminalReporter::print_warning(&format!(
2392                        "Conformance report not generated for target {} (k6 handleSummary may not have run)",
2393                        target.url
2394                    ));
2395                    continue;
2396                }
2397            } else {
2398                let mut executor =
2399                    crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2400
2401                executor = if let Some(ref annotated) = annotated_ops {
2402                    executor.with_spec_driven_checks(annotated)
2403                } else {
2404                    executor.with_reference_checks()
2405                };
2406                executor = executor.with_custom_checks()?;
2407
2408                TerminalReporter::print_success(&format!(
2409                    "Executing {} conformance checks against {}...",
2410                    executor.check_count(),
2411                    target.url
2412                ));
2413
2414                executor.execute().await?
2415            };
2416            let target_elapsed = target_start.elapsed();
2417
2418            let report_json = report.to_json();
2419
2420            // Extract pass/fail from the summary in the JSON
2421            let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
2422            let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
2423            let total_checks = passed + failed;
2424            let rate = if total_checks == 0 {
2425                0.0
2426            } else {
2427                (passed as f64 / total_checks as f64) * 100.0
2428            };
2429
2430            TerminalReporter::print_success(&format!(
2431                "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
2432                target.url,
2433                passed,
2434                total_checks,
2435                rate,
2436                target_elapsed.as_secs_f64()
2437            ));
2438
2439            // Save per-target report (target_dir created above)
2440            let target_report_path = target_dir.join("conformance-report.json");
2441            let report_str = serde_json::to_string_pretty(&report_json)
2442                .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2443            std::fs::write(&target_report_path, &report_str)
2444                .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2445
2446            // Save failure details if any
2447            let failure_details = report.failure_details();
2448            if !failure_details.is_empty() {
2449                let details_path = target_dir.join("conformance-failure-details.json");
2450                if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2451                    let _ = std::fs::write(&details_path, json);
2452                }
2453            }
2454
2455            // Compute OWASP coverage for this target
2456            let owasp_coverage = report.owasp_coverage_data();
2457
2458            target_results.push(TargetResult {
2459                url: target.url.clone(),
2460                passed,
2461                failed,
2462                elapsed: target_elapsed,
2463                report_json,
2464                owasp_coverage,
2465            });
2466        }
2467
2468        let total_elapsed = total_start.elapsed();
2469
2470        // Print summary table
2471        println!("\n{}", "=".repeat(80));
2472        println!("  Multi-Target Conformance Summary");
2473        println!("{}", "=".repeat(80));
2474        println!(
2475            "  {:<40} {:>8} {:>8} {:>8} {:>8}",
2476            "Target URL", "Passed", "Failed", "Rate", "Time"
2477        );
2478        println!("  {}", "-".repeat(76));
2479
2480        let mut total_passed = 0usize;
2481        let mut total_failed = 0usize;
2482
2483        for result in &target_results {
2484            let total_checks = result.passed + result.failed;
2485            let rate = if total_checks == 0 {
2486                0.0
2487            } else {
2488                (result.passed as f64 / total_checks as f64) * 100.0
2489            };
2490
2491            // Truncate long URLs for display
2492            let display_url = if result.url.len() > 38 {
2493                format!("{}...", &result.url[..35])
2494            } else {
2495                result.url.clone()
2496            };
2497
2498            println!(
2499                "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2500                display_url,
2501                result.passed,
2502                result.failed,
2503                rate,
2504                result.elapsed.as_secs_f64()
2505            );
2506
2507            total_passed += result.passed;
2508            total_failed += result.failed;
2509        }
2510
2511        let grand_total = total_passed + total_failed;
2512        let overall_rate = if grand_total == 0 {
2513            0.0
2514        } else {
2515            (total_passed as f64 / grand_total as f64) * 100.0
2516        };
2517
2518        println!("  {}", "-".repeat(76));
2519        println!(
2520            "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2521            format!("TOTAL ({} targets)", num_targets),
2522            total_passed,
2523            total_failed,
2524            overall_rate,
2525            total_elapsed.as_secs_f64()
2526        );
2527        println!("{}", "=".repeat(80));
2528
2529        // Print per-target OWASP coverage
2530        for result in &target_results {
2531            println!("\n  OWASP API Security Top 10 Coverage for {}:", result.url);
2532            for entry in &result.owasp_coverage {
2533                let status = if !entry.tested {
2534                    "-"
2535                } else if entry.all_passed {
2536                    "pass"
2537                } else {
2538                    "FAIL"
2539                };
2540                let via = if entry.via_categories.is_empty() {
2541                    String::new()
2542                } else {
2543                    format!(" (via {})", entry.via_categories.join(", "))
2544                };
2545                println!("    {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
2546            }
2547        }
2548
2549        // Save combined summary
2550        let per_target_summaries: Vec<serde_json::Value> = target_results
2551            .iter()
2552            .enumerate()
2553            .map(|(idx, r)| {
2554                let total_checks = r.passed + r.failed;
2555                let rate = if total_checks == 0 {
2556                    0.0
2557                } else {
2558                    (r.passed as f64 / total_checks as f64) * 100.0
2559                };
2560                let owasp_json: Vec<serde_json::Value> = r
2561                    .owasp_coverage
2562                    .iter()
2563                    .map(|e| {
2564                        serde_json::json!({
2565                            "id": e.id,
2566                            "name": e.name,
2567                            "tested": e.tested,
2568                            "all_passed": e.all_passed,
2569                            "via_categories": e.via_categories,
2570                        })
2571                    })
2572                    .collect();
2573                serde_json::json!({
2574                    "target_url": r.url,
2575                    "target_index": idx,
2576                    "checks_passed": r.passed,
2577                    "checks_failed": r.failed,
2578                    "total_checks": total_checks,
2579                    "pass_rate": rate,
2580                    "elapsed_seconds": r.elapsed.as_secs_f64(),
2581                    "report": r.report_json,
2582                    "owasp_coverage": owasp_json,
2583                })
2584            })
2585            .collect();
2586
2587        let combined_summary = serde_json::json!({
2588            "total_targets": num_targets,
2589            "total_checks_passed": total_passed,
2590            "total_checks_failed": total_failed,
2591            "overall_pass_rate": overall_rate,
2592            "total_elapsed_seconds": total_elapsed.as_secs_f64(),
2593            "targets": per_target_summaries,
2594        });
2595
2596        let summary_path = self.output.join("multi-target-conformance-summary.json");
2597        let summary_str = serde_json::to_string_pretty(&combined_summary)
2598            .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
2599        std::fs::write(&summary_path, &summary_str)
2600            .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
2601        TerminalReporter::print_success(&format!(
2602            "Combined summary saved to: {}",
2603            summary_path.display()
2604        ));
2605
2606        Ok(())
2607    }
2608
2609    /// Execute OWASP API Security Top 10 testing mode
2610    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
2611        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
2612
2613        // Parse custom headers from CLI
2614        let custom_headers = self.parse_headers()?;
2615
2616        // Build OWASP configuration from CLI options
2617        let mut config = OwaspApiConfig::new()
2618            .with_auth_header(&self.owasp_auth_header)
2619            .with_verbose(self.verbose)
2620            .with_insecure(self.skip_tls_verify)
2621            .with_concurrency(self.vus as usize)
2622            .with_iterations(self.owasp_iterations as usize)
2623            .with_base_path(self.base_path.clone())
2624            .with_custom_headers(custom_headers);
2625
2626        // Set valid auth token if provided
2627        if let Some(ref token) = self.owasp_auth_token {
2628            config = config.with_valid_auth_token(token);
2629        }
2630
2631        // Parse categories if provided
2632        if let Some(ref cats_str) = self.owasp_categories {
2633            let categories: Vec<OwaspCategory> = cats_str
2634                .split(',')
2635                .filter_map(|s| {
2636                    let trimmed = s.trim();
2637                    match trimmed.parse::<OwaspCategory>() {
2638                        Ok(cat) => Some(cat),
2639                        Err(e) => {
2640                            TerminalReporter::print_warning(&e);
2641                            None
2642                        }
2643                    }
2644                })
2645                .collect();
2646
2647            if !categories.is_empty() {
2648                config = config.with_categories(categories);
2649            }
2650        }
2651
2652        // Load admin paths from file if provided
2653        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
2654            config.admin_paths_file = Some(admin_paths_file.clone());
2655            if let Err(e) = config.load_admin_paths() {
2656                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
2657            }
2658        }
2659
2660        // Set ID fields if provided
2661        if let Some(ref id_fields_str) = self.owasp_id_fields {
2662            let id_fields: Vec<String> = id_fields_str
2663                .split(',')
2664                .map(|s| s.trim().to_string())
2665                .filter(|s| !s.is_empty())
2666                .collect();
2667            if !id_fields.is_empty() {
2668                config = config.with_id_fields(id_fields);
2669            }
2670        }
2671
2672        // Set report path and format
2673        if let Some(ref report_path) = self.owasp_report {
2674            config = config.with_report_path(report_path);
2675        }
2676        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
2677            config = config.with_report_format(format);
2678        }
2679
2680        // Print configuration summary
2681        let categories = config.categories_to_test();
2682        TerminalReporter::print_success(&format!(
2683            "Testing {} OWASP categories: {}",
2684            categories.len(),
2685            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
2686        ));
2687
2688        if config.valid_auth_token.is_some() {
2689            TerminalReporter::print_progress("Using provided auth token for baseline requests");
2690        }
2691
2692        // Create the OWASP generator
2693        TerminalReporter::print_progress("Generating OWASP security test script...");
2694        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
2695
2696        // Generate the script
2697        let script = generator.generate()?;
2698        TerminalReporter::print_success("OWASP security test script generated");
2699
2700        // Write script to file
2701        let script_path = if let Some(output) = &self.script_output {
2702            output.clone()
2703        } else {
2704            self.output.join("k6-owasp-security-test.js")
2705        };
2706
2707        if let Some(parent) = script_path.parent() {
2708            std::fs::create_dir_all(parent)?;
2709        }
2710        std::fs::write(&script_path, &script)?;
2711        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2712
2713        // If generate-only mode, exit here
2714        if self.generate_only {
2715            println!("\nOWASP security test script generated. Run it with:");
2716            println!("  k6 run {}", script_path.display());
2717            return Ok(());
2718        }
2719
2720        // Execute k6
2721        TerminalReporter::print_progress("Executing OWASP security tests...");
2722        let executor = K6Executor::new()?;
2723        std::fs::create_dir_all(&self.output)?;
2724
2725        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2726
2727        let duration_secs = Self::parse_duration(&self.duration)?;
2728        TerminalReporter::print_summary(&results, duration_secs);
2729
2730        println!("\nOWASP security test results saved to: {}", self.output.display());
2731
2732        Ok(())
2733    }
2734}
2735
2736#[cfg(test)]
2737mod tests {
2738    use super::*;
2739    use tempfile::tempdir;
2740
2741    #[test]
2742    fn test_parse_duration() {
2743        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
2744        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
2745        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
2746        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
2747    }
2748
2749    #[test]
2750    fn test_parse_duration_invalid() {
2751        assert!(BenchCommand::parse_duration("invalid").is_err());
2752        assert!(BenchCommand::parse_duration("30x").is_err());
2753    }
2754
2755    #[test]
2756    fn test_parse_headers() {
2757        let cmd = BenchCommand {
2758            spec: vec![PathBuf::from("test.yaml")],
2759            spec_dir: None,
2760            merge_conflicts: "error".to_string(),
2761            spec_mode: "merge".to_string(),
2762            dependency_config: None,
2763            target: "http://localhost".to_string(),
2764            base_path: None,
2765            duration: "1m".to_string(),
2766            vus: 10,
2767            scenario: "ramp-up".to_string(),
2768            operations: None,
2769            exclude_operations: None,
2770            auth: None,
2771            headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
2772            output: PathBuf::from("output"),
2773            generate_only: false,
2774            script_output: None,
2775            threshold_percentile: "p(95)".to_string(),
2776            threshold_ms: 500,
2777            max_error_rate: 0.05,
2778            verbose: false,
2779            skip_tls_verify: false,
2780            chunked_request_bodies: false,
2781            targets_file: None,
2782            max_concurrency: None,
2783            results_format: "both".to_string(),
2784            params_file: None,
2785            crud_flow: false,
2786            flow_config: None,
2787            extract_fields: None,
2788            parallel_create: None,
2789            data_file: None,
2790            data_distribution: "unique-per-vu".to_string(),
2791            data_mappings: None,
2792            per_uri_control: false,
2793            error_rate: None,
2794            error_types: None,
2795            security_test: false,
2796            security_payloads: None,
2797            security_categories: None,
2798            security_target_fields: None,
2799            wafbench_dir: None,
2800            wafbench_cycle_all: false,
2801            owasp_api_top10: false,
2802            owasp_categories: None,
2803            owasp_auth_header: "Authorization".to_string(),
2804            owasp_auth_token: None,
2805            owasp_admin_paths: None,
2806            owasp_id_fields: None,
2807            owasp_report: None,
2808            owasp_report_format: "json".to_string(),
2809            owasp_iterations: 1,
2810            conformance: false,
2811            conformance_api_key: None,
2812            conformance_basic_auth: None,
2813            conformance_report: PathBuf::from("conformance-report.json"),
2814            conformance_categories: None,
2815            conformance_report_format: "json".to_string(),
2816            conformance_headers: vec![],
2817            conformance_all_operations: false,
2818            conformance_custom: None,
2819            conformance_delay_ms: 0,
2820            use_k6: false,
2821            conformance_custom_filter: None,
2822            export_requests: false,
2823            validate_requests: false,
2824        };
2825
2826        let headers = cmd.parse_headers().unwrap();
2827        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
2828        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
2829    }
2830
2831    #[test]
2832    fn test_get_spec_display_name() {
2833        let cmd = BenchCommand {
2834            spec: vec![PathBuf::from("test.yaml")],
2835            spec_dir: None,
2836            merge_conflicts: "error".to_string(),
2837            spec_mode: "merge".to_string(),
2838            dependency_config: None,
2839            target: "http://localhost".to_string(),
2840            base_path: None,
2841            duration: "1m".to_string(),
2842            vus: 10,
2843            scenario: "ramp-up".to_string(),
2844            operations: None,
2845            exclude_operations: None,
2846            auth: None,
2847            headers: None,
2848            output: PathBuf::from("output"),
2849            generate_only: false,
2850            script_output: None,
2851            threshold_percentile: "p(95)".to_string(),
2852            threshold_ms: 500,
2853            max_error_rate: 0.05,
2854            verbose: false,
2855            skip_tls_verify: false,
2856            chunked_request_bodies: false,
2857            targets_file: None,
2858            max_concurrency: None,
2859            results_format: "both".to_string(),
2860            params_file: None,
2861            crud_flow: false,
2862            flow_config: None,
2863            extract_fields: None,
2864            parallel_create: None,
2865            data_file: None,
2866            data_distribution: "unique-per-vu".to_string(),
2867            data_mappings: None,
2868            per_uri_control: false,
2869            error_rate: None,
2870            error_types: None,
2871            security_test: false,
2872            security_payloads: None,
2873            security_categories: None,
2874            security_target_fields: None,
2875            wafbench_dir: None,
2876            wafbench_cycle_all: false,
2877            owasp_api_top10: false,
2878            owasp_categories: None,
2879            owasp_auth_header: "Authorization".to_string(),
2880            owasp_auth_token: None,
2881            owasp_admin_paths: None,
2882            owasp_id_fields: None,
2883            owasp_report: None,
2884            owasp_report_format: "json".to_string(),
2885            owasp_iterations: 1,
2886            conformance: false,
2887            conformance_api_key: None,
2888            conformance_basic_auth: None,
2889            conformance_report: PathBuf::from("conformance-report.json"),
2890            conformance_categories: None,
2891            conformance_report_format: "json".to_string(),
2892            conformance_headers: vec![],
2893            conformance_all_operations: false,
2894            conformance_custom: None,
2895            conformance_delay_ms: 0,
2896            use_k6: false,
2897            conformance_custom_filter: None,
2898            export_requests: false,
2899            validate_requests: false,
2900        };
2901
2902        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
2903
2904        // Test multiple specs
2905        let cmd_multi = BenchCommand {
2906            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
2907            spec_dir: None,
2908            merge_conflicts: "error".to_string(),
2909            spec_mode: "merge".to_string(),
2910            dependency_config: None,
2911            target: "http://localhost".to_string(),
2912            base_path: None,
2913            duration: "1m".to_string(),
2914            vus: 10,
2915            scenario: "ramp-up".to_string(),
2916            operations: None,
2917            exclude_operations: None,
2918            auth: None,
2919            headers: None,
2920            output: PathBuf::from("output"),
2921            generate_only: false,
2922            script_output: None,
2923            threshold_percentile: "p(95)".to_string(),
2924            threshold_ms: 500,
2925            max_error_rate: 0.05,
2926            verbose: false,
2927            skip_tls_verify: false,
2928            chunked_request_bodies: false,
2929            targets_file: None,
2930            max_concurrency: None,
2931            results_format: "both".to_string(),
2932            params_file: None,
2933            crud_flow: false,
2934            flow_config: None,
2935            extract_fields: None,
2936            parallel_create: None,
2937            data_file: None,
2938            data_distribution: "unique-per-vu".to_string(),
2939            data_mappings: None,
2940            per_uri_control: false,
2941            error_rate: None,
2942            error_types: None,
2943            security_test: false,
2944            security_payloads: None,
2945            security_categories: None,
2946            security_target_fields: None,
2947            wafbench_dir: None,
2948            wafbench_cycle_all: false,
2949            owasp_api_top10: false,
2950            owasp_categories: None,
2951            owasp_auth_header: "Authorization".to_string(),
2952            owasp_auth_token: None,
2953            owasp_admin_paths: None,
2954            owasp_id_fields: None,
2955            owasp_report: None,
2956            owasp_report_format: "json".to_string(),
2957            owasp_iterations: 1,
2958            conformance: false,
2959            conformance_api_key: None,
2960            conformance_basic_auth: None,
2961            conformance_report: PathBuf::from("conformance-report.json"),
2962            conformance_categories: None,
2963            conformance_report_format: "json".to_string(),
2964            conformance_headers: vec![],
2965            conformance_all_operations: false,
2966            conformance_custom: None,
2967            conformance_delay_ms: 0,
2968            use_k6: false,
2969            conformance_custom_filter: None,
2970            export_requests: false,
2971            validate_requests: false,
2972        };
2973
2974        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2975    }
2976
2977    #[test]
2978    fn test_parse_extracted_values_from_output_dir() {
2979        let dir = tempdir().unwrap();
2980        let path = dir.path().join("extracted_values.json");
2981        std::fs::write(
2982            &path,
2983            r#"{
2984  "pool_id": "abc123",
2985  "count": 0,
2986  "enabled": false,
2987  "metadata": { "owner": "team-a" }
2988}"#,
2989        )
2990        .unwrap();
2991
2992        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
2993        assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
2994        assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
2995        assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
2996        assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
2997    }
2998
2999    #[test]
3000    fn test_parse_extracted_values_missing_file() {
3001        let dir = tempdir().unwrap();
3002        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
3003        assert!(extracted.values.is_empty());
3004    }
3005}