cli_testing_specialist/generator/
test_generator.rs

1use crate::analyzer::BehaviorInferrer;
2use crate::config::load_config;
3use crate::error::Result;
4use crate::types::{
5    Assertion, CliAnalysis, CliOption, CliTestConfig, NoArgsBehavior, OptionType, TestCase,
6    TestCategory, TestPriority,
7};
8use crate::utils::{choose_strategy, ParallelStrategy, Workload};
9use rayon::prelude::*;
10use std::path::Path;
11
12/// Test generator for creating test cases from CLI analysis
13pub struct TestGenerator {
14    /// CLI analysis to generate tests from
15    analysis: CliAnalysis,
16
17    /// Categories to generate tests for
18    categories: Vec<TestCategory>,
19
20    /// Optional configuration for test adjustments
21    config: Option<CliTestConfig>,
22}
23
24impl TestGenerator {
25    /// Create a new test generator
26    pub fn new(analysis: CliAnalysis, categories: Vec<TestCategory>) -> Self {
27        Self {
28            analysis,
29            categories,
30            config: None,
31        }
32    }
33
34    /// Create a new test generator with configuration file
35    pub fn with_config(
36        analysis: CliAnalysis,
37        categories: Vec<TestCategory>,
38        config_path: Option<&Path>,
39    ) -> Result<Self> {
40        let config = load_config(config_path)?;
41        Ok(Self {
42            analysis,
43            categories,
44            config,
45        })
46    }
47
48    /// Generate all test cases based on selected categories
49    ///
50    /// # Examples
51    ///
52    /// ```no_run
53    /// use cli_testing_specialist::analyzer::CliParser;
54    /// use cli_testing_specialist::generator::TestGenerator;
55    /// use cli_testing_specialist::types::TestCategory;
56    /// use std::path::Path;
57    ///
58    /// let parser = CliParser::new();
59    /// let analysis = parser.analyze(Path::new("/usr/bin/curl"))?;
60    ///
61    /// let generator = TestGenerator::new(
62    ///     analysis,
63    ///     vec![TestCategory::Basic, TestCategory::Security, TestCategory::Help]
64    /// );
65    ///
66    /// let tests = generator.generate()?;
67    /// println!("Generated {} test cases", tests.len());
68    ///
69    /// // Count tests by category
70    /// let basic_tests = tests.iter()
71    ///     .filter(|t| t.category == TestCategory::Basic)
72    ///     .count();
73    /// println!("Basic tests: {}", basic_tests);
74    /// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
75    /// ```
76    pub fn generate(&self) -> Result<Vec<TestCase>> {
77        log::info!("Generating tests for {} categories", self.categories.len());
78
79        let mut all_tests = Vec::new();
80
81        for category in &self.categories {
82            let tests = match category {
83                TestCategory::Basic => self.generate_basic_tests()?,
84                TestCategory::Help => self.generate_help_tests()?,
85                TestCategory::Security => self.generate_security_tests()?,
86                TestCategory::Path => self.generate_path_tests()?,
87                TestCategory::InputValidation => self.generate_input_validation_tests()?,
88                TestCategory::DestructiveOps => self.generate_destructive_ops_tests()?,
89                TestCategory::DirectoryTraversal => self.generate_directory_traversal_tests()?,
90                TestCategory::Performance => self.generate_performance_tests()?,
91                TestCategory::MultiShell => self.generate_multi_shell_tests()?,
92            };
93
94            log::info!("Generated {} tests for {:?}", tests.len(), category);
95            all_tests.extend(tests);
96        }
97
98        log::info!("Total tests generated: {}", all_tests.len());
99        Ok(all_tests)
100    }
101
102    /// Generate tests in parallel using rayon
103    ///
104    /// Automatically chooses optimal parallelization strategy based on workload size.
105    /// For large CLI tools (10+ subcommands), this provides 2-3x speedup.
106    ///
107    /// # Examples
108    ///
109    /// ```no_run
110    /// use cli_testing_specialist::analyzer::CliParser;
111    /// use cli_testing_specialist::generator::TestGenerator;
112    /// use cli_testing_specialist::types::TestCategory;
113    /// use std::path::Path;
114    ///
115    /// let parser = CliParser::new();
116    /// let analysis = parser.analyze(Path::new("/usr/bin/kubectl"))?;
117    ///
118    /// let generator = TestGenerator::new(
119    ///     analysis,
120    ///     vec![TestCategory::Basic, TestCategory::Security]
121    /// );
122    ///
123    /// // Use parallel generation for large CLI tools
124    /// let tests = generator.generate_parallel()?;
125    /// println!("Generated {} tests in parallel", tests.len());
126    /// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
127    /// ```
128    pub fn generate_parallel(&self) -> Result<Vec<TestCase>> {
129        log::info!(
130            "Generating tests in parallel for {} categories",
131            self.categories.len()
132        );
133
134        let results: Result<Vec<Vec<TestCase>>> = self
135            .categories
136            .par_iter()
137            .map(|category| match category {
138                TestCategory::Basic => self.generate_basic_tests(),
139                TestCategory::Help => self.generate_help_tests(),
140                TestCategory::Security => self.generate_security_tests(),
141                TestCategory::Path => self.generate_path_tests(),
142                TestCategory::InputValidation => self.generate_input_validation_tests(),
143                TestCategory::DestructiveOps => self.generate_destructive_ops_tests(),
144                TestCategory::DirectoryTraversal => self.generate_directory_traversal_tests(),
145                TestCategory::Performance => self.generate_performance_tests(),
146                TestCategory::MultiShell => self.generate_multi_shell_tests(),
147            })
148            .collect();
149
150        let all_tests: Vec<TestCase> = results?.into_iter().flatten().collect();
151
152        log::info!("Total tests generated (parallel): {}", all_tests.len());
153        Ok(all_tests)
154    }
155
156    /// Generate tests with automatic strategy selection
157    ///
158    /// This is the recommended method for test generation. It automatically
159    /// chooses the optimal parallel processing strategy based on:
160    /// - Number of test categories
161    /// - CLI complexity (options and subcommands)
162    /// - Available CPU cores
163    ///
164    /// # Strategy Selection
165    ///
166    /// - **Sequential**: Small workloads (<20 tests, 1 category)
167    /// - **CategoryLevel**: Medium workloads (20-100 tests, multiple categories)
168    /// - **TestLevel**: Large workloads (100+ tests, 4+ CPU cores)
169    ///
170    /// # Examples
171    ///
172    /// ```ignore
173    /// let generator = TestGenerator::new(analysis, categories);
174    /// let tests = generator.generate_with_strategy()?;
175    /// ```
176    pub fn generate_with_strategy(&self) -> Result<Vec<TestCase>> {
177        // Create workload descriptor
178        let workload = Workload::new(
179            &self.categories,
180            self.analysis.global_options.len(),
181            self.analysis.subcommands.len(),
182        );
183
184        // Choose optimal strategy
185        let strategy = choose_strategy(&workload);
186
187        log::info!(
188            "Auto-selected strategy: {:?} (categories={}, global_options={}, subcommands={}, estimated_tests={})",
189            strategy,
190            workload.num_categories,
191            self.analysis.global_options.len(),
192            self.analysis.subcommands.len(),
193            workload.total_estimated_tests()
194        );
195
196        // Execute based on strategy
197        match strategy {
198            ParallelStrategy::Sequential => {
199                log::debug!("Using sequential generation");
200                self.generate()
201            }
202            ParallelStrategy::CategoryLevel => {
203                log::debug!("Using category-level parallel generation");
204                self.generate_parallel()
205            }
206            ParallelStrategy::TestLevel => {
207                log::debug!("Using test-level parallel generation");
208                // Use category-level parallelism as base
209                // Individual categories (e.g., Help with 10+ subcommands) automatically
210                // use test-level parallelism internally
211                self.generate_parallel()
212            }
213        }
214    }
215
216    /// Generate basic validation tests (help, version, exit codes)
217    fn generate_basic_tests(&self) -> Result<Vec<TestCase>> {
218        let mut tests = Vec::new();
219
220        // Test 1: Help flag
221        tests.push(
222            TestCase::new(
223                "basic-001".to_string(),
224                "Display help with --help flag".to_string(),
225                TestCategory::Basic,
226                "\"$CLI_BINARY\" --help".to_string(),
227            )
228            .with_exit_code(0)
229            .with_assertion(Assertion::OutputContains("Usage:".to_string()))
230            .with_tag("help".to_string()),
231        );
232
233        // Test 2: Short help flag
234        tests.push(
235            TestCase::new(
236                "basic-002".to_string(),
237                "Display help with -h flag".to_string(),
238                TestCategory::Basic,
239                "\"$CLI_BINARY\" -h".to_string(),
240            )
241            .with_exit_code(0)
242            .with_assertion(Assertion::OutputContains("Usage:".to_string()))
243            .with_tag("help".to_string()),
244        );
245
246        // Test 3: Version flag (if version detected)
247        if self.analysis.version.is_some() {
248            tests.push(
249                TestCase::new(
250                    "basic-003".to_string(),
251                    "Display version with --version flag".to_string(),
252                    TestCategory::Basic,
253                    "\"$CLI_BINARY\" --version".to_string(),
254                )
255                .with_exit_code(0)
256                .with_tag("version".to_string()),
257            );
258        }
259
260        // Test 4: Invalid option
261        // Note: Different CLI frameworks use different exit codes for invalid options:
262        // - Rust (clap): exit code 2 (Unix standard for usage errors)
263        // - Node.js (commander.js): exit code 1
264        // - Python (argparse): exit code 2
265        // Accept any non-zero exit code to support all frameworks
266        tests.push(
267            TestCase::new(
268                "basic-004".to_string(),
269                "Reject invalid option".to_string(),
270                TestCategory::Basic,
271                "\"$CLI_BINARY\" --invalid-option-xyz".to_string(),
272            )
273            .expect_nonzero_exit() // Accept exit 1 or 2 (framework-agnostic)
274            // Match common error message patterns across different CLIs:
275            // - error, Error (most CLIs)
276            // - unknown, unrecognized (curl, many CLIs)
277            // - invalid (common in validation errors)
278            .with_assertion(Assertion::OutputMatches(
279                "(error|Error|unknown|unrecognized|invalid)".to_string(),
280            ))
281            .with_tag("error-handling".to_string()),
282        );
283
284        // Test 5: No arguments (behavior depends on CLI type)
285        let inferrer = BehaviorInferrer::new();
286        let no_args_behavior = inferrer.infer_no_args_behavior(&self.analysis);
287
288        match no_args_behavior {
289            NoArgsBehavior::ShowHelp => {
290                tests.push(
291                    TestCase::new(
292                        "basic-005".to_string(),
293                        "Show help when invoked without arguments".to_string(),
294                        TestCategory::Basic,
295                        "\"$CLI_BINARY\"".to_string(),
296                    )
297                    .with_exit_code(0)
298                    // Output check removed: Some CLIs output nothing
299                    // (e.g., backup-suite exits silently with code 0)
300                    .with_priority(TestPriority::Important)
301                    .with_tag("no-args".to_string())
302                    .with_tag("show-help".to_string()),
303                );
304            }
305
306            NoArgsBehavior::RequireSubcommand => {
307                tests.push(
308                    TestCase::new(
309                        "basic-005".to_string(),
310                        "Require subcommand when invoked without arguments".to_string(),
311                        TestCategory::Basic,
312                        "\"$CLI_BINARY\"".to_string(),
313                    )
314                    .expect_nonzero_exit() // Accept exit 1 or 2
315                    // Output check removed: CLIs show different error formats
316                    // (short error message vs full help text)
317                    .with_priority(TestPriority::Important)
318                    .with_tag("no-args".to_string())
319                    .with_tag("require-subcommand".to_string()),
320                );
321            }
322
323            NoArgsBehavior::Interactive => {
324                tests.push(
325                    TestCase::new(
326                        "basic-005".to_string(),
327                        "Enter interactive mode when invoked without arguments".to_string(),
328                        TestCategory::Basic,
329                        "echo '' | \"$CLI_BINARY\"".to_string(), // Pipe empty input to exit immediately
330                    )
331                    .with_exit_code(0)
332                    .with_priority(TestPriority::Important)
333                    .with_tag("no-args".to_string())
334                    .with_tag("interactive".to_string()),
335                );
336            }
337        }
338
339        Ok(tests)
340    }
341
342    /// Generate help display tests
343    fn generate_help_tests(&self) -> Result<Vec<TestCase>> {
344        // For small number of subcommands, use simple sequential generation
345        // Parallel overhead not worth it for <10 subcommands
346        if self.analysis.subcommands.len() < 10 {
347            let mut tests = Vec::new();
348
349            for (idx, subcommand) in self.analysis.subcommands.iter().enumerate() {
350                // Skip 'help' meta-command
351                if subcommand.name.to_lowercase() == "help" {
352                    log::debug!("Skipping help test for meta-command 'help'");
353                    continue;
354                }
355
356                tests.push(
357                    TestCase::new(
358                        format!("help-{:03}", idx + 1),
359                        format!("Display help for subcommand '{}'", subcommand.name),
360                        TestCategory::Help,
361                        format!("\"$CLI_BINARY\" {} --help", subcommand.name),
362                    )
363                    .with_exit_code(0)
364                    .with_assertion(Assertion::OutputContains("Usage:".to_string()))
365                    .with_tag("subcommand-help".to_string())
366                    .with_tag(subcommand.name.clone()),
367                );
368            }
369
370            return Ok(tests);
371        }
372
373        // For large number of subcommands (10+), use parallel generation
374        let tests: Vec<TestCase> = self
375            .analysis
376            .subcommands
377            .par_iter()
378            .enumerate()
379            .filter_map(|(idx, subcommand)| {
380                // Skip 'help' meta-command
381                if subcommand.name.to_lowercase() == "help" {
382                    log::debug!("Skipping help test for meta-command 'help'");
383                    return None;
384                }
385
386                Some(
387                    TestCase::new(
388                        format!("help-{:03}", idx + 1),
389                        format!("Display help for subcommand '{}'", subcommand.name),
390                        TestCategory::Help,
391                        format!("\"$CLI_BINARY\" {} --help", subcommand.name),
392                    )
393                    .with_exit_code(0)
394                    .with_assertion(Assertion::OutputContains("Usage:".to_string()))
395                    .with_tag("subcommand-help".to_string())
396                    .with_tag(subcommand.name.clone()),
397                )
398            })
399            .collect();
400
401        Ok(tests)
402    }
403
404    /// Generate security scanner tests
405    ///
406    /// **IMPORTANT**: Security tests MUST expect non-zero exit codes for malicious inputs.
407    /// A tool that accepts malicious input (exit code 0) is vulnerable.
408    ///
409    /// # Security Test Philosophy
410    ///
411    /// - **Injection attacks**: Tool MUST reject with non-zero exit code (1 or 2)
412    /// - **Null bytes**: Tool MUST reject with non-zero exit code (1 or 2)
413    /// - **Path traversal**: Tool MUST reject with non-zero exit code (1 or 2)
414    /// - **Buffer overflow**: Tool MUST handle gracefully (may succeed if sanitized)
415    ///
416    /// # Unix Exit Code Convention
417    ///
418    /// - **0**: Success
419    /// - **1**: General error (runtime error, validation failure)
420    /// - **2**: Command-line usage error (invalid option, clap/argparse default)
421    ///
422    /// Modern CLI parsers (clap, commander, argparse) return exit code 2 for invalid options,
423    /// which is the correct Unix convention. Security tests accept both 1 and 2 as valid rejection.
424    fn generate_security_tests(&self) -> Result<Vec<TestCase>> {
425        let mut tests = Vec::new();
426
427        // Get skip_options from config if available
428        let skip_options: Vec<String> = self
429            .config
430            .as_ref()
431            .and_then(|c| {
432                c.test_adjustments
433                    .security
434                    .as_ref()
435                    .map(|s| s.skip_options.iter().map(|opt| opt.name.clone()).collect())
436            })
437            .unwrap_or_default();
438
439        // Find a string option for testing (prefer --config, --file, or first string option)
440        // Skip options that are in skip_options list
441        let string_option = self
442            .analysis
443            .global_options
444            .iter()
445            .find(|opt| {
446                matches!(opt.option_type, OptionType::String | OptionType::Path)
447                    && opt.long.is_some()
448                    && !skip_options.iter().any(|skip_name| {
449                        opt.long
450                            .as_ref()
451                            .is_some_and(|long| long.trim_start_matches("--") == skip_name)
452                    })
453            })
454            .and_then(|opt| opt.long.as_ref())
455            .unwrap_or(&"--invalid-option".to_string())
456            .clone();
457
458        // Test 1: Command injection via option
459        // MUST reject malicious input (any non-zero exit code)
460        tests.push(
461            TestCase::new(
462                "security-001".to_string(),
463                "Reject command injection in option value".to_string(),
464                TestCategory::Security,
465                format!("\"$CLI_BINARY\" {} 'test; rm -rf /'", string_option),
466            )
467            .expect_nonzero_exit() // Accept exit code 1, 2, or any non-zero
468            .with_priority(TestPriority::SecurityCheck)
469            .with_tag("injection".to_string())
470            .with_tag("critical".to_string()),
471        );
472
473        // Test 2: Null byte injection
474        // MUST reject malicious input (any non-zero exit code)
475        tests.push(
476            TestCase::new(
477                "security-002".to_string(),
478                "Reject null byte in option value".to_string(),
479                TestCategory::Security,
480                format!(
481                    r#""$CLI_BINARY" {} $'/tmp/test\x00malicious'"#,
482                    string_option
483                ),
484            )
485            .expect_nonzero_exit() // Accept exit code 1, 2, or any non-zero
486            .with_priority(TestPriority::SecurityCheck)
487            .with_tag("injection".to_string())
488            .with_tag("critical".to_string()),
489        );
490
491        // Test 3: Path traversal
492        // MUST reject path traversal attempt (any non-zero exit code)
493        tests.push(
494            TestCase::new(
495                "security-003".to_string(),
496                "Reject path traversal attempt".to_string(),
497                TestCategory::Security,
498                format!("\"$CLI_BINARY\" {} ../../../etc/passwd", string_option),
499            )
500            .expect_nonzero_exit() // Accept exit code 1, 2, or any non-zero
501            .with_priority(TestPriority::SecurityCheck)
502            .with_tag("path-traversal".to_string())
503            .with_tag("critical".to_string()),
504        );
505
506        // Test 4: Long input (buffer overflow test)
507        // NOTE: Disabled by default due to platform-dependent behavior
508        // - Node.js: May fail with E2BIG (Argument list too long) - OS limit
509        // - Shell: May fail with ARG_MAX exceeded - OS limit (typically 128KB-2MB)
510        // - Different platforms have different limits (macOS: 256KB, Linux: 2MB)
511        //
512        // This test is informational and should only be enabled for:
513        // - Low-level languages (C/C++, Rust with unsafe code)
514        // - Tools handling binary data or parsing untrusted input
515        //
516        // For most CLI tools (especially Node.js), this test is not meaningful
517        // and will fail due to OS argument length limits, not application bugs.
518        //
519        // Uncomment to enable (not recommended for Node.js CLIs):
520        // let long_input = "A".repeat(10000);
521        // tests.push(
522        //     TestCase::new(
523        //         "security-004".to_string(),
524        //         "Handle extremely long input without crashing".to_string(),
525        //         TestCategory::Security,
526        //         format!("\"$CLI_BINARY\" {} '{}'", string_option, long_input),
527        //     )
528        //     .expect_nonzero_exit() // Expect rejection (OS limit or input validation)
529        //     .with_priority(TestPriority::Important) // Informational test
530        //     .with_tag("buffer-overflow".to_string())
531        //     .with_tag("dos-protection".to_string())
532        //     .with_tag("informational".to_string()),
533        // );
534
535        // Add custom security tests from config
536        if let Some(config) = &self.config {
537            if let Some(security_config) = &config.test_adjustments.security {
538                for (idx, custom_test) in security_config.custom_tests.iter().enumerate() {
539                    tests.push(
540                        TestCase::new(
541                            format!("security-custom-{:03}", idx + 1),
542                            custom_test.description.clone(),
543                            TestCategory::Security,
544                            custom_test.command.clone(),
545                        )
546                        .with_exit_code(custom_test.expected_exit_code)
547                        .with_priority(TestPriority::SecurityCheck)
548                        .with_tag("custom".to_string())
549                        .with_tag(custom_test.name.clone()),
550                    );
551                }
552            }
553        }
554
555        Ok(tests)
556    }
557
558    /// Generate path handling tests
559    fn generate_path_tests(&self) -> Result<Vec<TestCase>> {
560        let mut tests = Vec::new();
561
562        // Find path options
563        let path_options: Vec<&CliOption> = self
564            .analysis
565            .global_options
566            .iter()
567            .filter(|opt| matches!(opt.option_type, OptionType::Path))
568            .collect();
569
570        if path_options.is_empty() {
571            log::debug!("No path options found, generating generic path tests");
572            return Ok(tests);
573        }
574
575        for (idx, option) in path_options.iter().enumerate() {
576            let flag = option.long.as_ref().or(option.short.as_ref()).unwrap();
577
578            // Test 1: Path with spaces
579            tests.push(
580                TestCase::new(
581                    format!("path-{:03}-spaces", idx + 1),
582                    format!("Handle path with spaces for {}", flag),
583                    TestCategory::Path,
584                    format!("\"$CLI_BINARY\" {} '/tmp/test dir/file.txt'", flag),
585                )
586                .with_tag("spaces".to_string()),
587            );
588
589            // Test 2: Unicode path
590            tests.push(
591                TestCase::new(
592                    format!("path-{:03}-unicode", idx + 1),
593                    format!("Handle Unicode in path for {}", flag),
594                    TestCategory::Path,
595                    format!("\"$CLI_BINARY\" {} '/tmp/ใƒ†ใ‚นใƒˆ/file.txt'", flag),
596                )
597                .with_tag("unicode".to_string()),
598            );
599
600            // Test 3: Symbolic links
601            tests.push(
602                TestCase::new(
603                    format!("path-{:03}-symlink", idx + 1),
604                    format!("Handle symbolic links for {}", flag),
605                    TestCategory::Path,
606                    format!("\"$CLI_BINARY\" {} '/tmp/test-symlink'", flag),
607                )
608                .with_tag("symlink".to_string()),
609            );
610        }
611
612        Ok(tests)
613    }
614
615    /// Generate input validation tests
616    fn generate_input_validation_tests(&self) -> Result<Vec<TestCase>> {
617        let mut tests = Vec::new();
618
619        // Find numeric options
620        let numeric_options: Vec<&CliOption> = self
621            .analysis
622            .global_options
623            .iter()
624            .filter(|opt| matches!(opt.option_type, OptionType::Numeric { .. }))
625            .collect();
626
627        for (idx, option) in numeric_options.iter().enumerate() {
628            let flag = option.long.as_ref().or(option.short.as_ref()).unwrap();
629
630            // Test 1: Valid value
631            tests.push(
632                TestCase::new(
633                    format!("input-{:03}-valid", idx + 1),
634                    format!("Accept valid numeric value for {}", flag),
635                    TestCategory::InputValidation,
636                    format!("\"$CLI_BINARY\" {} 10", flag),
637                )
638                .with_tag("numeric".to_string()),
639            );
640
641            // Test 2: Non-numeric value
642            tests.push(
643                TestCase::new(
644                    format!("input-{:03}-invalid", idx + 1),
645                    format!("Reject non-numeric value for {}", flag),
646                    TestCategory::InputValidation,
647                    format!("\"$CLI_BINARY\" {} 'not-a-number'", flag),
648                )
649                .with_exit_code(1)
650                .with_tag("numeric".to_string())
651                .with_tag("validation".to_string()),
652            );
653
654            // Test 3: Negative value (if min >= 0)
655            if let OptionType::Numeric {
656                min: Some(min_val), ..
657            } = &option.option_type
658            {
659                if *min_val >= 0 {
660                    tests.push(
661                        TestCase::new(
662                            format!("input-{:03}-negative", idx + 1),
663                            format!("Reject negative value for {}", flag),
664                            TestCategory::InputValidation,
665                            format!("\"$CLI_BINARY\" {} -1", flag),
666                        )
667                        .with_exit_code(1)
668                        .with_tag("numeric".to_string())
669                        .with_tag("validation".to_string()),
670                    );
671                }
672            }
673        }
674
675        // Find enum options
676        let enum_options: Vec<&CliOption> = self
677            .analysis
678            .global_options
679            .iter()
680            .filter(|opt| matches!(opt.option_type, OptionType::Enum { .. }))
681            .collect();
682
683        for (idx, option) in enum_options.iter().enumerate() {
684            let flag = option.long.as_ref().or(option.short.as_ref()).unwrap();
685
686            if let OptionType::Enum { values } = &option.option_type {
687                if let Some(first_value) = values.first() {
688                    // Test valid enum value
689                    tests.push(
690                        TestCase::new(
691                            format!("enum-{:03}-valid", idx + 1),
692                            format!("Accept valid enum value for {}", flag),
693                            TestCategory::InputValidation,
694                            format!("\"$CLI_BINARY\" {} {}", flag, first_value),
695                        )
696                        .with_tag("enum".to_string()),
697                    );
698                }
699
700                // Test invalid enum value
701                tests.push(
702                    TestCase::new(
703                        format!("enum-{:03}-invalid", idx + 1),
704                        format!("Reject invalid enum value for {}", flag),
705                        TestCategory::InputValidation,
706                        format!("\"$CLI_BINARY\" {} 'invalid-value-xyz'", flag),
707                    )
708                    .with_exit_code(1)
709                    .with_tag("enum".to_string())
710                    .with_tag("validation".to_string()),
711                );
712            }
713        }
714
715        Ok(tests)
716    }
717
718    /// Generate destructive operations tests
719    fn generate_destructive_ops_tests(&self) -> Result<Vec<TestCase>> {
720        let mut tests = Vec::new();
721
722        // Get env_vars and cancel_exit_code from config
723        let env_vars = self
724            .config
725            .as_ref()
726            .and_then(|c| c.test_adjustments.destructive_ops.as_ref())
727            .map(|d| d.env_vars.clone())
728            .unwrap_or_default();
729
730        let cancel_exit_code = self
731            .config
732            .as_ref()
733            .and_then(|c| c.test_adjustments.destructive_ops.as_ref())
734            .map(|d| d.cancel_exit_code)
735            .unwrap_or(1); // Default to 1 if not specified
736
737        // Look for destructive subcommands (delete, remove, clean, destroy, etc.)
738        let destructive_keywords = ["delete", "remove", "clean", "destroy", "purge", "drop"];
739
740        for subcommand in &self.analysis.subcommands {
741            let is_destructive = destructive_keywords
742                .iter()
743                .any(|keyword| subcommand.name.to_lowercase().contains(keyword));
744
745            if is_destructive {
746                // Generate dummy values for required arguments
747                let dummy_args = subcommand
748                    .required_args
749                    .iter()
750                    .map(|arg| {
751                        // Generate appropriate dummy value based on argument name
752                        match arg.to_lowercase().as_str() {
753                            "id" | "name" => "test-id",
754                            "file" | "path" => "/tmp/test-file",
755                            "dir" | "directory" => "/tmp/test-dir",
756                            _ => "test-value",
757                        }
758                    })
759                    .collect::<Vec<_>>()
760                    .join(" ");
761
762                let args_part = if dummy_args.is_empty() {
763                    String::new()
764                } else {
765                    format!(" {}", dummy_args)
766                };
767
768                // Build env vars prefix for commands
769                let env_prefix = if env_vars.is_empty() {
770                    String::new()
771                } else {
772                    env_vars
773                        .iter()
774                        .map(|(k, v)| format!("{}=\"{}\"", k, v))
775                        .collect::<Vec<_>>()
776                        .join(" ")
777                        + " "
778                };
779
780                // Test 1: Check for confirmation prompt (or skip if env vars set)
781                let test_command = if env_vars.is_empty() {
782                    // No env vars: test cancellation with 'n' input
783                    format!(
784                        "echo 'n' | \"$CLI_BINARY\" {}{}",
785                        subcommand.name, args_part
786                    )
787                } else {
788                    // With env vars: test auto-confirmation
789                    format!(
790                        "{}\"$CLI_BINARY\" {}{}",
791                        env_prefix, subcommand.name, args_part
792                    )
793                };
794
795                let mut test = TestCase::new(
796                    format!("destructive-{}-001", subcommand.name),
797                    if env_vars.is_empty() {
798                        format!("Subcommand '{}' requires confirmation", subcommand.name)
799                    } else {
800                        format!(
801                            "Subcommand '{}' auto-confirms with env vars",
802                            subcommand.name
803                        )
804                    },
805                    TestCategory::DestructiveOps,
806                    test_command,
807                )
808                .with_tag("confirmation".to_string())
809                .with_tag(subcommand.name.clone());
810
811                // Set expected exit code based on whether we're testing cancellation or execution
812                if env_vars.is_empty() {
813                    // Test cancellation: expect cancel_exit_code
814                    test = test.with_exit_code(cancel_exit_code);
815                }
816                // else: execution test, exit code depends on implementation (don't set)
817
818                tests.push(test);
819
820                // Test 2: Check for --yes or --force flag
821                let has_yes_flag = subcommand.options.iter().any(|opt| {
822                    opt.long
823                        .as_ref()
824                        .is_some_and(|l| l.contains("yes") || l.contains("force"))
825                });
826
827                if has_yes_flag {
828                    tests.push(
829                        TestCase::new(
830                            format!("destructive-{}-002", subcommand.name),
831                            format!("Subcommand '{}' accepts --yes flag", subcommand.name),
832                            TestCategory::DestructiveOps,
833                            format!("\"$CLI_BINARY\" {}{} --yes", subcommand.name, args_part),
834                        )
835                        .with_tag("force".to_string())
836                        .with_tag(subcommand.name.clone()),
837                    );
838                }
839            }
840        }
841
842        // If no destructive subcommands found, generate generic test
843        if tests.is_empty() {
844            log::debug!("No destructive subcommands detected");
845        }
846
847        Ok(tests)
848    }
849
850    /// Generate directory traversal tests
851    fn generate_directory_traversal_tests(&self) -> Result<Vec<TestCase>> {
852        let mut tests = Vec::new();
853
854        // Get test_directories from config or use defaults
855        let test_directories = self
856            .config
857            .as_ref()
858            .and_then(|c| c.test_adjustments.directory_traversal.as_ref())
859            .and_then(|dt| {
860                if dt.test_directories.is_empty() {
861                    None
862                } else {
863                    Some(dt.test_directories.clone())
864                }
865            });
866
867        if let Some(test_dirs) = test_directories {
868            // Use configured test directories
869            for (idx, test_dir) in test_dirs.iter().enumerate() {
870                let description = if let Some(file_count) = test_dir.file_count {
871                    format!(
872                        "Handle directory with {} files at {}",
873                        file_count, test_dir.path
874                    )
875                } else if let Some(depth) = test_dir.depth {
876                    format!(
877                        "Handle deeply nested directory (depth {}) at {}",
878                        depth, test_dir.path
879                    )
880                } else {
881                    format!("Handle directory at {}", test_dir.path)
882                };
883
884                tests.push(
885                    TestCase::new(
886                        format!("dir-traversal-{:03}", idx + 1),
887                        description,
888                        TestCategory::DirectoryTraversal,
889                        format!("\"$CLI_BINARY\" {}", test_dir.path),
890                    )
891                    .with_tag("configured".to_string())
892                    .with_tag(if test_dir.file_count.is_some() {
893                        "large-dir".to_string()
894                    } else if test_dir.depth.is_some() {
895                        "deep-nesting".to_string()
896                    } else {
897                        "basic".to_string()
898                    }),
899                );
900            }
901        } else {
902            // Use default tests
903            tests = vec![
904                // Test 1: Large directory (1000 files)
905                TestCase::new(
906                    "dir-traversal-001".to_string(),
907                    "Handle directory with 1000 files".to_string(),
908                    TestCategory::DirectoryTraversal,
909                    "\"$CLI_BINARY\" /tmp/test-large-dir".to_string(),
910                )
911                .with_tag("performance".to_string())
912                .with_tag("large-dir".to_string()),
913                // Test 2: Deep directory nesting (50 levels)
914                TestCase::new(
915                    "dir-traversal-002".to_string(),
916                    "Handle deeply nested directory (50 levels)".to_string(),
917                    TestCategory::DirectoryTraversal,
918                    "\"$CLI_BINARY\" /tmp/test-deep-dir".to_string(),
919                )
920                .with_tag("performance".to_string())
921                .with_tag("deep-nesting".to_string()),
922                // Test 3: Symlink loops
923                TestCase::new(
924                    "dir-traversal-003".to_string(),
925                    "Detect and handle symlink loops".to_string(),
926                    TestCategory::DirectoryTraversal,
927                    "\"$CLI_BINARY\" /tmp/test-symlink-loop".to_string(),
928                )
929                .with_tag("symlink".to_string())
930                .with_tag("loop-detection".to_string()),
931            ];
932        }
933
934        Ok(tests)
935    }
936
937    /// Generate performance tests
938    fn generate_performance_tests(&self) -> Result<Vec<TestCase>> {
939        let tests = vec![
940            // Test 1: Startup time (help should be fast)
941            TestCase::new(
942                "perf-001".to_string(),
943                "Startup time for --help < 100ms".to_string(),
944                TestCategory::Performance,
945                "\"$CLI_BINARY\" --help".to_string(),
946            )
947            .with_exit_code(0)
948            .with_tag("startup".to_string())
949            .with_tag("benchmark".to_string()),
950            // Test 2: Memory usage
951            TestCase::new(
952                "perf-002".to_string(),
953                "Memory usage stays within reasonable limits".to_string(),
954                TestCategory::Performance,
955                "\"$CLI_BINARY\" --help".to_string(),
956            )
957            .with_exit_code(0)
958            .with_tag("memory".to_string())
959            .with_tag("benchmark".to_string()),
960        ];
961
962        Ok(tests)
963    }
964
965    /// Generate multi-shell compatibility tests
966    fn generate_multi_shell_tests(&self) -> Result<Vec<TestCase>> {
967        let mut tests = Vec::new();
968
969        // Test basic command in different shells (bash, zsh, sh)
970        // Note: Use double quotes to allow variable expansion, escape inner quotes
971        for shell in &["bash", "zsh", "sh"] {
972            tests.push(
973                TestCase::new(
974                    format!("multi-shell-{}", shell),
975                    format!("Run --help in {}", shell),
976                    TestCategory::MultiShell,
977                    format!("{} -c \"\\\"$CLI_BINARY\\\" --help\"", shell),
978                )
979                .with_exit_code(0)
980                .with_tag(shell.to_string()),
981            );
982        }
983
984        Ok(tests)
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::types::Subcommand;
992    use std::path::PathBuf;
993
994    fn create_test_analysis() -> CliAnalysis {
995        let mut analysis = CliAnalysis::new(
996            PathBuf::from("/usr/bin/test-cli"),
997            "test-cli".to_string(),
998            "Test CLI help output".to_string(),
999        );
1000
1001        analysis.version = Some("1.0.0".to_string());
1002
1003        // Add a numeric option
1004        analysis.global_options.push(CliOption {
1005            short: Some("-t".to_string()),
1006            long: Some("--timeout".to_string()),
1007            description: Some("Timeout in seconds".to_string()),
1008            option_type: OptionType::Numeric {
1009                min: Some(0),
1010                max: Some(3600),
1011            },
1012            required: false,
1013            default_value: Some("30".to_string()),
1014        });
1015
1016        // Add a path option
1017        analysis.global_options.push(CliOption {
1018            short: Some("-f".to_string()),
1019            long: Some("--file".to_string()),
1020            description: Some("Input file".to_string()),
1021            option_type: OptionType::Path,
1022            required: false,
1023            default_value: None,
1024        });
1025
1026        // Add an enum option
1027        analysis.global_options.push(CliOption {
1028            short: None,
1029            long: Some("--format".to_string()),
1030            description: Some("Output format".to_string()),
1031            option_type: OptionType::Enum {
1032                values: vec!["json".to_string(), "yaml".to_string(), "text".to_string()],
1033            },
1034            required: false,
1035            default_value: Some("text".to_string()),
1036        });
1037
1038        // Add a subcommand
1039        analysis.subcommands.push(Subcommand {
1040            name: "delete".to_string(),
1041            description: Some("Delete resources".to_string()),
1042            options: vec![CliOption {
1043                short: None,
1044                long: Some("--yes".to_string()),
1045                description: Some("Skip confirmation".to_string()),
1046                option_type: OptionType::Flag,
1047                required: false,
1048                default_value: None,
1049            }],
1050            required_args: vec![],
1051            subcommands: vec![],
1052            depth: 0,
1053        });
1054
1055        analysis
1056    }
1057
1058    #[test]
1059    fn test_generator_creation() {
1060        let analysis = create_test_analysis();
1061        let categories = vec![TestCategory::Basic];
1062        let generator = TestGenerator::new(analysis, categories);
1063
1064        assert_eq!(generator.categories.len(), 1);
1065    }
1066
1067    #[test]
1068    fn test_generate_basic_tests() {
1069        let analysis = create_test_analysis();
1070        let generator = TestGenerator::new(analysis, vec![]);
1071
1072        let tests = generator.generate_basic_tests().unwrap();
1073
1074        assert!(!tests.is_empty());
1075        assert!(tests.iter().any(|t| t.command.contains("--help")));
1076        assert!(tests.iter().any(|t| t.command.contains("--version")));
1077    }
1078
1079    #[test]
1080    fn test_generate_security_tests() {
1081        let analysis = create_test_analysis();
1082        let generator = TestGenerator::new(analysis, vec![]);
1083
1084        let tests = generator.generate_security_tests().unwrap();
1085
1086        assert!(!tests.is_empty());
1087        assert!(tests
1088            .iter()
1089            .any(|t| t.tags.contains(&"injection".to_string())));
1090    }
1091
1092    #[test]
1093    fn test_generate_input_validation_tests() {
1094        let analysis = create_test_analysis();
1095        let generator = TestGenerator::new(analysis, vec![]);
1096
1097        let tests = generator.generate_input_validation_tests().unwrap();
1098
1099        assert!(!tests.is_empty());
1100        // Should have tests for numeric, path, and enum options
1101        assert!(tests
1102            .iter()
1103            .any(|t| t.tags.contains(&"numeric".to_string())));
1104        assert!(tests.iter().any(|t| t.tags.contains(&"enum".to_string())));
1105    }
1106
1107    #[test]
1108    fn test_generate_destructive_ops_tests() {
1109        let analysis = create_test_analysis();
1110        let generator = TestGenerator::new(analysis, vec![]);
1111
1112        let tests = generator.generate_destructive_ops_tests().unwrap();
1113
1114        assert!(!tests.is_empty());
1115        assert!(tests.iter().any(|t| t.command.contains("delete")));
1116    }
1117
1118    #[test]
1119    fn test_generate_all_categories() {
1120        let analysis = create_test_analysis();
1121        let categories = vec![
1122            TestCategory::Basic,
1123            TestCategory::Security,
1124            TestCategory::InputValidation,
1125        ];
1126        let generator = TestGenerator::new(analysis, categories);
1127
1128        let tests = generator.generate().unwrap();
1129
1130        assert!(!tests.is_empty());
1131        assert!(tests.iter().any(|t| t.category == TestCategory::Basic));
1132        assert!(tests.iter().any(|t| t.category == TestCategory::Security));
1133        assert!(tests
1134            .iter()
1135            .any(|t| t.category == TestCategory::InputValidation));
1136    }
1137
1138    #[test]
1139    fn test_generate_parallel() {
1140        let analysis = create_test_analysis();
1141        let categories = vec![
1142            TestCategory::Basic,
1143            TestCategory::Security,
1144            TestCategory::Performance,
1145        ];
1146        let generator = TestGenerator::new(analysis, categories);
1147
1148        let tests = generator.generate_parallel().unwrap();
1149
1150        assert!(!tests.is_empty());
1151    }
1152}