coderlib 0.1.0

A Rust library for AI-powered code assistance and agentic system
Documentation
//! Command file parser for markdown-based command definitions

use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tracing::debug;

use super::{CustomCommand, CommandParameter, CommandType, CommandError, utils};

/// Parser for command files
pub struct CommandParser {
    /// Regex for finding parameters in command content
    parameter_regex: Regex,
}

impl CommandParser {
    /// Create a new command parser
    pub fn new() -> Self {
        Self {
            // Match $PARAMETER_NAME pattern
            parameter_regex: Regex::new(r"\$([A-Z_][A-Z0-9_]*)")
                .expect("Invalid parameter regex"),
        }
    }
    
    /// Parse a command file and return a CustomCommand
    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,
        })
    }
    
    /// Determine command type based on file path
    fn determine_command_type(&self, file_path: &Path) -> Result<CommandType, CommandError> {
        let path_str = file_path.to_string_lossy();
        
        // Check for project-specific patterns
        if path_str.contains("/.coderlib/commands/") || 
           path_str.contains("\\.coderlib\\commands\\") {
            Ok(CommandType::Project)
        } else {
            // Default to user commands
            Ok(CommandType::User)
        }
    }
    
    /// Parse markdown content to extract title, description, and command content
    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;
        
        // Look for title (# Title)
        if let Some(first_line) = lines.first() {
            if first_line.starts_with("# ") {
                name = Some(first_line[2..].trim().to_string());
                content_start = 1;
                
                // Look for description on next non-empty line
                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;
                }
            }
        }
        
        // Skip empty lines at the beginning of content
        while content_start < lines.len() && lines[content_start].trim().is_empty() {
            content_start += 1;
        }
        
        // Join remaining lines as command content
        let command_content = if content_start < lines.len() {
            lines[content_start..].join("\n")
        } else {
            String::new()
        };
        
        Ok((name, description, command_content))
    }
    
    /// Extract parameters from 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();
                
                // Avoid duplicates
                if seen_params.insert(param_name.clone()) {
                    parameters.push(CommandParameter {
                        name: param_name,
                        description: None,
                        required: true, // Default to required
                        default_value: None,
                    });
                }
            }
        }
        
        debug!("Extracted {} parameters from command content", parameters.len());
        parameters
    }
    
    /// Substitute parameters in command content
    pub fn substitute_parameters(
        &self,
        content: &str,
        arguments: &HashMap<String, String>,
    ) -> Result<String, CommandError> {
        let mut result = content.to_string();
        
        // Find all parameter references
        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)
    }
    
    /// Convert filename to human-readable name
    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"));
        
        // Check that duplicates are removed
        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());
        // Missing PROJECT parameter
        
        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);
    }
}