1use crate::prompt_file::{PromptFile, SKILL_FILENAME};
10use std::collections::BTreeMap;
11use std::fs::{DirEntry, ReadDir, read_dir};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
16pub struct PromptCatalog {
17 specs: Vec<PromptFile>,
18}
19
20impl PromptCatalog {
21 pub fn from_dirs(skills_dirs: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
26 let mut seen: BTreeMap<String, PromptFile> = BTreeMap::new();
27
28 for dir in skills_dirs {
29 let dir = dir.as_ref();
30 let Ok(entries) = read_dir(dir) else {
31 tracing::warn!("Skills directory does not exist, skipping: {}", dir.display());
32 continue;
33 };
34 seen.extend(parse_skills(entries).map(|spec| (spec.name.clone(), spec)));
35 }
36
37 Self { specs: seen.into_values().collect() }
38 }
39
40 pub fn empty() -> Self {
42 Self { specs: Vec::new() }
43 }
44
45 pub fn all(&self) -> &[PromptFile] {
47 &self.specs
48 }
49
50 pub fn find(&self, name: &str) -> Option<&PromptFile> {
52 self.specs.iter().find(|spec| spec.name == name)
53 }
54
55 pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
57 self.specs.iter().filter(|s| s.user_invocable)
58 }
59
60 pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
62 self.specs.iter().filter(|s| s.agent_invocable)
63 }
64
65 pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
67 self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
68 }
69}
70
71fn parse_skills(entries: ReadDir) -> impl Iterator<Item = PromptFile> {
72 entries.filter_map(Result::ok).filter_map(|entry| get_path(&entry)).filter_map(|path| {
73 PromptFile::parse(&path)
74 .inspect_err(|err| tracing::warn!("Skipping invalid skill at {}: {err}", path.display()))
75 .ok()
76 })
77}
78
79fn get_path(entry: &DirEntry) -> Option<PathBuf> {
80 let path = entry.path();
81 if entry.file_name().to_string_lossy().starts_with('.') {
82 return None;
83 }
84 if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
85 Some(path.join(SKILL_FILENAME))
86 } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
87 Some(path)
88 } else {
89 None
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::testing::project;
97
98 #[test]
99 fn discover_empty_project() {
100 let dir = project();
101 let catalog = PromptCatalog::from_dirs([dir.root()]);
102 assert!(catalog.all().is_empty());
103 }
104
105 #[test]
106 fn discover_user_only_prompt() {
107 let dir = project().file(
108 "commit/SKILL.md",
109 "---\ndescription: Generate commit messages\nuser-invocable: true\nagent-invocable: false\n---\nGenerate a commit message.",
110 );
111
112 let catalog = PromptCatalog::from_dirs([dir.root()]);
113 assert_eq!(catalog.all().len(), 1);
114
115 let spec = &catalog.all()[0];
116 assert_eq!(spec.name, "commit");
117 assert!(spec.user_invocable);
118 assert!(!spec.agent_invocable);
119 assert!(spec.triggers.is_empty());
120 }
121
122 #[test]
123 fn discover_agent_only_prompt() {
124 let dir = project().file(
125 "explain-code/SKILL.md",
126 "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
127 );
128
129 let catalog = PromptCatalog::from_dirs([dir.root()]);
130 assert_eq!(catalog.all().len(), 1);
131
132 let spec = &catalog.all()[0];
133 assert!(spec.agent_invocable);
134 assert!(spec.user_invocable);
135 }
136
137 #[test]
138 fn discover_rule_only_prompt() {
139 let dir = project().file(
140 "rust-rules/SKILL.md",
141 "---\ndescription: Rust conventions\nagent-invocable: false\ntriggers:\n read:\n - \"crates/**/*.rs\"\n---\nFollow Rust conventions.",
142 );
143
144 let catalog = PromptCatalog::from_dirs([dir.root()]);
145 assert_eq!(catalog.all().len(), 1);
146
147 let spec = &catalog.all()[0];
148 assert!(spec.user_invocable);
149 assert!(!spec.agent_invocable);
150 assert!(!spec.triggers.is_empty());
151 assert!(spec.triggers.matches_read("crates/foo/bar.rs"));
152 assert!(!spec.triggers.matches_read("other/file.py"));
153 }
154
155 #[test]
156 fn discover_dual_use_prompt() {
157 let dir = project().file(
158 "explain/SKILL.md",
159 "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
160 );
161
162 let catalog = PromptCatalog::from_dirs([dir.root()]);
163 let spec = &catalog.all()[0];
164 assert!(spec.user_invocable);
165 assert!(spec.agent_invocable);
166 assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
167
168 let user: Vec<_> = catalog.slash_commands().collect();
169 assert_eq!(user.len(), 1);
170 let agent: Vec<_> = catalog.skills().collect();
171 assert_eq!(agent.len(), 1);
172 }
173
174 #[test]
175 fn empty_description_defaults_to_name() {
176 let dir = project().file("bad/SKILL.md", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
177
178 let catalog = PromptCatalog::from_dirs([dir.root()]);
179 assert_eq!(catalog.all().len(), 1);
180 assert_eq!(catalog.all()[0].description, "bad");
181 }
182
183 #[test]
184 fn flat_md_without_activation_surface_is_skipped() {
185 let dir = project().file("noop.md", "---\ndescription: Does nothing\nagent-invocable: false\n---\nContent.");
186
187 let catalog = PromptCatalog::from_dirs([dir.root()]);
188 assert!(catalog.all().is_empty());
189 }
190
191 #[test]
192 fn name_from_frontmatter_overrides_directory() {
193 let dir = project().file(
194 "dir-name/SKILL.md",
195 "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
196 );
197
198 let catalog = PromptCatalog::from_dirs([dir.root()]);
199 assert_eq!(catalog.all()[0].name, "custom-name");
200 }
201
202 #[test]
203 fn matching_read_rules_finds_matches() {
204 let dir = project()
205 .file(
206 "rust-rules/SKILL.md",
207 "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.",
208 )
209 .file(
210 "ts-rules/SKILL.md",
211 "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.",
212 )
213 .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
214
215 let catalog = PromptCatalog::from_dirs([dir.root()]);
216 let matches = catalog.matching_rules("src/main.rs");
217 assert_eq!(matches.len(), 1);
218 assert_eq!(matches[0].name, "rust-rules");
219
220 let matches = catalog.matching_rules("src/app.ts");
221 assert_eq!(matches.len(), 1);
222 assert_eq!(matches[0].name, "ts-rules");
223
224 let matches = catalog.matching_rules("README.md");
225 assert!(matches.is_empty());
226 }
227
228 #[test]
229 fn pure_flat_rule_not_in_user_or_agent_invocable() {
230 let dir = project().file(
231 "rule.md",
232 "---\ndescription: A rule\nagent-invocable: false\ntriggers:\n read:\n - \"*.rs\"\n---\nRule content.",
233 );
234
235 let catalog = PromptCatalog::from_dirs([dir.root()]);
236 assert_eq!(catalog.all().len(), 1);
237 assert_eq!(catalog.slash_commands().count(), 0);
238 assert_eq!(catalog.skills().count(), 0);
239 }
240
241 #[test]
242 fn skips_hidden_directories() {
243 let dir = project()
244 .file(".archived/SKILL.md", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.")
245 .file("visible/SKILL.md", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
246
247 let catalog = PromptCatalog::from_dirs([dir.root()]);
248 assert_eq!(catalog.all().len(), 1);
249 assert_eq!(catalog.all()[0].name, "visible");
250 }
251
252 #[test]
253 fn from_dirs_last_wins() {
254 let dir_a =
255 project().file("rust/SKILL.md", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
256 let dir_b =
257 project().file("rust/SKILL.md", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
258
259 let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
260 assert_eq!(catalog.all().len(), 1);
261
262 let spec = &catalog.all()[0];
263 assert_eq!(spec.name, "rust");
264 assert_eq!(spec.description, "Rust B");
265 assert!(spec.body.contains("From dir B."));
266 }
267
268 #[test]
269 fn from_dirs_union() {
270 let dir_a =
271 project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
272 let dir_b =
273 project().file("python/SKILL.md", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
274
275 let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
276 assert_eq!(catalog.all().len(), 2);
277
278 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
279 assert!(names.contains(&"rust"));
280 assert!(names.contains(&"python"));
281 }
282
283 #[test]
284 fn from_dirs_lists_prompts_by_name() {
285 let names = ["delta", "alpha", "echo", "charlie", "bravo", "foxtrot"];
286 let dir = names.iter().fold(project(), |dir, name| {
287 dir.file(format!("{name}/SKILL.md"), "---\ndescription: A skill\nagent-invocable: true\n---\nBody.")
288 });
289
290 let catalog = PromptCatalog::from_dirs([dir.root()]);
291
292 let listed: Vec<&str> = catalog.all().iter().map(|spec| spec.name.as_str()).collect();
293 assert_eq!(listed, ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"]);
294 }
295
296 #[test]
297 fn from_dirs_skips_missing() {
298 let dir_a =
299 project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
300 let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
301
302 let catalog = PromptCatalog::from_dirs([missing.as_path(), dir_a.root()]);
303 assert_eq!(catalog.all().len(), 1);
304 assert_eq!(catalog.all()[0].name, "rust");
305 }
306
307 #[test]
308 fn discover_mixed_skill_md_and_flat_rules() {
309 let dir = project()
310 .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.")
311 .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.");
312
313 let catalog = PromptCatalog::from_dirs([dir.root()]);
314 assert_eq!(catalog.all().len(), 2);
315
316 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
317 assert!(names.contains(&"commit"));
318 assert!(names.contains(&"rust-rules"));
319 }
320
321 #[test]
322 fn from_dirs_merges_flat_rules() {
323 let dir_a = project().file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
324 let dir_b = project()
325 .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.");
326
327 let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
328 assert_eq!(catalog.all().len(), 2);
329
330 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
331 assert!(names.contains(&"commit"));
332 assert!(names.contains(&"rust-rules"));
333 }
334
335 #[test]
336 fn skips_hidden_flat_md_files() {
337 let dir = project()
338 .file(".hidden-rule.md", "---\ndescription: Hidden\nglobs:\n - \"**/*.rs\"\n---\nHidden.")
339 .file("visible-rule.md", "---\ndescription: Visible\nglobs:\n - \"**/*.ts\"\n---\nVisible.");
340
341 let catalog = PromptCatalog::from_dirs([dir.root()]);
342 assert_eq!(catalog.all().len(), 1);
343 assert_eq!(catalog.all()[0].name, "visible-rule");
344 }
345}