cli_testing_specialist/analyzer/
subcommand_detector.rs1use 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 static ref SUBCOMMAND_PATTERN: Regex = Regex::new(r"^\s{2,}([a-z][a-z0-9-]+)(?:\s+\[[^\]]+\])*\s{2,}(.+)$").unwrap();
25
26 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:", ];
36}
37
38const MAX_RECURSION_DEPTH: u8 = 3;
40
41pub struct SubcommandDetector {
43 resource_limits: ResourceLimits,
44 option_inferrer: OptionInferrer,
45 max_depth: u8,
46}
47
48impl SubcommandDetector {
49 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 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 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 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 if current_depth >= self.max_depth {
83 log::debug!("Max recursion depth {} reached", self.max_depth);
84 return Ok(vec![]);
85 }
86
87 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 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 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 let cli_parser = CliParser::new();
122 let mut options = cli_parser.parse_options(&subcommand_help);
123
124 self.option_inferrer.infer_types(&mut options);
126
127 let required_args = cli_parser.parse_required_args(&subcommand_help);
129
130 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 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 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 if line.trim().is_empty() {
172 in_subcommand_section = false;
173 continue;
174 }
175
176 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 fn get_subcommand_help(&self, binary: &Path, subcommand: &str) -> Result<String> {
190 log::debug!("Getting help for subcommand: {}", subcommand);
191
192 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 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 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 assert_eq!(subcommands.len(), 2);
324 }
325
326 #[cfg(unix)]
327 #[test]
328 fn test_detect_git_subcommands() {
329 let git_path = Path::new("/usr/bin/git");
331 if !git_path.exists() {
332 return; }
334
335 let detector = SubcommandDetector::with_max_depth(1).unwrap();
336
337 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 match result {
347 Ok(subcommands) => {
348 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 visited.insert("/bin/test-help".to_string());
367
368 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}