Initial upload with CI/CD workflow
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-03 09:41:15 +00:00
parent 3f24d7f1ef
commit 3d702c768a

271
app/src/parser/mod.rs Normal file
View File

@@ -0,0 +1,271 @@
use anyhow::{Result, anyhow, Context};
use regex::Regex;
use std::process::Command;
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub name: String,
pub short: Option<char>,
pub long: Option<String>,
pub description: String,
pub required: bool,
pub takes_value: bool,
pub default_value: Option<String>,
pub possible_values: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubCommand {
pub name: String,
pub description: String,
pub arguments: Vec<Argument>,
pub subcommands: Vec<SubCommand>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandInfo {
pub name: String,
pub description: String,
pub version: Option<String>,
pub arguments: Vec<Argument>,
pub subcommands: Vec<SubCommand>,
pub examples: Vec<String>,
pub environment_variables: Vec<EnvironmentVar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentVar {
pub name: String,
pub description: String,
pub default: Option<String>,
}
pub struct HelpParser;
impl HelpParser {
pub async fn parse_command(command_str: &str) -> Result<CommandInfo> {
let parts: Vec<&str> = command_str.split_whitespace().collect();
if parts.is_empty() {
return Err(anyhow!("Empty command provided"));
}
let executable = parts[0];
let args: Vec<String> = parts[1..].iter().map(|s| (*s).to_string()).collect();
let help_output = Self::fetch_help_output(executable, &args)?;
Self::parse_help_output(executable, &help_output)
}
fn fetch_help_output(executable: &str, extra_args: &[String]) -> Result<String> {
let mut cmd = Command::new(executable);
cmd.arg("--help");
for arg in extra_args {
cmd.arg(arg);
}
let output = cmd.output()
.with_context(|| format!("Failed to execute '{} --help'", executable))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("Command failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.to_string())
}
fn parse_help_output(name: &str, help_text: &str) -> Result<CommandInfo> {
let mut info = CommandInfo {
name: name.to_string(),
description: String::new(),
version: None,
arguments: Vec::new(),
subcommands: Vec::new(),
examples: Vec::new(),
environment_variables: Vec::new(),
};
info.description = Self::extract_description(help_text);
info.version = Self::extract_version(help_text);
info.arguments = Self::extract_arguments(help_text);
info.subcommands = Self::extract_subcommands(help_text);
info.examples = Self::extract_examples(help_text);
info.environment_variables = Self::extract_env_vars(help_text);
Ok(info)
}
fn extract_description(help_text: &str) -> String {
let re = Regex::new(r"(?s)^(.+?)(?:\n\n|\n[A-Z])").unwrap();
if let Some(caps) = re.captures(help_text) {
if let Some(m) = caps.get(1) {
return m.as_str().trim().to_string();
}
}
String::new()
}
fn extract_version(help_text: &str) -> Option<String> {
let re = Regex::new(r"(?:version|Version)[:\s]+([^\s\n]+)").unwrap();
re.captures(help_text)?.get(1).map(|m| m.as_str().to_string())
}
fn extract_arguments(help_text: &str) -> Vec<Argument> {
let mut arguments = Vec::new();
let arg_pattern = Regex::new(
r"(?m)^\s*(?:(-([a-zA-Z]),?\s+)?(--([a-zA-Z0-9_-]+))?)\s*(?:<([^>]+)>)?\s*(?:\[[^\]]+\])?\s*(.*)$"
).unwrap();
let desc_pattern = Regex::new(r"(?s)\s+(.+?)(?:\n\s{2,}|\n\n|$)").unwrap();
for line in help_text.lines() {
if line.starts_with('-') || line.starts_with("--") {
let mut arg = Argument {
name: String::new(),
short: None,
long: None,
description: String::new(),
required: false,
takes_value: false,
default_value: None,
possible_values: Vec::new(),
};
let short_re = Regex::new(r"-([a-zA-Z]),?\s*").unwrap();
let long_re = Regex::new(r"--([a-zA-Z][a-zA-Z0-9_-]*)").unwrap();
let required_re = Regex::new(r"(?:required|needed)").unwrap();
let default_re = Regex::new(r"(?:default|Default)[:\s]+['"]?([^'",\n]+)['"]?").unwrap();
let values_re = Regex::new(r"(?:options|choices|values)[:\s]+\[?([^\]]+)\]?").unwrap();
if let Some(caps) = short_re.captures(line) {
arg.short = caps.get(1).map(|c| c.as_str().chars().next().unwrap());
}
if let Some(caps) = long_re.captures(line) {
arg.long = caps.get(1).map(|c| c.as_str().to_string());
}
if let Some(long) = &arg.long {
arg.name = long.clone();
} else if let Some(short) = arg.short {
arg.name = short.to_string();
}
arg.description = line
.splitn(2, " ")
.nth(1)
.map(|s| s.trim().to_string())
.unwrap_or_default();
arg.required = required_re.is_match(line);
arg.takes_value = line.contains('<') || line.contains("ARG") || line.contains("FILE");
arg.default_value = default_re.captures(line).and_then(|c| c.get(1).map(|m| m.as_str().to_string()));
arg.possible_values = values_re.captures(line)
.and_then(|c| c.get(1).map(|m| m.as_str().split(',').map(|s| s.trim().to_string()).collect()))
.unwrap_or_default();
if !arg.name.is_empty() {
arguments.push(arg);
}
}
}
arguments
}
fn extract_subcommands(help_text: &str) -> Vec<SubCommand> {
let mut subcommands = Vec::new();
let re = Regex::new(r"(?m)^ ([a-z][a-z0-9_-]*)\s+(.+?)(?:\n\s{4,}|\n\n|$)").unwrap();
for caps in re.captures_iter(help_text) {
let subcmd = SubCommand {
name: caps[1].to_string(),
description: caps[2].trim().to_string(),
arguments: Vec::new(),
subcommands: Vec::new(),
};
subcommands.push(subcmd);
}
subcommands
}
fn extract_examples(help_text: &str) -> Vec<String> {
let examples_section_re = Regex::new(r"(?i)(?s)(?:examples?|usage)[:\s]*\n(.+?)(?:\n\n|\n[A-Z][a-z]+:|$)").unwrap();
let mut examples = Vec::new();
if let Some(caps) = examples_section_re.captures(help_text) {
if let Some(section) = caps.get(1) {
let example_re = Regex::new(r"(?m)^\$ (.+)$").unwrap();
for cap in example_re.captures_iter(section.as_str()) {
examples.push(cap[1].to_string());
}
}
}
examples
}
fn extract_env_vars(help_text: &str) -> Vec<EnvironmentVar> {
let mut env_vars = Vec::new();
let re = Regex::new(r"(?mi)^(?:ENVIRONMENT|ENVIRONMENT VARIABLE)[:\s]*\n(.+?)(?:\n\n|\n[A-Z])").unwrap();
if let Some(caps) = re.captures(help_text) {
if let Some(section) = caps.get(1) {
let var_re = Regex::new(r"\$([A-Z_][A-Z0-9_]*)\s+(.+)").unwrap();
for cap in var_re.captures_iter(section.as_str()) {
env_vars.push(EnvironmentVar {
name: cap[1].to_string(),
description: cap[2].trim().to_string(),
default: None,
});
}
}
}
env_vars
}
pub async fn parse_default_commands() -> Result<CommandInfo> {
Self::parse_command("git").await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_description() {
let help = "A test command description\n\nUsage:\n test [OPTIONS]";
let desc = HelpParser::extract_description(help);
assert_eq!(desc, "A test command description");
}
#[test]
fn test_extract_version() {
let help = "Some tool\nVersion: 1.2.3\n\nOptions:";
let version = HelpParser::extract_version(help);
assert_eq!(version, Some("1.2.3".to_string()));
}
#[test]
fn test_extract_arguments_basic() {
let help = r#"Usage: test [OPTIONS]
Options:
-v, --verbose Enable verbose output
-o, --output FILE Write to file"#;
let args = HelpParser::extract_arguments(help);
assert!(args.len() >= 2);
}
}