cli_testing_specialist/analyzer/
behavior_inferrer.rs1use crate::error::Result;
2use crate::types::{CliAnalysis, NoArgsBehavior};
3use lazy_static::lazy_static;
4use regex::Regex;
5use std::path::Path;
6use std::process::{Command, Stdio};
7use std::time::Duration;
8
9lazy_static! {
10 static ref USAGE_LINE: Regex = Regex::new(r"(?i)^\s*usage:\s+(.+)$").unwrap();
12
13 static ref INTERACTIVE_TOOLS: Vec<&'static str> = vec![
15 "psql", "mysql", "redis-cli", "mongo", "mongosh", "sqlite3",
17 "python", "python3", "node", "irb", "php", "R", "julia",
19 "gdb", "lldb", "ghci", "erl", "iex",
21 ];
22}
23
24pub struct BehaviorInferrer;
26
27impl BehaviorInferrer {
28 pub fn new() -> Self {
30 Self
31 }
32
33 pub fn infer_no_args_behavior(&self, analysis: &CliAnalysis) -> NoArgsBehavior {
42 if self.is_interactive_tool(&analysis.binary_name) {
46 log::info!(
47 "Inferred no-args behavior: Interactive (known REPL: {})",
48 analysis.binary_name
49 );
50 return NoArgsBehavior::Interactive;
51 }
52
53 if let Ok(Some(exit_code)) = self.execute_and_measure(&analysis.binary_path) {
56 let behavior = match exit_code {
57 0 => NoArgsBehavior::ShowHelp,
58 1 | 2 => NoArgsBehavior::RequireSubcommand,
59 _ => NoArgsBehavior::ShowHelp, };
61 log::info!(
62 "Inferred no-args behavior: {:?} (from execution: exit {})",
63 behavior,
64 exit_code
65 );
66 return behavior;
67 }
68
69 if let Some(pattern) = self.extract_usage_pattern(&analysis.help_output) {
71 log::debug!("Extracted usage pattern: {}", pattern);
72
73 if self.requires_subcommand_from_usage(&pattern) {
75 log::info!("Inferred no-args behavior: RequireSubcommand (from Usage pattern)");
76 return NoArgsBehavior::RequireSubcommand;
77 }
78
79 if self.is_optional_only_from_usage(&pattern) {
81 log::info!("Inferred no-args behavior: ShowHelp (from Usage pattern)");
82 return NoArgsBehavior::ShowHelp;
83 }
84 }
85
86 if !analysis.subcommands.is_empty() {
88 log::info!(
89 "Inferred no-args behavior: RequireSubcommand (has {} subcommands)",
90 analysis.subcommands.len()
91 );
92 return NoArgsBehavior::RequireSubcommand;
93 }
94
95 log::info!("Inferred no-args behavior: ShowHelp (default)");
97 NoArgsBehavior::ShowHelp
98 }
99
100 fn execute_and_measure(&self, binary_path: &Path) -> Result<Option<i32>> {
113 log::debug!(
114 "Executing binary to measure no-args behavior: {:?}",
115 binary_path
116 );
117
118 let mut child = Command::new(binary_path)
119 .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .env("NO_COLOR", "1") .env("TERM", "dumb") .spawn()?;
125
126 use wait_timeout::ChildExt;
128 match child.wait_timeout(Duration::from_secs(1))? {
129 Some(status) => {
130 let exit_code = status.code().unwrap_or(0);
131 log::debug!("Binary exited with code: {}", exit_code);
132 Ok(Some(exit_code))
133 }
134 None => {
135 log::debug!("Binary execution timed out (likely interactive tool)");
137 let _ = child.kill();
138 let _ = child.wait();
139 Ok(None)
140 }
141 }
142 }
143
144 fn extract_usage_pattern(&self, help_output: &str) -> Option<String> {
146 for line in help_output.lines() {
147 if let Some(cap) = USAGE_LINE.captures(line.trim()) {
148 return Some(cap[1].to_string());
149 }
150 }
151 None
152 }
153
154 fn requires_subcommand_from_usage(&self, pattern: &str) -> bool {
161 let pattern_lower = pattern.to_lowercase();
162
163 if pattern_lower.contains("<subcommand>") || pattern_lower.contains("<command>") {
165 return true;
166 }
167
168 if pattern_lower.contains(" command") || pattern_lower.contains(" subcommand") {
170 if !pattern.contains("[command]") && !pattern.contains("[subcommand]") {
172 return true;
173 }
174 }
175
176 false
177 }
178
179 fn is_optional_only_from_usage(&self, pattern: &str) -> bool {
186 let parts: Vec<&str> = pattern.split_whitespace().collect();
188 if parts.len() <= 1 {
189 return true; }
191
192 let args = &parts[1..].join(" ");
194
195 args.trim_start().starts_with('[')
197 }
198
199 fn is_interactive_tool(&self, binary_name: &str) -> bool {
201 INTERACTIVE_TOOLS
202 .iter()
203 .any(|&name| binary_name.contains(name))
204 }
205}
206
207impl Default for BehaviorInferrer {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::types::Subcommand;
217 use std::path::PathBuf;
218
219 fn create_mock_analysis(
220 binary_name: &str,
221 help_output: &str,
222 subcommands: Vec<&str>,
223 ) -> CliAnalysis {
224 let mut analysis = CliAnalysis::new(
225 PathBuf::from(format!("/usr/bin/{}", binary_name)),
226 binary_name.to_string(),
227 help_output.to_string(),
228 );
229
230 analysis.subcommands = subcommands
231 .iter()
232 .map(|name| Subcommand {
233 name: name.to_string(),
234 description: None,
235 options: vec![],
236 required_args: vec![],
237 subcommands: vec![],
238 depth: 0,
239 })
240 .collect();
241
242 analysis
243 }
244
245 #[test]
246 fn test_infer_require_subcommand_from_usage() {
247 let inferrer = BehaviorInferrer::new();
248
249 let help_output = "Usage: git <SUBCOMMAND>\n\nAvailable commands:\n clone\n pull";
251 let analysis = create_mock_analysis("git", help_output, vec!["clone", "pull"]);
252
253 let behavior = inferrer.infer_no_args_behavior(&analysis);
254 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
255 }
256
257 #[test]
258 fn test_infer_require_subcommand_from_command_pattern() {
259 let inferrer = BehaviorInferrer::new();
260
261 let help_output = "Usage: docker COMMAND\n\nCommands:\n run\n build";
263 let analysis = create_mock_analysis("docker", help_output, vec!["run", "build"]);
264
265 let behavior = inferrer.infer_no_args_behavior(&analysis);
266 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
267 }
268
269 #[test]
270 fn test_infer_show_help_from_usage() {
271 let inferrer = BehaviorInferrer::new();
272
273 let help_output = "Usage: backup-suite [OPTIONS]\n\nOptions:\n --help";
275 let analysis = create_mock_analysis("backup-suite", help_output, vec![]);
276
277 let behavior = inferrer.infer_no_args_behavior(&analysis);
278 assert_eq!(behavior, NoArgsBehavior::ShowHelp);
279 }
280
281 #[test]
282 fn test_infer_require_subcommand_from_subcommands() {
283 let inferrer = BehaviorInferrer::new();
284
285 let help_output = "A CLI tool\n\nCommands:\n start\n stop";
287 let analysis = create_mock_analysis("service", help_output, vec!["start", "stop"]);
288
289 let behavior = inferrer.infer_no_args_behavior(&analysis);
290 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
291 }
292
293 #[test]
294 fn test_infer_interactive_psql() {
295 let inferrer = BehaviorInferrer::new();
296
297 let help_output = "Usage: psql [OPTIONS]\n\nOptions:\n --help";
298 let analysis = create_mock_analysis("psql", help_output, vec![]);
299
300 let behavior = inferrer.infer_no_args_behavior(&analysis);
301 assert_eq!(behavior, NoArgsBehavior::Interactive);
302 }
303
304 #[test]
305 fn test_infer_interactive_python() {
306 let inferrer = BehaviorInferrer::new();
307
308 let help_output = "Usage: python [OPTIONS]\n\nOptions:\n -h";
309 let analysis = create_mock_analysis("python3", help_output, vec![]);
310
311 let behavior = inferrer.infer_no_args_behavior(&analysis);
312 assert_eq!(behavior, NoArgsBehavior::Interactive);
313 }
314
315 #[test]
316 fn test_default_to_show_help() {
317 let inferrer = BehaviorInferrer::new();
318
319 let help_output = "A simple tool\n\nOptions:\n --verbose";
321 let analysis = create_mock_analysis("unknown-tool", help_output, vec![]);
322
323 let behavior = inferrer.infer_no_args_behavior(&analysis);
324 assert_eq!(behavior, NoArgsBehavior::ShowHelp);
325 }
326
327 #[test]
328 fn test_extract_usage_pattern() {
329 let inferrer = BehaviorInferrer::new();
330
331 let help = "Usage: git <SUBCOMMAND>\n\nOptions:";
332 let pattern = inferrer.extract_usage_pattern(help);
333 assert_eq!(pattern, Some("git <SUBCOMMAND>".to_string()));
334
335 let help2 = "usage: backup-suite [OPTIONS]";
336 let pattern2 = inferrer.extract_usage_pattern(help2);
337 assert_eq!(pattern2, Some("backup-suite [OPTIONS]".to_string()));
338 }
339
340 #[test]
341 fn test_requires_subcommand_from_usage() {
342 let inferrer = BehaviorInferrer::new();
343
344 assert!(inferrer.requires_subcommand_from_usage("git <SUBCOMMAND>"));
345 assert!(inferrer.requires_subcommand_from_usage("docker <COMMAND>"));
346 assert!(inferrer.requires_subcommand_from_usage("cli COMMAND"));
347 assert!(!inferrer.requires_subcommand_from_usage("cli [OPTIONS]"));
348 assert!(!inferrer.requires_subcommand_from_usage("cli [command]"));
349 }
350
351 #[test]
352 fn test_is_optional_only_from_usage() {
353 let inferrer = BehaviorInferrer::new();
354
355 assert!(inferrer.is_optional_only_from_usage("backup-suite [OPTIONS]"));
356 assert!(inferrer.is_optional_only_from_usage("tool [options] [file]"));
357 assert!(!inferrer.is_optional_only_from_usage("tool <FILE> [OPTIONS]"));
358 assert!(!inferrer.is_optional_only_from_usage("tool COMMAND"));
359 }
360
361 #[test]
362 fn test_is_interactive_tool() {
363 let inferrer = BehaviorInferrer::new();
364
365 assert!(inferrer.is_interactive_tool("psql"));
366 assert!(inferrer.is_interactive_tool("python3"));
367 assert!(inferrer.is_interactive_tool("node"));
368 assert!(inferrer.is_interactive_tool("mysql"));
369 assert!(!inferrer.is_interactive_tool("git"));
370 assert!(!inferrer.is_interactive_tool("backup-suite"));
371 }
372}