coderlib/commands/
parser.rs1use 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
11pub struct CommandParser {
13 parameter_regex: Regex,
15}
16
17impl CommandParser {
18 pub fn new() -> Self {
20 Self {
21 parameter_regex: Regex::new(r"\$([A-Z_][A-Z0-9_]*)")
23 .expect("Invalid parameter regex"),
24 }
25 }
26
27 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 fn determine_command_type(&self, file_path: &Path) -> Result<CommandType, CommandError> {
57 let path_str = file_path.to_string_lossy();
58
59 if path_str.contains("/.coderlib/commands/") ||
61 path_str.contains("\\.coderlib\\commands\\") {
62 Ok(CommandType::Project)
63 } else {
64 Ok(CommandType::User)
66 }
67 }
68
69 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 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 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 while content_start < lines.len() && lines[content_start].trim().is_empty() {
101 content_start += 1;
102 }
103
104 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 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 if seen_params.insert(param_name.clone()) {
125 parameters.push(CommandParameter {
126 name: param_name,
127 description: None,
128 required: true, default_value: None,
130 });
131 }
132 }
133 }
134
135 debug!("Extracted {} parameters from command content", parameters.len());
136 parameters
137 }
138
139 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 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 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 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 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}