1use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::LoadError;
12use crate::skills::{frontmatter, read_capped, skill_name_ok};
13
14pub const MAX_COMMAND_BYTES: u64 = 64 * 1024;
16pub const MAX_COMMANDS_PER_DIR: usize = 100;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Command {
22 pub name: String,
24 pub description: String,
25 pub argument_hint: String,
27 pub body: String,
29 pub file: PathBuf,
30 pub meta: BTreeMap<String, String>,
32}
33
34impl Command {
35 pub fn expand(&self, args: &str) -> String {
38 let args = args.trim();
39 let words: Vec<&str> = args.split_whitespace().collect();
40 let mut out = String::with_capacity(self.body.len() + args.len());
41 let mut used = false;
42 let mut rest = self.body.as_str();
43 while let Some(i) = rest.find('$') {
44 out.push_str(rest.get(..i).unwrap_or_default());
45 let after = rest.get(i + 1..).unwrap_or_default();
46 if let Some(tail) = after.strip_prefix("ARGUMENTS") {
47 out.push_str(args);
48 used = true;
49 rest = tail;
50 } else if let Some(d) = after.chars().next().and_then(|c| c.to_digit(10)).filter(|d| *d >= 1) {
51 let n = usize::try_from(d).unwrap_or(1);
52 out.push_str(words.get(n - 1).copied().unwrap_or_default());
53 used = true;
54 rest = after.get(1..).unwrap_or_default();
55 } else {
56 out.push('$');
57 rest = after;
58 }
59 }
60 out.push_str(rest);
61 let mut out = out.trim().to_owned();
62 if !used && !args.is_empty() {
63 out.push_str("\n\nARGUMENTS: ");
64 out.push_str(args);
65 }
66 out
67 }
68}
69
70pub fn parse_command(text: &str, file: &Path) -> Result<Command, String> {
72 let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
73 let shown = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
74 let name = stem.to_lowercase();
75 if !skill_name_ok(&name) {
76 return Err(format!("commands/{shown}: name the file with lowercase words and dashes, like \"review-pr.md\""));
77 }
78 let (mut meta, body) = frontmatter(text);
79 let body = body.trim().to_owned();
80 if body.is_empty() {
81 return Err(format!("commands/{shown}: the file has no prompt"));
82 }
83 let description = meta.remove("description").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| first_line(&body));
84 let argument_hint = meta.remove("argument-hint").unwrap_or_default();
85 Ok(Command { name, description, argument_hint, body, file: file.to_path_buf(), meta })
86}
87
88fn first_line(body: &str) -> String {
90 let line = body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
91 line.trim_start_matches('#').trim().chars().take(120).collect()
92}
93
94pub fn load_commands_dir(dir: &Path) -> (Vec<Command>, Vec<LoadError>) {
97 let mut commands: Vec<Command> = Vec::new();
98 let mut errors = Vec::new();
99 let Ok(rd) = fs::read_dir(dir) else { return (commands, errors) };
100 let mut files: Vec<PathBuf> = rd
101 .flatten()
102 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
103 .map(|e| e.path())
104 .filter(|p| p.is_file() && p.extension().is_some_and(|x| x.eq_ignore_ascii_case("md")))
105 .collect();
106 files.sort();
107 if files.len() > MAX_COMMANDS_PER_DIR {
108 errors.push(LoadError { path: dir.to_path_buf(), message: format!("only the first {MAX_COMMANDS_PER_DIR} command files are read") });
109 files.truncate(MAX_COMMANDS_PER_DIR);
110 }
111 for file in files {
112 match read_capped(&file, MAX_COMMAND_BYTES).and_then(|text| parse_command(&text, &file)) {
113 Ok(c) if commands.iter().any(|d| d.name == c.name) => {
114 errors.push(LoadError { path: file, message: format!("command \"{}\" is defined twice", c.name) });
115 }
116 Ok(c) => commands.push(c),
117 Err(message) => errors.push(LoadError { path: file, message }),
118 }
119 }
120 (commands, errors)
121}
122
123pub fn split_slash(text: &str) -> Option<(&str, &str)> {
126 let rest = text.trim_start().strip_prefix('/')?;
127 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
128 let (name, args) = rest.split_at(end);
129 skill_name_ok(name).then_some((name, args.trim()))
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 fn cmd(body: &str) -> Command {
137 parse_command(body, Path::new("/p/commands/review.md")).unwrap()
138 }
139
140 #[test]
141 fn templates_fill_in_arguments() {
142 let c = cmd("---\ndescription: Review a file\nargument-hint: <file> [focus]\n---\nReview $1 with a focus on $2.\nAll: $ARGUMENTS; cost $5 stays.");
143 assert_eq!((c.name.as_str(), c.description.as_str(), c.argument_hint.as_str()), ("review", "Review a file", "<file> [focus]"));
144 assert_eq!(c.expand(" src/a.rs errors "), "Review src/a.rs with a focus on errors.\nAll: src/a.rs errors; cost stays.");
145 let plain = cmd("# Summarize the chat\nBe brief.");
147 assert_eq!(plain.description, "Summarize the chat");
148 assert_eq!(plain.expand("in French"), "# Summarize the chat\nBe brief.\n\nARGUMENTS: in French");
149 assert_eq!(plain.expand(""), "# Summarize the chat\nBe brief.");
150 assert_eq!(cmd("price: $").expand("x"), "price: $\n\nARGUMENTS: x");
151 assert!(parse_command("---\ndescription: x\n---\n", Path::new("/p/commands/empty.md")).is_err());
152 assert!(parse_command("hi", Path::new("/p/commands/Bad Name.md")).is_err());
153 }
154
155 #[test]
156 fn slash_text_splits_into_name_and_arguments() {
157 assert_eq!(split_slash("/review src/a.rs now"), Some(("review", "src/a.rs now")));
158 assert_eq!(split_slash(" /compact"), Some(("compact", "")));
159 assert_eq!(split_slash("/review\nmore lines"), Some(("review", "more lines")));
160 assert_eq!(split_slash("/usr/bin/env is a path"), None);
161 assert_eq!(split_slash("no slash"), None);
162 assert_eq!(split_slash("/"), None);
163 }
164
165 #[test]
166 fn a_folder_loads_good_files_and_reports_bad_ones() {
167 let d = tempfile::tempdir().unwrap();
168 fs::write(d.path().join("b-one.md"), "Do B.").unwrap();
169 fs::write(d.path().join("a-one.md"), "---\ndescription: A\n---\nDo A with $ARGUMENTS").unwrap();
170 fs::write(d.path().join("Bad Name.md"), "x").unwrap();
171 fs::write(d.path().join("notes.txt"), "ignored").unwrap();
172 let (cmds, errors) = load_commands_dir(d.path());
173 assert_eq!(cmds.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["a-one", "b-one"]);
174 assert_eq!(errors.len(), 1, "{errors:?}");
175 assert!(load_commands_dir(&d.path().join("missing")).0.is_empty());
176 }
177}