cli_testing_specialist/analyzer/
behavior_inferrer.rs

1use 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    /// Regex pattern for Usage line
11    static ref USAGE_LINE: Regex = Regex::new(r"(?i)^\s*usage:\s+(.+)$").unwrap();
12
13    /// Known interactive tools (REPLs, database clients)
14    static ref INTERACTIVE_TOOLS: Vec<&'static str> = vec![
15        // Database clients
16        "psql", "mysql", "redis-cli", "mongo", "mongosh", "sqlite3",
17        // Programming language REPLs
18        "python", "python3", "node", "irb", "php", "R", "julia",
19        // Other interactive tools
20        "gdb", "lldb", "ghci", "erl", "iex",
21    ];
22}
23
24/// Behavior Inferrer - Infers CLI behavior patterns
25pub struct BehaviorInferrer;
26
27impl BehaviorInferrer {
28    /// Create a new behavior inferrer
29    pub fn new() -> Self {
30        Self
31    }
32
33    /// Infer CLI behavior when invoked without arguments
34    ///
35    /// Uses multiple strategies in order of preference:
36    /// 0. Check for known interactive tools (highest priority - must avoid execution)
37    /// 1. Execute binary and measure exit code (most accurate for non-interactive tools)
38    /// 2. Parse Usage line pattern for subcommand requirements
39    /// 3. Check for subcommands presence
40    /// 4. Default to ShowHelp (safest assumption)
41    pub fn infer_no_args_behavior(&self, analysis: &CliAnalysis) -> NoArgsBehavior {
42        // Strategy 0: Check for interactive tools FIRST (must not execute)
43        // Interactive tools (psql, python) may exit immediately with stdin=null
44        // which would give false ShowHelp result
45        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        // Strategy 1: Execute and measure exit code (most accurate)
54        // This directly observes the actual behavior instead of guessing from Usage line
55        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, // Unknown code, assume safe default
60            };
61            log::info!(
62                "Inferred no-args behavior: {:?} (from execution: exit {})",
63                behavior,
64                exit_code
65            );
66            return behavior;
67        }
68
69        // Strategy 2: Parse Usage line pattern (fallback)
70        if let Some(pattern) = self.extract_usage_pattern(&analysis.help_output) {
71            log::debug!("Extracted usage pattern: {}", pattern);
72
73            // Check for subcommand requirement patterns
74            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            // Check for optional-only pattern (indicates ShowHelp)
82            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        // Strategy 3: Check if has subcommands (fallback)
89        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        // Default: Show help (safest assumption)
98        log::info!("Inferred no-args behavior: ShowHelp (default)");
99        NoArgsBehavior::ShowHelp
100    }
101
102    /// Execute binary without arguments and measure exit code
103    ///
104    /// Safety measures:
105    /// - 1 second timeout (prevents hanging on interactive tools)
106    /// - Discard all output (stdout/stderr) to avoid log pollution
107    /// - No user interaction (stdin=null, non-TTY mode)
108    /// - Environment variables to disable colors and interactivity
109    ///
110    /// Returns:
111    /// - Ok(Some(exit_code)) - Successfully executed and got exit code
112    /// - Ok(None) - Timeout (likely interactive tool)
113    /// - Err(_) - Execution failed (permission denied, not found, etc.)
114    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()) // No user input
122            .stdout(Stdio::null()) // Discard stdout
123            .stderr(Stdio::null()) // Discard stderr
124            .env("NO_COLOR", "1") // Disable colors
125            .env("TERM", "dumb") // Non-interactive terminal
126            .spawn()?;
127
128        // Wait with timeout (1 second)
129        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                // Timeout - likely an interactive tool
138                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    /// Extract Usage line from help output
147    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    /// Check if Usage pattern indicates subcommand requirement
157    ///
158    /// Patterns that indicate RequireSubcommand:
159    /// - "Usage: cmd <SUBCOMMAND>"
160    /// - "Usage: cmd <COMMAND>"
161    /// - "Usage: cmd COMMAND"
162    fn requires_subcommand_from_usage(&self, pattern: &str) -> bool {
163        let pattern_lower = pattern.to_lowercase();
164
165        // Check for <SUBCOMMAND> or <COMMAND> pattern
166        if pattern_lower.contains("<subcommand>") || pattern_lower.contains("<command>") {
167            return true;
168        }
169
170        // Check for unbracketed COMMAND/SUBCOMMAND (e.g., "git COMMAND")
171        if pattern_lower.contains(" command") || pattern_lower.contains(" subcommand") {
172            // Make sure it's not in brackets (which would be optional)
173            if !pattern.contains("[command]") && !pattern.contains("[subcommand]") {
174                return true;
175            }
176        }
177
178        false
179    }
180
181    /// Check if Usage pattern indicates optional-only (ShowHelp)
182    ///
183    /// Patterns that indicate ShowHelp:
184    /// - "Usage: cmd [OPTIONS]"
185    /// - "Usage: cmd [options]"
186    /// - Everything in brackets
187    fn is_optional_only_from_usage(&self, pattern: &str) -> bool {
188        // Remove the binary name from pattern
189        let parts: Vec<&str> = pattern.split_whitespace().collect();
190        if parts.len() <= 1 {
191            return true; // No arguments at all
192        }
193
194        // Check if all arguments are optional (in brackets)
195        let args = &parts[1..].join(" ");
196
197        // Simple heuristic: if it starts with '[', it's likely optional-only
198        args.trim_start().starts_with('[')
199    }
200
201    /// Check if tool is known to be interactive
202    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        // Test with <SUBCOMMAND> pattern
252        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        // Test with COMMAND pattern (no brackets)
264        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        // Test with [OPTIONS] pattern
276        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        // Test with no clear Usage pattern but has subcommands
288        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        // No clear pattern → default to ShowHelp
322        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}