1use std::collections::HashSet;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct FileCommand {
14 pub name: String,
16 pub description: String,
18 pub aliases: Vec<String>,
20 body: String,
23}
24
25impl FileCommand {
26 pub fn parse(name: &str, content: &str) -> Self {
32 let (front, body) = split_frontmatter(content);
33 let mut description = String::new();
34 let mut aliases = Vec::new();
35
36 if let Some(ref front) = front {
37 for line in front.lines() {
38 if let Some(val) = line.strip_prefix("description:") {
39 description = val.trim().to_string();
40 } else if let Some(val) = line.strip_prefix("aliases:") {
41 aliases = val
42 .split(',')
43 .map(|s| s.trim().to_string())
44 .filter(|s| !s.is_empty())
45 .collect();
46 }
47 }
48 }
49
50 if description.is_empty() {
52 description = body
53 .lines()
54 .find(|l| !l.trim().is_empty())
55 .unwrap_or("")
56 .trim()
57 .chars()
58 .take(60)
59 .collect();
60 if body.lines().any(|l| !l.trim().is_empty()) && description.len() == 60 {
61 description.push_str("...");
62 }
63 }
64
65 FileCommand {
66 name: name.to_string(),
67 description,
68 aliases,
69 body,
70 }
71 }
72
73 pub fn matches(&self, token: &str) -> bool {
75 self.name == token || self.aliases.iter().any(|a| a == token)
76 }
77
78 pub fn expand(&self, args: &str) -> String {
81 expand_template(&self.body, args)
82 }
83}
84
85fn split_frontmatter(content: &str) -> (Option<String>, String) {
88 if let Some(body) = content.strip_prefix("---\n")
89 && let Some(end) = body.find("\n---")
90 {
91 let front = body[..end].to_string();
92 let rest = body[end + 4..].trim_start_matches('\n').to_string();
93 return (Some(front), rest);
94 }
95 (None, content.trim_start_matches('\n').to_string())
96}
97
98pub fn expand_template(body: &str, args: &str) -> String {
104 let positional = split_args(args);
105 let mut out = String::with_capacity(body.len());
106 let bytes = body.as_bytes();
107 let mut i = 0;
108 while i < bytes.len() {
109 if bytes[i] == b'$' {
110 let rest = &body[i + 1..];
113 if rest.starts_with("ARGUMENTS") {
114 out.push_str(args);
115 i += 1 + "ARGUMENTS".len();
116 continue;
117 }
118 if rest.starts_with('@') {
119 out.push_str(args);
120 i += 2;
121 continue;
122 }
123 let digit_len = rest
125 .as_bytes()
126 .iter()
127 .take_while(|b| b.is_ascii_digit())
128 .count();
129 if digit_len > 0 {
130 if digit_len == 1 && rest.as_bytes()[0] == b'0' {
133 out.push('$');
134 out.push('0');
135 i += 2;
136 continue;
137 }
138 let digits = &rest[..digit_len];
139 match digits.parse::<usize>() {
140 Ok(idx) if idx >= 1 && idx <= positional.len() => {
141 out.push_str(positional[idx - 1].as_str());
142 }
143 Ok(_) => {
144 }
146 Err(_) => {
147 out.push('$');
149 for c in digits.chars() {
150 out.push(c);
151 }
152 }
153 }
154 i += 1 + digit_len;
155 continue;
156 }
157 out.push('$');
159 i += 1;
160 continue;
161 }
162 let ch = body[i..].chars().next().unwrap_or('\0');
166 if ch == '\0' {
167 break;
168 }
169 out.push(ch);
170 i += ch.len_utf8();
171 }
172 out
173}
174
175fn split_args(args: &str) -> Vec<String> {
178 let mut result = Vec::new();
179 let mut current = String::new();
180 let mut in_quote: Option<char> = None;
181 for ch in args.chars() {
182 match in_quote {
183 Some(q) => {
184 if ch == q {
185 in_quote = None;
186 } else {
187 current.push(ch);
188 }
189 }
190 None => {
191 if ch == '\'' || ch == '"' {
192 in_quote = Some(ch);
193 } else if ch.is_whitespace() {
194 if !current.is_empty() {
195 result.push(std::mem::take(&mut current));
196 }
197 } else {
198 current.push(ch);
199 }
200 }
201 }
202 }
203 if !current.is_empty() {
204 result.push(current);
205 }
206 result
207}
208
209fn scan_dir(dir: &Path, out: &mut Vec<FileCommand>, seen: &mut HashSet<String>) {
212 let entries = match std::fs::read_dir(dir) {
213 Ok(e) => e,
214 Err(_) => return,
215 };
216 let mut entries: Vec<_> = entries.flatten().collect();
217 entries.sort_by_key(|entry| {
218 entry
219 .path()
220 .file_stem()
221 .and_then(|stem| stem.to_str())
222 .map(str::to_owned)
223 });
224 for entry in entries {
225 let path = entry.path();
226 if path.extension().is_none_or(|ext| ext != "md") {
228 continue;
229 }
230 let stem = match path.file_stem().and_then(|s| s.to_str()) {
231 Some(s) => s.to_string(),
232 None => continue,
233 };
234 if stem.starts_with('.') {
235 continue;
236 }
237 if seen.contains(&stem) {
238 continue;
239 }
240 let content = match std::fs::read_to_string(&path) {
241 Ok(c) => c,
242 Err(_) => continue,
243 };
244 seen.insert(stem.clone());
245 out.push(FileCommand::parse(&stem, &content));
246 }
247}
248
249pub fn load_file_commands(cwd: &Path) -> Vec<FileCommand> {
254 let mut commands = Vec::new();
255 let mut seen: HashSet<String> = HashSet::new();
256
257 let project_dir = cwd.join(".oxicode").join("commands");
259 scan_dir(&project_dir, &mut commands, &mut seen);
260
261 if let Some(user_dir) = oxicode_catalog::oxi_home::read_path(Path::new("commands")) {
263 scan_dir(&user_dir, &mut commands, &mut seen);
264 }
265
266 commands
267}
268pub fn try_expand(commands: &[FileCommand], input: &str) -> Option<String> {
272 let trimmed = input.trim();
273 let after_slash = trimmed.strip_prefix('/')?;
274 let (token, args) = match after_slash.find(' ') {
275 Some(space) => (&after_slash[..space], after_slash[space + 1..].trim()),
276 None => (after_slash, ""),
277 };
278 for cmd in commands {
279 if cmd.matches(token) {
280 return Some(cmd.expand(args));
281 }
282 }
283 None
284}
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::fs;
289 use std::sync::Mutex;
290 use tempfile::TempDir;
291
292 static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
293
294 struct HomeGuard(String);
295
296 impl Drop for HomeGuard {
297 fn drop(&mut self) {
298 unsafe { std::env::set_var("HOME", &self.0) };
300 }
301 }
302
303 #[test]
304 fn parse_with_frontmatter() {
305 let content = "---\ndescription: My review command\naliases: cr, review-code\n---\nReview this code:\n\n$ARGUMENTS\n";
306 let cmd = FileCommand::parse("review", content);
307 assert_eq!(cmd.name, "review");
308 assert_eq!(cmd.description, "My review command");
309 assert_eq!(cmd.aliases, vec!["cr", "review-code"]);
310 assert!(cmd.body.contains("Review this code:"));
311 assert!(cmd.body.contains("$ARGUMENTS"));
312 }
313
314 #[test]
315 fn parse_without_frontmatter_uses_body_and_first_line_description() {
316 let content = "Just do the thing\nWith more detail\n";
317 let cmd = FileCommand::parse("thing", content);
318 assert_eq!(cmd.name, "thing");
319 assert_eq!(cmd.description, "Just do the thing");
320 assert!(cmd.body.contains("Just do the thing"));
321 }
322
323 #[test]
324 fn parse_frontmatter_with_no_aliases() {
325 let content = "---\ndescription: A simple command\n---\nBody text\n";
326 let cmd = FileCommand::parse("simple", content);
327 assert!(cmd.aliases.is_empty());
328 }
329
330 #[test]
331 fn expand_arguments_placeholder() {
332 let body = "Review: $ARGUMENTS";
333 assert_eq!(expand_template(body, "src/main.rs"), "Review: src/main.rs");
334 }
335
336 #[test]
337 fn expand_at_placeholder() {
338 let body = "Check $@ now";
339 assert_eq!(expand_template(body, "foo bar"), "Check foo bar now");
340 }
341
342 #[test]
343 fn expand_positional() {
344 let body = "From $1 to $2";
345 assert_eq!(expand_template(body, "alpha beta"), "From alpha to beta");
346 }
347
348 #[test]
349 fn expand_missing_positional_becomes_empty() {
350 let body = "Only $1 and $2";
351 assert_eq!(expand_template(body, "alpha"), "Only alpha and ");
352 }
353
354 #[test]
355 fn expand_preserves_dollar_digit_in_args() {
356 assert_eq!(
357 expand_template("Summarize: $ARGUMENTS", "The $9 variable"),
358 "Summarize: The $9 variable"
359 );
360 }
361
362 #[test]
363 fn expand_no_placeholders_returns_body_unchanged() {
364 let body = "Static prompt text";
365 assert_eq!(expand_template(body, "ignored"), "Static prompt text");
366 }
367
368 #[test]
369 fn expand_empty_args() {
370 let body = "Do stuff with $ARGUMENTS";
371 assert_eq!(expand_template(body, ""), "Do stuff with ");
372 }
373
374 #[test]
375 fn matches_canonical_name() {
376 let cmd = FileCommand::parse("review", "---\ndescription: x\n---\nbody");
377 assert!(cmd.matches("review"));
378 assert!(!cmd.matches("other"));
379 }
380
381 #[test]
382 fn matches_alias() {
383 let cmd = FileCommand::parse("review", "---\ndescription: x\naliases: cr, rv\n---\nbody");
384 assert!(cmd.matches("cr"));
385 assert!(cmd.matches("rv"));
386 }
387
388 #[test]
389 fn expand_method_combines_template_and_args() {
390 let cmd = FileCommand::parse(
391 "test",
392 "---\ndescription: x\n---\nRun $1 tests for $ARGUMENTS",
393 );
394 let expanded = cmd.expand("unit src/");
395 assert_eq!(expanded, "Run unit tests for unit src/");
396 }
397
398 #[test]
399 fn split_args_basic() {
400 assert_eq!(split_args("foo bar baz"), vec!["foo", "bar", "baz"]);
401 }
402
403 #[test]
404 fn split_args_quoted() {
405 assert_eq!(
406 split_args("foo \"bar baz\" qux"),
407 vec!["foo", "bar baz", "qux"]
408 );
409 }
410
411 #[test]
412 fn load_from_project_dir() {
413 let _home_lock = HOME_ENV_LOCK
414 .lock()
415 .unwrap_or_else(std::sync::PoisonError::into_inner);
416 let tmp = TempDir::new().unwrap();
417 let tmp_home = TempDir::new().unwrap();
418 let old_home = std::env::var("HOME").unwrap_or_default();
419 let _home_guard = HomeGuard(old_home);
420 unsafe { std::env::set_var("HOME", tmp_home.path()) };
422
423 let cmds_dir = tmp.path().join(".oxicode").join("commands");
424 fs::create_dir_all(&cmds_dir).unwrap();
425 fs::write(
426 cmds_dir.join("review.md"),
427 "---\ndescription: proj review\n---\nReview $ARGUMENTS",
428 )
429 .unwrap();
430
431 let cmds = load_file_commands(tmp.path());
432 assert_eq!(cmds.len(), 1);
433 assert_eq!(cmds[0].name, "review");
434 assert_eq!(cmds[0].description, "proj review");
435 }
436
437 #[test]
438 fn scan_dir_orders_commands_alphabetically() {
439 let tmp = TempDir::new().unwrap();
440 let cmds_dir = tmp.path().join(".oxicode").join("commands");
441 fs::create_dir_all(&cmds_dir).unwrap();
442 fs::write(cmds_dir.join("zulu.md"), "zulu body").unwrap();
443 fs::write(cmds_dir.join("alpha.md"), "alpha body").unwrap();
444
445 let mut cmds = Vec::new();
446 let mut seen = HashSet::new();
447 scan_dir(&cmds_dir, &mut cmds, &mut seen);
448
449 let names: Vec<&str> = cmds.iter().map(|cmd| cmd.name.as_str()).collect();
450 assert_eq!(names, vec!["alpha", "zulu"]);
451 }
452
453 #[test]
454 fn project_shadows_user_on_name_collision() {
455 let tmp_proj = TempDir::new().unwrap();
456 let tmp_user = TempDir::new().unwrap();
457
458 let user_oxicode = tmp_user.path().join(".oxicode").join("commands");
460 fs::create_dir_all(&user_oxicode).unwrap();
461 fs::write(
462 user_oxicode.join("shared.md"),
463 "---\ndescription: USER version\n---\nuser body",
464 )
465 .unwrap();
466
467 let proj_cmds = tmp_proj.path().join(".oxicode").join("commands");
469 fs::create_dir_all(&proj_cmds).unwrap();
470 fs::write(
471 proj_cmds.join("shared.md"),
472 "---\ndescription: PROJECT version\n---\nproj body",
473 )
474 .unwrap();
475
476 let mut cmds = Vec::new();
479 let mut seen = HashSet::new();
480 scan_dir(&proj_cmds, &mut cmds, &mut seen);
481 scan_dir(&user_oxicode, &mut cmds, &mut seen);
482
483 assert_eq!(cmds.len(), 1);
484 assert_eq!(cmds[0].description, "PROJECT version");
485 }
486
487 #[test]
488 fn load_ignores_non_md_files() {
489 let tmp = TempDir::new().unwrap();
490 let cmds_dir = tmp.path().join(".oxicode").join("commands");
491 fs::create_dir_all(&cmds_dir).unwrap();
492 fs::write(cmds_dir.join("valid.md"), "---\ndescription: x\n---\nbody").unwrap();
493 fs::write(cmds_dir.join("readme.txt"), "not a command").unwrap();
494 fs::write(
495 cmds_dir.join(".hidden.md"),
496 "---\ndescription: hidden\n---\nbody",
497 )
498 .unwrap();
499
500 let mut cmds = Vec::new();
501 let mut seen = HashSet::new();
502 scan_dir(&cmds_dir, &mut cmds, &mut seen);
503
504 assert_eq!(cmds.len(), 1);
505 assert_eq!(cmds[0].name, "valid");
506 }
507
508 #[test]
509 fn load_handles_missing_dir_gracefully() {
510 let _home_lock = HOME_ENV_LOCK
511 .lock()
512 .unwrap_or_else(std::sync::PoisonError::into_inner);
513 let tmp = TempDir::new().unwrap();
514 let tmp_home = TempDir::new().unwrap();
515 let old_home = std::env::var("HOME").unwrap_or_default();
516 let _home_guard = HomeGuard(old_home);
517 unsafe { std::env::set_var("HOME", tmp_home.path()) };
519
520 let cmds = load_file_commands(tmp.path());
522 assert!(cmds.is_empty());
523 }
524
525 #[test]
526 fn split_args_empty() {
527 assert!(split_args("").is_empty());
528 }
529
530 #[test]
531 fn try_expand_matches_canonical_name() {
532 let cmds = vec![FileCommand::parse(
533 "review",
534 "---\ndescription: x\n---\nReview $ARGUMENTS",
535 )];
536 let result = try_expand(&cmds, "/review src/main.rs");
537 assert_eq!(result.as_deref(), Some("Review src/main.rs"));
538 }
539
540 #[test]
541 fn try_expand_matches_alias() {
542 let cmds = vec![FileCommand::parse(
543 "review",
544 "---\ndescription: x\naliases: cr\n---\nCode review: $ARGUMENTS",
545 )];
546 let result = try_expand(&cmds, "/cr src/lib.rs");
547 assert_eq!(result.as_deref(), Some("Code review: src/lib.rs"));
548 }
549
550 #[test]
551 fn try_expand_no_args() {
552 let cmds = vec![FileCommand::parse(
553 "deploy",
554 "---\ndescription: x\n---\nDeploy everything now",
555 )];
556 let result = try_expand(&cmds, "/deploy");
557 assert_eq!(result.as_deref(), Some("Deploy everything now"));
558 }
559
560 #[test]
561 fn try_expand_no_match_returns_none() {
562 let cmds = vec![FileCommand::parse(
563 "review",
564 "---\ndescription: x\n---\nbody",
565 )];
566 assert!(try_expand(&cmds, "/nonexistent").is_none());
567 }
568
569 #[test]
570 fn try_expand_empty_commands_returns_none() {
571 assert!(try_expand(&[], "/anything").is_none());
572 }
573}