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!(
76 "Inferred no-args behavior: RequireSubcommand (from Usage pattern)"
77 );
78 return NoArgsBehavior::RequireSubcommand;
79 }
80
81 if self.is_optional_only_from_usage(&pattern) {
83 log::info!("Inferred no-args behavior: ShowHelp (from Usage pattern)");
84 return NoArgsBehavior::ShowHelp;
85 }
86 }
87
88 if !analysis.subcommands.is_empty() {
90 log::info!(
91 "Inferred no-args behavior: RequireSubcommand (has {} subcommands)",
92 analysis.subcommands.len()
93 );
94 return NoArgsBehavior::RequireSubcommand;
95 }
96
97 log::info!("Inferred no-args behavior: ShowHelp (default)");
99 NoArgsBehavior::ShowHelp
100 }
101
102 fn execute_and_measure(&self, binary_path: &Path) -> Result<Option<i32>> {
115 log::debug!(
116 "Executing binary to measure no-args behavior: {:?}",
117 binary_path
118 );
119
120 let mut child = Command::new(binary_path)
121 .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .env("NO_COLOR", "1") .env("TERM", "dumb") .spawn()?;
127
128 use wait_timeout::ChildExt;
130 match child.wait_timeout(Duration::from_secs(1))? {
131 Some(status) => {
132 let exit_code = status.code().unwrap_or(0);
133 log::debug!("Binary exited with code: {}", exit_code);
134 Ok(Some(exit_code))
135 }
136 None => {
137 log::debug!("Binary execution timed out (likely interactive tool)");
139 let _ = child.kill();
140 let _ = child.wait();
141 Ok(None)
142 }
143 }
144 }
145
146 fn extract_usage_pattern(&self, help_output: &str) -> Option<String> {
148 for line in help_output.lines() {
149 if let Some(cap) = USAGE_LINE.captures(line.trim()) {
150 return Some(cap[1].to_string());
151 }
152 }
153 None
154 }
155
156 fn requires_subcommand_from_usage(&self, pattern: &str) -> bool {
163 let pattern_lower = pattern.to_lowercase();
164
165 if pattern_lower.contains("<subcommand>") || pattern_lower.contains("<command>") {
167 return true;
168 }
169
170 if pattern_lower.contains(" command") || pattern_lower.contains(" subcommand") {
172 if !pattern.contains("[command]") && !pattern.contains("[subcommand]") {
174 return true;
175 }
176 }
177
178 false
179 }
180
181 fn is_optional_only_from_usage(&self, pattern: &str) -> bool {
188 let parts: Vec<&str> = pattern.split_whitespace().collect();
190 if parts.len() <= 1 {
191 return true; }
193
194 let args = &parts[1..].join(" ");
196
197 args.trim_start().starts_with('[')
199 }
200
201 fn is_interactive_tool(&self, binary_name: &str) -> bool {
203 INTERACTIVE_TOOLS
204 .iter()
205 .any(|&name| binary_name.contains(name))
206 }
207}
208
209impl Default for BehaviorInferrer {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::types::Subcommand;
219 use std::path::PathBuf;
220
221 fn create_mock_analysis(
222 binary_name: &str,
223 help_output: &str,
224 subcommands: Vec<&str>,
225 ) -> CliAnalysis {
226 let mut analysis = CliAnalysis::new(
227 PathBuf::from(format!("/usr/bin/{}", binary_name)),
228 binary_name.to_string(),
229 help_output.to_string(),
230 );
231
232 analysis.subcommands = subcommands
233 .iter()
234 .map(|name| Subcommand {
235 name: name.to_string(),
236 description: None,
237 options: vec![],
238 required_args: vec![],
239 subcommands: vec![],
240 depth: 0,
241 })
242 .collect();
243
244 analysis
245 }
246
247 #[test]
248 fn test_infer_require_subcommand_from_usage() {
249 let inferrer = BehaviorInferrer::new();
250
251 let help_output = "Usage: git <SUBCOMMAND>\n\nAvailable commands:\n clone\n pull";
253 let analysis = create_mock_analysis("git", help_output, vec!["clone", "pull"]);
254
255 let behavior = inferrer.infer_no_args_behavior(&analysis);
256 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
257 }
258
259 #[test]
260 fn test_infer_require_subcommand_from_command_pattern() {
261 let inferrer = BehaviorInferrer::new();
262
263 let help_output = "Usage: docker COMMAND\n\nCommands:\n run\n build";
265 let analysis = create_mock_analysis("docker", help_output, vec!["run", "build"]);
266
267 let behavior = inferrer.infer_no_args_behavior(&analysis);
268 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
269 }
270
271 #[test]
272 fn test_infer_show_help_from_usage() {
273 let inferrer = BehaviorInferrer::new();
274
275 let help_output = "Usage: backup-suite [OPTIONS]\n\nOptions:\n --help";
277 let analysis = create_mock_analysis("backup-suite", help_output, vec![]);
278
279 let behavior = inferrer.infer_no_args_behavior(&analysis);
280 assert_eq!(behavior, NoArgsBehavior::ShowHelp);
281 }
282
283 #[test]
284 fn test_infer_require_subcommand_from_subcommands() {
285 let inferrer = BehaviorInferrer::new();
286
287 let help_output = "A CLI tool\n\nCommands:\n start\n stop";
289 let analysis = create_mock_analysis("service", help_output, vec!["start", "stop"]);
290
291 let behavior = inferrer.infer_no_args_behavior(&analysis);
292 assert_eq!(behavior, NoArgsBehavior::RequireSubcommand);
293 }
294
295 #[test]
296 fn test_infer_interactive_psql() {
297 let inferrer = BehaviorInferrer::new();
298
299 let help_output = "Usage: psql [OPTIONS]\n\nOptions:\n --help";
300 let analysis = create_mock_analysis("psql", help_output, vec![]);
301
302 let behavior = inferrer.infer_no_args_behavior(&analysis);
303 assert_eq!(behavior, NoArgsBehavior::Interactive);
304 }
305
306 #[test]
307 fn test_infer_interactive_python() {
308 let inferrer = BehaviorInferrer::new();
309
310 let help_output = "Usage: python [OPTIONS]\n\nOptions:\n -h";
311 let analysis = create_mock_analysis("python3", help_output, vec![]);
312
313 let behavior = inferrer.infer_no_args_behavior(&analysis);
314 assert_eq!(behavior, NoArgsBehavior::Interactive);
315 }
316
317 #[test]
318 fn test_default_to_show_help() {
319 let inferrer = BehaviorInferrer::new();
320
321 let help_output = "A simple tool\n\nOptions:\n --verbose";
323 let analysis = create_mock_analysis("unknown-tool", help_output, vec![]);
324
325 let behavior = inferrer.infer_no_args_behavior(&analysis);
326 assert_eq!(behavior, NoArgsBehavior::ShowHelp);
327 }
328
329 #[test]
330 fn test_extract_usage_pattern() {
331 let inferrer = BehaviorInferrer::new();
332
333 let help = "Usage: git <SUBCOMMAND>\n\nOptions:";
334 let pattern = inferrer.extract_usage_pattern(help);
335 assert_eq!(pattern, Some("git <SUBCOMMAND>".to_string()));
336
337 let help2 = "usage: backup-suite [OPTIONS]";
338 let pattern2 = inferrer.extract_usage_pattern(help2);
339 assert_eq!(pattern2, Some("backup-suite [OPTIONS]".to_string()));
340 }
341
342 #[test]
343 fn test_requires_subcommand_from_usage() {
344 let inferrer = BehaviorInferrer::new();
345
346 assert!(inferrer.requires_subcommand_from_usage("git <SUBCOMMAND>"));
347 assert!(inferrer.requires_subcommand_from_usage("docker <COMMAND>"));
348 assert!(inferrer.requires_subcommand_from_usage("cli COMMAND"));
349 assert!(!inferrer.requires_subcommand_from_usage("cli [OPTIONS]"));
350 assert!(!inferrer.requires_subcommand_from_usage("cli [command]"));
351 }
352
353 #[test]
354 fn test_is_optional_only_from_usage() {
355 let inferrer = BehaviorInferrer::new();
356
357 assert!(inferrer.is_optional_only_from_usage("backup-suite [OPTIONS]"));
358 assert!(inferrer.is_optional_only_from_usage("tool [options] [file]"));
359 assert!(!inferrer.is_optional_only_from_usage("tool <FILE> [OPTIONS]"));
360 assert!(!inferrer.is_optional_only_from_usage("tool COMMAND"));
361 }
362
363 #[test]
364 fn test_is_interactive_tool() {
365 let inferrer = BehaviorInferrer::new();
366
367 assert!(inferrer.is_interactive_tool("psql"));
368 assert!(inferrer.is_interactive_tool("python3"));
369 assert!(inferrer.is_interactive_tool("node"));
370 assert!(inferrer.is_interactive_tool("mysql"));
371 assert!(!inferrer.is_interactive_tool("git"));
372 assert!(!inferrer.is_interactive_tool("backup-suite"));
373 }
374}