coderlib/commands/
parser.rs

1//! Command file parser for markdown-based command definitions
2
3use regex::Regex;
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7use tracing::debug;
8
9use super::{CustomCommand, CommandParameter, CommandType, CommandError, utils};
10
11/// Parser for command files
12pub struct CommandParser {
13    /// Regex for finding parameters in command content
14    parameter_regex: Regex,
15}
16
17impl CommandParser {
18    /// Create a new command parser
19    pub fn new() -> Self {
20        Self {
21            // Match $PARAMETER_NAME pattern
22            parameter_regex: Regex::new(r"\$([A-Z_][A-Z0-9_]*)")
23                .expect("Invalid parameter regex"),
24        }
25    }
26    
27    /// Parse a command file and return a CustomCommand
28    pub fn parse_command_file(&self, file_path: &Path) -> Result<CustomCommand, CommandError> {
29        debug!("Parsing command file: {}", file_path.display());
30        
31        let content = fs::read_to_string(file_path)
32            .map_err(|e| CommandError::FileError(format!("Failed to read {}: {}", file_path.display(), e)))?;
33        
34        let command_type = self.determine_command_type(file_path)?;
35        let command_id = utils::generate_command_id(file_path, command_type.clone())?;
36        
37        let (name, description, command_content) = self.parse_markdown_content(&content)?;
38        let parameters = self.extract_parameters(&command_content);
39        
40        let file_name = file_path.file_stem()
41            .and_then(|s| s.to_str())
42            .unwrap_or("unknown");
43        
44        Ok(CustomCommand {
45            id: command_id,
46            name: name.unwrap_or_else(|| self.humanize_filename(file_name)),
47            description,
48            content: command_content,
49            parameters,
50            source_file: file_path.to_path_buf(),
51            command_type,
52        })
53    }
54    
55    /// Determine command type based on file path
56    fn determine_command_type(&self, file_path: &Path) -> Result<CommandType, CommandError> {
57        let path_str = file_path.to_string_lossy();
58        
59        // Check for project-specific patterns
60        if path_str.contains("/.coderlib/commands/") || 
61           path_str.contains("\\.coderlib\\commands\\") {
62            Ok(CommandType::Project)
63        } else {
64            // Default to user commands
65            Ok(CommandType::User)
66        }
67    }
68    
69    /// Parse markdown content to extract title, description, and command content
70    fn parse_markdown_content(&self, content: &str) -> Result<(Option<String>, Option<String>, String), CommandError> {
71        let lines: Vec<&str> = content.lines().collect();
72        
73        let mut name = None;
74        let mut description = None;
75        let mut content_start = 0;
76        
77        // Look for title (# Title)
78        if let Some(first_line) = lines.first() {
79            if first_line.starts_with("# ") {
80                name = Some(first_line[2..].trim().to_string());
81                content_start = 1;
82                
83                // Look for description on next non-empty line
84                for i in 1..lines.len() {
85                    let line = lines[i].trim();
86                    if line.is_empty() {
87                        continue;
88                    }
89                    if line.starts_with('#') {
90                        break;
91                    }
92                    description = Some(line.to_string());
93                    content_start = i + 1;
94                    break;
95                }
96            }
97        }
98        
99        // Skip empty lines at the beginning of content
100        while content_start < lines.len() && lines[content_start].trim().is_empty() {
101            content_start += 1;
102        }
103        
104        // Join remaining lines as command content
105        let command_content = if content_start < lines.len() {
106            lines[content_start..].join("\n")
107        } else {
108            String::new()
109        };
110        
111        Ok((name, description, command_content))
112    }
113    
114    /// Extract parameters from command content
115    fn extract_parameters(&self, content: &str) -> Vec<CommandParameter> {
116        let mut parameters = Vec::new();
117        let mut seen_params = std::collections::HashSet::new();
118        
119        for cap in self.parameter_regex.captures_iter(content) {
120            if let Some(param_match) = cap.get(1) {
121                let param_name = param_match.as_str().to_string();
122                
123                // Avoid duplicates
124                if seen_params.insert(param_name.clone()) {
125                    parameters.push(CommandParameter {
126                        name: param_name,
127                        description: None,
128                        required: true, // Default to required
129                        default_value: None,
130                    });
131                }
132            }
133        }
134        
135        debug!("Extracted {} parameters from command content", parameters.len());
136        parameters
137    }
138    
139    /// Substitute parameters in command content
140    pub fn substitute_parameters(
141        &self,
142        content: &str,
143        arguments: &HashMap<String, String>,
144    ) -> Result<String, CommandError> {
145        let mut result = content.to_string();
146        
147        // Find all parameter references
148        for cap in self.parameter_regex.captures_iter(content) {
149            if let Some(param_match) = cap.get(0) {
150                if let Some(param_name) = cap.get(1) {
151                    let param_name_str = param_name.as_str();
152                    
153                    if let Some(value) = arguments.get(param_name_str) {
154                        result = result.replace(param_match.as_str(), value);
155                    } else {
156                        return Err(CommandError::MissingParameter(param_name_str.to_string()));
157                    }
158                }
159            }
160        }
161        
162        Ok(result)
163    }
164    
165    /// Convert filename to human-readable name
166    fn humanize_filename(&self, filename: &str) -> String {
167        filename
168            .replace('-', " ")
169            .replace('_', " ")
170            .split_whitespace()
171            .map(|word| {
172                let mut chars = word.chars();
173                match chars.next() {
174                    None => String::new(),
175                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
176                }
177            })
178            .collect::<Vec<_>>()
179            .join(" ")
180    }
181}
182
183impl Default for CommandParser {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use tempfile::TempDir;
194    
195    #[test]
196    fn test_parse_markdown_content() {
197        let parser = CommandParser::new();
198        
199        let content = r#"# Test Command
200This is a test command description.
201
202Hello $NAME, welcome to $PROJECT!
203Please run the following: $COMMAND
204"#;
205        
206        let (name, description, command_content) = parser.parse_markdown_content(content).unwrap();
207        
208        assert_eq!(name, Some("Test Command".to_string()));
209        assert_eq!(description, Some("This is a test command description.".to_string()));
210        assert!(command_content.contains("Hello $NAME"));
211        assert!(command_content.contains("$PROJECT"));
212        assert!(command_content.contains("$COMMAND"));
213    }
214    
215    #[test]
216    fn test_extract_parameters() {
217        let parser = CommandParser::new();
218        let content = "Hello $NAME, your project $PROJECT is ready! Run $COMMAND with $NAME again.";
219        
220        let parameters = parser.extract_parameters(content);
221        
222        assert_eq!(parameters.len(), 3);
223        assert!(parameters.iter().any(|p| p.name == "NAME"));
224        assert!(parameters.iter().any(|p| p.name == "PROJECT"));
225        assert!(parameters.iter().any(|p| p.name == "COMMAND"));
226        
227        // Check that duplicates are removed
228        let name_count = parameters.iter().filter(|p| p.name == "NAME").count();
229        assert_eq!(name_count, 1);
230    }
231    
232    #[test]
233    fn test_substitute_parameters() {
234        let parser = CommandParser::new();
235        let content = "Hello $NAME, your project $PROJECT is ready!";
236        
237        let mut args = HashMap::new();
238        args.insert("NAME".to_string(), "Alice".to_string());
239        args.insert("PROJECT".to_string(), "MyApp".to_string());
240        
241        let result = parser.substitute_parameters(content, &args).unwrap();
242        assert_eq!(result, "Hello Alice, your project MyApp is ready!");
243    }
244    
245    #[test]
246    fn test_substitute_parameters_missing() {
247        let parser = CommandParser::new();
248        let content = "Hello $NAME, your project $PROJECT is ready!";
249        
250        let mut args = HashMap::new();
251        args.insert("NAME".to_string(), "Alice".to_string());
252        // Missing PROJECT parameter
253        
254        let result = parser.substitute_parameters(content, &args);
255        assert!(result.is_err());
256        assert!(matches!(result.unwrap_err(), CommandError::MissingParameter(_)));
257    }
258    
259    #[test]
260    fn test_humanize_filename() {
261        let parser = CommandParser::new();
262        
263        assert_eq!(parser.humanize_filename("hello-world"), "Hello World");
264        assert_eq!(parser.humanize_filename("test_command"), "Test Command");
265        assert_eq!(parser.humanize_filename("simple"), "Simple");
266        assert_eq!(parser.humanize_filename("multi-word-test"), "Multi Word Test");
267    }
268    
269    #[test]
270    fn test_determine_command_type() {
271        let parser = CommandParser::new();
272        
273        let user_path = Path::new("/home/user/.config/coderlib/commands/test.md");
274        assert_eq!(parser.determine_command_type(user_path).unwrap(), CommandType::User);
275        
276        let project_path = Path::new("/project/.coderlib/commands/deploy.md");
277        assert_eq!(parser.determine_command_type(project_path).unwrap(), CommandType::Project);
278    }
279    
280    #[test]
281    fn test_parse_command_file() {
282        let temp_dir = TempDir::new().unwrap();
283        let command_file = temp_dir.path().join("test-command.md");
284        
285        fs::write(&command_file, r#"# Test Command
286This is a test command with parameters.
287
288Hello $NAME, welcome to $PROJECT!
289Please run: $COMMAND
290"#).unwrap();
291        
292        let parser = CommandParser::new();
293        let command = parser.parse_command_file(&command_file).unwrap();
294        
295        assert_eq!(command.name, "Test Command");
296        assert_eq!(command.description, Some("This is a test command with parameters.".to_string()));
297        assert_eq!(command.parameters.len(), 3);
298        assert!(command.parameters.iter().any(|p| p.name == "NAME"));
299        assert!(command.parameters.iter().any(|p| p.name == "PROJECT"));
300        assert!(command.parameters.iter().any(|p| p.name == "COMMAND"));
301        assert_eq!(command.command_type, CommandType::User);
302    }
303}