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