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!("Inferred no-args behavior: RequireSubcommand (from Usage pattern)");
76                return NoArgsBehavior::RequireSubcommand;
77            }
78
79            // Check for optional-only pattern (indicates ShowHelp)
80            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        // Strategy 3: Check if has subcommands (fallback)
87        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        // Default: Show help (safest assumption)
96        log::info!("Inferred no-args behavior: ShowHelp (default)");
97        NoArgsBehavior::ShowHelp
98    }
99
100    /// Execute binary without arguments and measure exit code
101    ///
102    /// Safety measures:
103    /// - 1 second timeout (prevents hanging on interactive tools)
104    /// - Discard all output (stdout/stderr) to avoid log pollution
105    /// - No user interaction (stdin=null, non-TTY mode)
106    /// - Environment variables to disable colors and interactivity
107    ///
108    /// Returns:
109    /// - Ok(Some(exit_code)) - Successfully executed and got exit code
110    /// - Ok(None) - Timeout (likely interactive tool)
111    /// - Err(_) - Execution failed (permission denied, not found, etc.)
112    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()) // No user input
120            .stdout(Stdio::null()) // Discard stdout
121            .stderr(Stdio::null()) // Discard stderr
122            .env("NO_COLOR", "1") // Disable colors
123            .env("TERM", "dumb") // Non-interactive terminal
124            .spawn()?;
125
126        // Wait with timeout (1 second)
127        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                // Timeout - likely an interactive tool
136                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    /// Extract Usage line from help output
145    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    /// Check if Usage pattern indicates subcommand requirement
155    ///
156    /// Patterns that indicate RequireSubcommand:
157    /// - "Usage: cmd \<SUBCOMMAND\>"
158    /// - "Usage: cmd \<COMMAND\>"
159    /// - "Usage: cmd COMMAND"
160    fn requires_subcommand_from_usage(&self, pattern: &str) -> bool {
161        let pattern_lower = pattern.to_lowercase();
162
163        // Check for <SUBCOMMAND> or <COMMAND> pattern
164        if pattern_lower.contains("<subcommand>") || pattern_lower.contains("<command>") {
165            return true;
166        }
167
168        // Check for unbracketed COMMAND/SUBCOMMAND (e.g., "git COMMAND")
169        if pattern_lower.contains(" command") || pattern_lower.contains(" subcommand") {
170            // Make sure it's not in brackets (which would be optional)
171            if !pattern.contains("[command]") && !pattern.contains("[subcommand]") {
172                return true;
173            }
174        }
175
176        false
177    }
178
179    /// Check if Usage pattern indicates optional-only (ShowHelp)
180    ///
181    /// Patterns that indicate ShowHelp:
182    /// - "Usage: cmd \[OPTIONS\]"
183    /// - "Usage: cmd \[options\]"
184    /// - Everything in brackets
185    fn is_optional_only_from_usage(&self, pattern: &str) -> bool {
186        // Remove the binary name from pattern
187        let parts: Vec<&str> = pattern.split_whitespace().collect();
188        if parts.len() <= 1 {
189            return true; // No arguments at all
190        }
191
192        // Check if all arguments are optional (in brackets)
193        let args = &parts[1..].join(" ");
194
195        // Simple heuristic: if it starts with '[', it's likely optional-only
196        args.trim_start().starts_with('[')
197    }
198
199    /// Check if tool is known to be interactive
200    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        // Test with <SUBCOMMAND> pattern
250        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        // Test with COMMAND pattern (no brackets)
262        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        // Test with [OPTIONS] pattern
274        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        // Test with no clear Usage pattern but has subcommands
286        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        // No clear pattern → default to ShowHelp
320        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}