use crate::analyzer::SubcommandDetector;
use crate::error::{CliTestError, Result};
use crate::types::analysis::{CliAnalysis, CliOption, OptionType};
use crate::utils::{execute_with_timeout, validate_binary_path, ResourceLimits};
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use std::time::Instant;
lazy_static! {
static ref SHORT_OPTION: Regex = Regex::new(r"-([a-zA-Z])(?:\s|,|$)").unwrap();
static ref LONG_OPTION: Regex = Regex::new(r"--([a-z][a-z0-9-]+)").unwrap();
static ref VERSION_PATTERN: Regex = Regex::new(r"\b\d+\.\d+(?:\.\d+)?(?:-[a-z0-9.]+)?\b").unwrap();
static ref OPTION_WITH_VALUE: Regex = Regex::new(r"--([a-z][a-z0-9-]+)\s+<([^>]+)>").unwrap();
static ref OPTION_DESCRIPTION: Regex = Regex::new(r"(?:--[a-z][a-z0-9-]+)(?:\s+<[^>]+>)?\s+(.+)").unwrap();
}
pub struct CliParser {
resource_limits: ResourceLimits,
}
impl CliParser {
pub fn new() -> Self {
Self {
resource_limits: ResourceLimits::default(),
}
}
pub fn with_limits(resource_limits: ResourceLimits) -> Self {
Self { resource_limits }
}
pub fn analyze(&self, binary_path: &Path) -> Result<CliAnalysis> {
let start_time = Instant::now();
let canonical_path = validate_binary_path(binary_path)?;
log::info!("Analyzing binary: {}", canonical_path.display());
let binary_name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CliTestError::BinaryNotFound(canonical_path.clone()))?
.to_string();
let help_output = self.execute_help(&canonical_path)?;
if help_output.trim().is_empty() {
return Err(CliTestError::InvalidHelpOutput);
}
let version = self.try_get_version(&canonical_path);
let global_options = self.parse_options(&help_output);
let subcommand_detector = SubcommandDetector::default();
let subcommands = subcommand_detector
.detect(&canonical_path, &help_output)
.unwrap_or_default();
let mut analysis = CliAnalysis::new(canonical_path, binary_name, help_output);
analysis.version = version;
analysis.global_options = global_options;
analysis.subcommands = subcommands;
let duration_ms = start_time.elapsed().as_millis() as u64;
analysis.update_metadata(duration_ms);
log::info!(
"Analysis complete: {} options, {} subcommands found in {}ms",
analysis.metadata.total_options,
analysis.subcommands.len(),
duration_ms
);
Ok(analysis)
}
fn execute_help(&self, binary: &Path) -> Result<String> {
log::debug!("Executing {} --help", binary.display());
match execute_with_timeout(binary, &["--help"], self.resource_limits.timeout()) {
Ok(output) => Ok(output),
Err(_) => {
log::debug!("--help failed, trying -h");
match execute_with_timeout(binary, &["-h"], self.resource_limits.timeout()) {
Ok(output) => Ok(output),
Err(_) => {
log::debug!("-h failed, trying 'help' subcommand");
execute_with_timeout(binary, &["help"], self.resource_limits.timeout())
}
}
}
}
}
fn try_get_version(&self, binary: &Path) -> Option<String> {
log::debug!("Attempting to get version for {}", binary.display());
if let Ok(output) =
execute_with_timeout(binary, &["--version"], self.resource_limits.timeout())
{
if let Some(version) = self.extract_version(&output) {
return Some(version);
}
}
if let Ok(output) = execute_with_timeout(binary, &["-v"], self.resource_limits.timeout()) {
if let Some(version) = self.extract_version(&output) {
return Some(version);
}
}
if let Ok(output) =
execute_with_timeout(binary, &["version"], self.resource_limits.timeout())
{
if let Some(version) = self.extract_version(&output) {
return Some(version);
}
}
None
}
fn extract_version(&self, output: &str) -> Option<String> {
VERSION_PATTERN.find(output).map(|m| m.as_str().to_string())
}
pub fn parse_options(&self, help_output: &str) -> Vec<CliOption> {
let mut options = Vec::new();
let mut seen_options = std::collections::HashSet::new();
for line in help_output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || !trimmed.contains('-') {
continue;
}
let short = SHORT_OPTION
.captures(trimmed)
.and_then(|cap| cap.get(1))
.map(|m| format!("-{}", m.as_str()));
let long = LONG_OPTION
.captures(trimmed)
.and_then(|cap| cap.get(1))
.map(|m| format!("--{}", m.as_str()));
if short.is_none() && long.is_none() {
continue;
}
let option_key = format!("{:?}:{:?}", short, long);
if seen_options.contains(&option_key) {
continue;
}
seen_options.insert(option_key);
let description = OPTION_DESCRIPTION
.captures(trimmed)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().trim().to_string());
let option_type = if OPTION_WITH_VALUE.is_match(trimmed) {
OptionType::String
} else {
OptionType::Flag
};
options.push(CliOption {
short,
long,
description,
option_type,
required: false, default_value: None,
});
}
options
}
pub fn parse_required_args(&self, help_output: &str) -> Vec<String> {
lazy_static! {
static ref USAGE_LINE: Regex = Regex::new(r"(?i)^\s*usage:\s+").unwrap();
static ref REQUIRED_ARG: Regex = Regex::new(r"<([^>]+)>").unwrap();
}
let mut required_args = Vec::new();
for line in help_output.lines() {
if USAGE_LINE.is_match(line) {
for cap in REQUIRED_ARG.captures_iter(line) {
if let Some(arg_match) = cap.get(1) {
let arg_name = arg_match.as_str().to_string();
required_args.push(arg_name);
}
}
break; }
}
log::debug!("Detected {} required arguments", required_args.len());
required_args
}
}
impl Default for CliParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_short_option_regex() {
assert!(SHORT_OPTION.is_match("-h"));
assert!(SHORT_OPTION.is_match("-v "));
assert!(SHORT_OPTION.is_match("-f,"));
assert!(!SHORT_OPTION.is_match("--help"));
}
#[test]
fn test_long_option_regex() {
assert!(LONG_OPTION.is_match("--help"));
assert!(LONG_OPTION.is_match("--verbose"));
assert!(LONG_OPTION.is_match("--max-size"));
assert!(!LONG_OPTION.is_match("-h"));
}
#[test]
fn test_version_pattern_regex() {
assert!(VERSION_PATTERN.is_match("1.0.0"));
assert!(VERSION_PATTERN.is_match("2.5.3"));
assert!(VERSION_PATTERN.is_match("1.0.0-alpha.1"));
assert!(VERSION_PATTERN.is_match("curl 7.64.1"));
}
#[test]
fn test_option_with_value_regex() {
assert!(OPTION_WITH_VALUE.is_match("--name <value>"));
assert!(OPTION_WITH_VALUE.is_match("--file <path>"));
assert!(!OPTION_WITH_VALUE.is_match("--verbose"));
}
#[test]
fn test_extract_version() {
let parser = CliParser::new();
assert_eq!(
parser.extract_version("curl 7.64.1"),
Some("7.64.1".to_string())
);
assert_eq!(
parser.extract_version("version 1.0.0"),
Some("1.0.0".to_string())
);
assert_eq!(parser.extract_version("no version here"), None);
}
#[test]
fn test_parse_options_basic() {
let parser = CliParser::new();
let help_output = r#"
Usage: test [OPTIONS]
Options:
-h, --help Print help information
-v, --verbose Enable verbose output
--name <VALUE> Set name value
"#;
let options = parser.parse_options(help_output);
assert_eq!(options.len(), 3);
assert!(options.iter().any(|o| o.long == Some("--help".to_string())));
assert!(options.iter().any(|o| o.short == Some("-h".to_string())));
assert!(options
.iter()
.any(|o| o.long == Some("--verbose".to_string())));
}
#[test]
fn test_parse_options_deduplication() {
let parser = CliParser::new();
let help_output = r#"
-h, --help Help text
-h, --help Duplicate help text
"#;
let options = parser.parse_options(help_output);
assert_eq!(options.len(), 1);
}
#[cfg(unix)]
#[test]
fn test_analyze_ls() {
let ls_path = Path::new("/bin/ls");
if !ls_path.exists() {
return; }
let parser = CliParser::new();
let result = parser.analyze(ls_path);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.binary_name, "ls");
assert!(!analysis.help_output.is_empty());
assert!(!analysis.global_options.is_empty());
}
#[cfg(unix)]
#[test]
fn test_analyze_curl() {
let curl_path = Path::new("/usr/bin/curl");
if !curl_path.exists() {
return; }
let parser = CliParser::new();
let result = parser.analyze(curl_path);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.binary_name, "curl");
assert!(analysis.version.is_some());
assert!(!analysis.global_options.is_empty());
assert!(analysis.global_options.len() > 10);
}
}