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 ];
35}
36
37const MAX_RECURSION_DEPTH: u8 = 3;
39
40pub struct SubcommandDetector {
42 resource_limits: ResourceLimits,
43 option_inferrer: OptionInferrer,
44 max_depth: u8,
45}
46
47impl SubcommandDetector {
48 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 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 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 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 if current_depth >= self.max_depth {
82 log::debug!("Max recursion depth {} reached", self.max_depth);
83 return Ok(vec![]);
84 }
85
86 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 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 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 let cli_parser = CliParser::new();
121 let mut options = cli_parser.parse_options(&subcommand_help);
122
123 self.option_inferrer.infer_types(&mut options);
125
126 let required_args = cli_parser.parse_required_args(&subcommand_help);
128
129 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 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 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 if line.trim().is_empty() {
171 in_subcommand_section = false;
172 continue;
173 }
174
175 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 fn get_subcommand_help(&self, binary: &Path, subcommand: &str) -> Result<String> {
189 log::debug!("Getting help for subcommand: {}", subcommand);
190
191 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 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 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 assert_eq!(subcommands.len(), 2);
323 }
324
325 #[cfg(unix)]
326 #[test]
327 fn test_detect_git_subcommands() {
328 let git_path = Path::new("/usr/bin/git");
330 if !git_path.exists() {
331 return; }
333
334 let detector = SubcommandDetector::with_max_depth(1).unwrap();
335
336 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 match result {
346 Ok(subcommands) => {
347 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 visited.insert("/bin/test-help".to_string());
366
367 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}