use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tracing::debug;
use super::{CustomCommand, CommandParameter, CommandType, CommandError, utils};
pub struct CommandParser {
parameter_regex: Regex,
}
impl CommandParser {
pub fn new() -> Self {
Self {
parameter_regex: Regex::new(r"\$([A-Z_][A-Z0-9_]*)")
.expect("Invalid parameter regex"),
}
}
pub fn parse_command_file(&self, file_path: &Path) -> Result<CustomCommand, CommandError> {
debug!("Parsing command file: {}", file_path.display());
let content = fs::read_to_string(file_path)
.map_err(|e| CommandError::FileError(format!("Failed to read {}: {}", file_path.display(), e)))?;
let command_type = self.determine_command_type(file_path)?;
let command_id = utils::generate_command_id(file_path, command_type.clone())?;
let (name, description, command_content) = self.parse_markdown_content(&content)?;
let parameters = self.extract_parameters(&command_content);
let file_name = file_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
Ok(CustomCommand {
id: command_id,
name: name.unwrap_or_else(|| self.humanize_filename(file_name)),
description,
content: command_content,
parameters,
source_file: file_path.to_path_buf(),
command_type,
})
}
fn determine_command_type(&self, file_path: &Path) -> Result<CommandType, CommandError> {
let path_str = file_path.to_string_lossy();
if path_str.contains("/.coderlib/commands/") ||
path_str.contains("\\.coderlib\\commands\\") {
Ok(CommandType::Project)
} else {
Ok(CommandType::User)
}
}
fn parse_markdown_content(&self, content: &str) -> Result<(Option<String>, Option<String>, String), CommandError> {
let lines: Vec<&str> = content.lines().collect();
let mut name = None;
let mut description = None;
let mut content_start = 0;
if let Some(first_line) = lines.first() {
if first_line.starts_with("# ") {
name = Some(first_line[2..].trim().to_string());
content_start = 1;
for i in 1..lines.len() {
let line = lines[i].trim();
if line.is_empty() {
continue;
}
if line.starts_with('#') {
break;
}
description = Some(line.to_string());
content_start = i + 1;
break;
}
}
}
while content_start < lines.len() && lines[content_start].trim().is_empty() {
content_start += 1;
}
let command_content = if content_start < lines.len() {
lines[content_start..].join("\n")
} else {
String::new()
};
Ok((name, description, command_content))
}
fn extract_parameters(&self, content: &str) -> Vec<CommandParameter> {
let mut parameters = Vec::new();
let mut seen_params = std::collections::HashSet::new();
for cap in self.parameter_regex.captures_iter(content) {
if let Some(param_match) = cap.get(1) {
let param_name = param_match.as_str().to_string();
if seen_params.insert(param_name.clone()) {
parameters.push(CommandParameter {
name: param_name,
description: None,
required: true, default_value: None,
});
}
}
}
debug!("Extracted {} parameters from command content", parameters.len());
parameters
}
pub fn substitute_parameters(
&self,
content: &str,
arguments: &HashMap<String, String>,
) -> Result<String, CommandError> {
let mut result = content.to_string();
for cap in self.parameter_regex.captures_iter(content) {
if let Some(param_match) = cap.get(0) {
if let Some(param_name) = cap.get(1) {
let param_name_str = param_name.as_str();
if let Some(value) = arguments.get(param_name_str) {
result = result.replace(param_match.as_str(), value);
} else {
return Err(CommandError::MissingParameter(param_name_str.to_string()));
}
}
}
}
Ok(result)
}
fn humanize_filename(&self, filename: &str) -> String {
filename
.replace('-', " ")
.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
impl Default for CommandParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_parse_markdown_content() {
let parser = CommandParser::new();
let content = r#"# Test Command
This is a test command description.
Hello $NAME, welcome to $PROJECT!
Please run the following: $COMMAND
"#;
let (name, description, command_content) = parser.parse_markdown_content(content).unwrap();
assert_eq!(name, Some("Test Command".to_string()));
assert_eq!(description, Some("This is a test command description.".to_string()));
assert!(command_content.contains("Hello $NAME"));
assert!(command_content.contains("$PROJECT"));
assert!(command_content.contains("$COMMAND"));
}
#[test]
fn test_extract_parameters() {
let parser = CommandParser::new();
let content = "Hello $NAME, your project $PROJECT is ready! Run $COMMAND with $NAME again.";
let parameters = parser.extract_parameters(content);
assert_eq!(parameters.len(), 3);
assert!(parameters.iter().any(|p| p.name == "NAME"));
assert!(parameters.iter().any(|p| p.name == "PROJECT"));
assert!(parameters.iter().any(|p| p.name == "COMMAND"));
let name_count = parameters.iter().filter(|p| p.name == "NAME").count();
assert_eq!(name_count, 1);
}
#[test]
fn test_substitute_parameters() {
let parser = CommandParser::new();
let content = "Hello $NAME, your project $PROJECT is ready!";
let mut args = HashMap::new();
args.insert("NAME".to_string(), "Alice".to_string());
args.insert("PROJECT".to_string(), "MyApp".to_string());
let result = parser.substitute_parameters(content, &args).unwrap();
assert_eq!(result, "Hello Alice, your project MyApp is ready!");
}
#[test]
fn test_substitute_parameters_missing() {
let parser = CommandParser::new();
let content = "Hello $NAME, your project $PROJECT is ready!";
let mut args = HashMap::new();
args.insert("NAME".to_string(), "Alice".to_string());
let result = parser.substitute_parameters(content, &args);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommandError::MissingParameter(_)));
}
#[test]
fn test_humanize_filename() {
let parser = CommandParser::new();
assert_eq!(parser.humanize_filename("hello-world"), "Hello World");
assert_eq!(parser.humanize_filename("test_command"), "Test Command");
assert_eq!(parser.humanize_filename("simple"), "Simple");
assert_eq!(parser.humanize_filename("multi-word-test"), "Multi Word Test");
}
#[test]
fn test_determine_command_type() {
let parser = CommandParser::new();
let user_path = Path::new("/home/user/.config/coderlib/commands/test.md");
assert_eq!(parser.determine_command_type(user_path).unwrap(), CommandType::User);
let project_path = Path::new("/project/.coderlib/commands/deploy.md");
assert_eq!(parser.determine_command_type(project_path).unwrap(), CommandType::Project);
}
#[test]
fn test_parse_command_file() {
let temp_dir = TempDir::new().unwrap();
let command_file = temp_dir.path().join("test-command.md");
fs::write(&command_file, r#"# Test Command
This is a test command with parameters.
Hello $NAME, welcome to $PROJECT!
Please run: $COMMAND
"#).unwrap();
let parser = CommandParser::new();
let command = parser.parse_command_file(&command_file).unwrap();
assert_eq!(command.name, "Test Command");
assert_eq!(command.description, Some("This is a test command with parameters.".to_string()));
assert_eq!(command.parameters.len(), 3);
assert!(command.parameters.iter().any(|p| p.name == "NAME"));
assert!(command.parameters.iter().any(|p| p.name == "PROJECT"));
assert!(command.parameters.iter().any(|p| p.name == "COMMAND"));
assert_eq!(command.command_type, CommandType::User);
}
}