cli_testing_specialist/analyzer/
subcommand_detector.rs

1use crate::analyzer::cli_parser::CliParser;
2use crate::analyzer::option_inferrer::OptionInferrer;
3use crate::error::Result;
4use crate::types::analysis::Subcommand;
5use crate::utils::{execute_with_timeout, ResourceLimits};
6use lazy_static::lazy_static;
7use regex::Regex;
8use std::collections::HashSet;
9use std::path::Path;
10
11lazy_static! {
12    /// Regex pattern for subcommand lines in help output
13    /// Matches lines like:
14    /// - "  help      Show help information" (standard format)
15    /// - "  config    Manage configuration" (standard format)
16    /// - "  publish [options] [project-path]  Publish package to registry" (Commander.js format)
17    ///
18    /// Pattern breakdown:
19    /// - `^\s{2,}` - Line starts with 2+ spaces (indentation)
20    /// - `([a-z][a-z0-9-]+)` - Subcommand name (lowercase, alphanumeric, hyphens)
21    /// - `(?:\s+\[[^\]]+\])*` - Optional argument specifications like [options], [path] (Commander.js)
22    /// - `\s{2,}` - 2+ spaces separating command from description
23    /// - `(.+)$` - Description text
24    static ref SUBCOMMAND_PATTERN: Regex = Regex::new(r"^\s{2,}([a-z][a-z0-9-]+)(?:\s+\[[^\]]+\])*\s{2,}(.+)$").unwrap();
25
26    /// Common section headers that indicate subcommands section
27    static ref SUBCOMMAND_HEADERS: Vec<&'static str> = vec![
28        "Commands:",
29        "Available Commands:",
30        "Subcommands:",
31        "Available subcommands:",
32        "COMMANDS:",
33        "SUBCOMMANDS:",
34        "positional arguments:",  // Python argparse
35    ];
36}
37
38/// Maximum recursion depth for subcommand detection
39const MAX_RECURSION_DEPTH: u8 = 3;
40
41/// Subcommand Detector - Recursively detects CLI subcommands
42pub struct SubcommandDetector {
43    resource_limits: ResourceLimits,
44    option_inferrer: OptionInferrer,
45    max_depth: u8,
46}
47
48impl SubcommandDetector {
49    /// Create a new subcommand detector with default settings
50    pub fn new() -> Result<Self> {
51        Ok(Self {
52            resource_limits: ResourceLimits::default(),
53            option_inferrer: OptionInferrer::new()?,
54            max_depth: MAX_RECURSION_DEPTH,
55        })
56    }
57
58    /// Create a new subcommand detector with custom max depth
59    pub fn with_max_depth(max_depth: u8) -> Result<Self> {
60        Ok(Self {
61            resource_limits: ResourceLimits::default(),
62            option_inferrer: OptionInferrer::new()?,
63            max_depth,
64        })
65    }
66
67    /// Detect subcommands from help output
68    pub fn detect(&self, binary: &Path, help_output: &str) -> Result<Vec<Subcommand>> {
69        log::info!("Detecting subcommands for {}", binary.display());
70        self.detect_recursive(binary, help_output, 0, &mut HashSet::new())
71    }
72
73    /// Recursively detect subcommands
74    fn detect_recursive(
75        &self,
76        binary: &Path,
77        help_output: &str,
78        current_depth: u8,
79        visited: &mut HashSet<String>,
80    ) -> Result<Vec<Subcommand>> {
81        // Stop if max depth reached
82        if current_depth >= self.max_depth {
83            log::debug!("Max recursion depth {} reached", self.max_depth);
84            return Ok(vec![]);
85        }
86
87        // Parse subcommand names from help output
88        let subcommand_candidates = self.parse_subcommands(help_output);
89
90        if subcommand_candidates.is_empty() {
91            return Ok(vec![]);
92        }
93
94        log::debug!(
95            "Found {} subcommand candidates at depth {}",
96            subcommand_candidates.len(),
97            current_depth
98        );
99
100        let mut subcommands = Vec::new();
101
102        for (name, description) in subcommand_candidates {
103            // Skip if already visited (prevent circular references)
104            let visit_key = format!("{}-{}", binary.display(), name);
105            if visited.contains(&visit_key) {
106                log::debug!("Skipping already visited subcommand: {}", name);
107                continue;
108            }
109            visited.insert(visit_key);
110
111            // Get help output for this subcommand
112            let subcommand_help = match self.get_subcommand_help(binary, &name) {
113                Ok(help) => help,
114                Err(e) => {
115                    log::warn!("Failed to get help for subcommand '{}': {}", name, e);
116                    continue;
117                }
118            };
119
120            // Parse options for this subcommand
121            let cli_parser = CliParser::new();
122            let mut options = cli_parser.parse_options(&subcommand_help);
123
124            // Infer option types
125            self.option_inferrer.infer_types(&mut options);
126
127            // Parse required positional arguments
128            let required_args = cli_parser.parse_required_args(&subcommand_help);
129
130            // Recursively detect nested subcommands
131            let nested_subcommands =
132                self.detect_recursive(binary, &subcommand_help, current_depth + 1, visited)?;
133
134            subcommands.push(Subcommand {
135                name,
136                description: Some(description),
137                options,
138                required_args,
139                subcommands: nested_subcommands,
140                depth: current_depth,
141            });
142        }
143
144        log::info!(
145            "Detected {} subcommands at depth {}",
146            subcommands.len(),
147            current_depth
148        );
149
150        Ok(subcommands)
151    }
152
153    /// Parse subcommand names and descriptions from help output
154    fn parse_subcommands(&self, help_output: &str) -> Vec<(String, String)> {
155        let mut subcommands = Vec::new();
156        let mut in_subcommand_section = false;
157
158        for line in help_output.lines() {
159            // Check if we entered subcommands section
160            if !in_subcommand_section {
161                for header in SUBCOMMAND_HEADERS.iter() {
162                    if line.trim().starts_with(header) {
163                        in_subcommand_section = true;
164                        break;
165                    }
166                }
167                continue;
168            }
169
170            // Check if we left subcommands section (empty line or new section)
171            if line.trim().is_empty() {
172                in_subcommand_section = false;
173                continue;
174            }
175
176            // Parse subcommand line
177            if let Some(captures) = SUBCOMMAND_PATTERN.captures(line) {
178                let name = captures.get(1).unwrap().as_str().to_string();
179                let description = captures.get(2).unwrap().as_str().trim().to_string();
180
181                subcommands.push((name, description));
182            }
183        }
184
185        subcommands
186    }
187
188    /// Get help output for a specific subcommand
189    fn get_subcommand_help(&self, binary: &Path, subcommand: &str) -> Result<String> {
190        log::debug!("Getting help for subcommand: {}", subcommand);
191
192        // Try: <binary> <subcommand> --help
193        if let Ok(output) = execute_with_timeout(
194            binary,
195            &[subcommand, "--help"],
196            self.resource_limits.timeout(),
197        ) {
198            if !output.trim().is_empty() {
199                return Ok(output);
200            }
201        }
202
203        // Try: <binary> <subcommand> -h
204        if let Ok(output) =
205            execute_with_timeout(binary, &[subcommand, "-h"], self.resource_limits.timeout())
206        {
207            if !output.trim().is_empty() {
208                return Ok(output);
209            }
210        }
211
212        // Try: <binary> help <subcommand>
213        if let Ok(output) = execute_with_timeout(
214            binary,
215            &["help", subcommand],
216            self.resource_limits.timeout(),
217        ) {
218            if !output.trim().is_empty() {
219                return Ok(output);
220            }
221        }
222
223        Err(crate::error::CliTestError::InvalidHelpOutput)
224    }
225}
226
227impl Default for SubcommandDetector {
228    fn default() -> Self {
229        Self::new().unwrap_or_else(|_| Self {
230            resource_limits: ResourceLimits::default(),
231            option_inferrer: OptionInferrer::default(),
232            max_depth: MAX_RECURSION_DEPTH,
233        })
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_subcommand_pattern() {
243        assert!(SUBCOMMAND_PATTERN.is_match("  help      Show help information"));
244        assert!(SUBCOMMAND_PATTERN.is_match("  config    Manage configuration"));
245        assert!(SUBCOMMAND_PATTERN.is_match("    status    Show status"));
246        assert!(!SUBCOMMAND_PATTERN.is_match("Options:"));
247        assert!(!SUBCOMMAND_PATTERN.is_match("--help"));
248    }
249
250    #[test]
251    fn test_parse_subcommands_basic() {
252        let detector = SubcommandDetector::default();
253        let help_output = r#"
254Usage: test <command>
255
256Commands:
257  help      Show help information
258  config    Manage configuration
259  status    Show current status
260
261Options:
262  -h, --help    Show help
263"#;
264
265        let subcommands = detector.parse_subcommands(help_output);
266
267        assert_eq!(subcommands.len(), 3);
268        assert!(subcommands.iter().any(|(name, _)| name == "help"));
269        assert!(subcommands.iter().any(|(name, _)| name == "config"));
270        assert!(subcommands.iter().any(|(name, _)| name == "status"));
271    }
272
273    #[test]
274    fn test_parse_subcommands_with_description() {
275        let detector = SubcommandDetector::default();
276        let help_output = r#"
277Available Commands:
278  init    Initialize a new project
279  build   Build the project
280"#;
281
282        let subcommands = detector.parse_subcommands(help_output);
283
284        assert_eq!(subcommands.len(), 2);
285
286        let init_cmd = subcommands.iter().find(|(name, _)| name == "init");
287        assert!(init_cmd.is_some());
288        assert_eq!(init_cmd.unwrap().1, "Initialize a new project");
289    }
290
291    #[test]
292    fn test_parse_subcommands_empty() {
293        let detector = SubcommandDetector::default();
294        let help_output = r#"
295Usage: test [OPTIONS]
296
297Options:
298  -h, --help    Show help
299"#;
300
301        let subcommands = detector.parse_subcommands(help_output);
302
303        assert!(subcommands.is_empty());
304    }
305
306    #[test]
307    fn test_parse_subcommands_multiple_sections() {
308        let detector = SubcommandDetector::default();
309        let help_output = r#"
310Commands:
311  help    Show help
312
313Options:
314  --verbose    Verbose output
315
316Commands:
317  config    Configuration
318"#;
319
320        let subcommands = detector.parse_subcommands(help_output);
321
322        // Should find both "help" and "config"
323        assert_eq!(subcommands.len(), 2);
324    }
325
326    #[cfg(unix)]
327    #[test]
328    fn test_detect_git_subcommands() {
329        // Test with git if available
330        let git_path = Path::new("/usr/bin/git");
331        if !git_path.exists() {
332            return; // Skip if git not available
333        }
334
335        let detector = SubcommandDetector::with_max_depth(1).unwrap();
336
337        // Get git help output
338        if let Ok(help_output) =
339            execute_with_timeout(git_path, &["--help"], ResourceLimits::default().timeout())
340        {
341            let result = detector.detect(git_path, &help_output);
342
343            // Note: This test may fail if git's help format is different than expected
344            // or if parse_subcommands doesn't match git's specific format.
345            // That's okay - the test is primarily to verify the detector doesn't panic.
346            match result {
347                Ok(subcommands) => {
348                    // If we found subcommands, log them for debugging
349                    if !subcommands.is_empty() {
350                        log::debug!("Found {} git subcommands", subcommands.len());
351                    }
352                }
353                Err(e) => {
354                    log::warn!("Git subcommand detection failed (expected): {}", e);
355                }
356            }
357        }
358    }
359
360    #[test]
361    fn test_circular_reference_prevention() {
362        let _detector = SubcommandDetector::default();
363        let mut visited = HashSet::new();
364
365        // Simulate visiting a subcommand twice
366        visited.insert("/bin/test-help".to_string());
367
368        // This should be prevented by the visited set
369        // (In real scenario, this is tested via detect_recursive)
370        assert!(visited.contains("/bin/test-help"));
371    }
372
373    #[test]
374    fn test_max_depth_limit() {
375        let detector = SubcommandDetector::with_max_depth(2).unwrap();
376        assert_eq!(detector.max_depth, 2);
377
378        let detector_default = SubcommandDetector::default();
379        assert_eq!(detector_default.max_depth, MAX_RECURSION_DEPTH);
380    }
381
382    #[test]
383    fn test_parse_subcommands_python_argparse() {
384        let detector = SubcommandDetector::default();
385        let help_output = r#"
386usage: tool.py [-h] [--version] command ...
387
388positional arguments:
389  command
390    init      Initialize project
391    build     Build project
392    test      Run tests
393
394optional arguments:
395  -h, --help  show this help message and exit
396"#;
397
398        let subcommands = detector.parse_subcommands(help_output);
399
400        assert_eq!(subcommands.len(), 3);
401        assert!(subcommands.iter().any(|(name, _)| name == "init"));
402        assert!(subcommands.iter().any(|(name, _)| name == "build"));
403        assert!(subcommands.iter().any(|(name, _)| name == "test"));
404
405        let init_cmd = subcommands.iter().find(|(name, _)| name == "init");
406        assert!(init_cmd.is_some());
407        assert_eq!(init_cmd.unwrap().1, "Initialize project");
408    }
409}