1use crate::error::SettingsError;
10use crate::prompt_file::{PromptFile, SKILL_FILENAME};
11use std::collections::{HashMap, HashSet};
12use std::fs::{DirEntry, read_dir};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone)]
17pub struct PromptCatalog {
18 specs: Vec<PromptFile>,
19}
20
21impl PromptCatalog {
22 pub fn from_dir(skills_dir: &Path) -> Result<Self, SettingsError> {
26 let mut prompts = Vec::new();
27
28 for entry in read_dir(skills_dir).map_err(|e| SettingsError::IoError(e.to_string()))?.filter_map(Result::ok) {
29 if let Some(p) = get_path(&entry) {
30 match PromptFile::parse(&p) {
31 Ok(spec) => prompts.push(spec),
32 Err(err) => tracing::warn!("Skipping invalid skill at {}: {err}", p.display()),
33 }
34 }
35 }
36
37 validate_catalog(&prompts)?;
38
39 Ok(Self { specs: prompts })
40 }
41
42 pub fn from_dirs(skills_dirs: &[PathBuf]) -> Self {
46 let mut seen: HashMap<String, PromptFile> = HashMap::new();
47
48 for dir in skills_dirs {
49 let Ok(entries) = read_dir(dir) else {
50 tracing::warn!("Skills directory does not exist, skipping: {}", dir.display());
51 continue;
52 };
53
54 for entry in entries.filter_map(Result::ok) {
55 if let Some(p) = get_path(&entry) {
56 match PromptFile::parse(&p) {
57 Ok(spec) => {
58 seen.insert(spec.name.clone(), spec);
59 }
60 Err(err) => {
61 tracing::warn!("Skipping invalid skill at {}: {err}", p.display());
62 }
63 }
64 }
65 }
66 }
67
68 Self { specs: seen.into_values().collect() }
69 }
70
71 pub fn empty() -> Self {
73 Self { specs: Vec::new() }
74 }
75
76 pub fn all(&self) -> &[PromptFile] {
78 &self.specs
79 }
80
81 pub fn find(&self, name: &str) -> Option<&PromptFile> {
83 self.specs.iter().find(|spec| spec.name == name)
84 }
85
86 pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
88 self.specs.iter().filter(|s| s.user_invocable)
89 }
90
91 pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
93 self.specs.iter().filter(|s| s.agent_invocable)
94 }
95
96 pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
98 self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
99 }
100}
101
102fn get_path(entry: &DirEntry) -> Option<PathBuf> {
103 let path = entry.path();
104 if entry.file_name().to_string_lossy().starts_with('.') {
105 return None;
106 }
107 if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
108 Some(path.join(SKILL_FILENAME))
109 } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
110 Some(path)
111 } else {
112 None
113 }
114}
115
116fn validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
117 let mut seen_names = HashSet::new();
118 for spec in specs {
119 if !seen_names.insert(&spec.name) {
120 return Err(SettingsError::DuplicatePromptName { name: spec.name.clone() });
121 }
122 }
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::testing::project;
130
131 #[test]
132 fn discover_empty_project() {
133 let dir = project();
134 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
135 assert!(catalog.all().is_empty());
136 }
137
138 #[test]
139 fn discover_user_only_prompt() {
140 let dir = project().skill(
141 "commit",
142 "---\ndescription: Generate commit messages\nuser-invocable: true\nagent-invocable: false\n---\nGenerate a commit message.",
143 );
144
145 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
146 assert_eq!(catalog.all().len(), 1);
147
148 let spec = &catalog.all()[0];
149 assert_eq!(spec.name, "commit");
150 assert!(spec.user_invocable);
151 assert!(!spec.agent_invocable);
152 assert!(spec.triggers.is_empty());
153 }
154
155 #[test]
156 fn discover_agent_only_prompt() {
157 let dir = project()
158 .skill("explain-code", "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.");
159
160 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
161 assert_eq!(catalog.all().len(), 1);
162
163 let spec = &catalog.all()[0];
164 assert!(spec.agent_invocable);
165 assert!(spec.user_invocable);
166 }
167
168 #[test]
169 fn discover_rule_only_prompt() {
170 let dir = project().skill(
171 "rust-rules",
172 "---\ndescription: Rust conventions\nagent-invocable: false\ntriggers:\n read:\n - \"crates/**/*.rs\"\n---\nFollow Rust conventions.",
173 );
174
175 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
176 assert_eq!(catalog.all().len(), 1);
177
178 let spec = &catalog.all()[0];
179 assert!(spec.user_invocable);
180 assert!(!spec.agent_invocable);
181 assert!(!spec.triggers.is_empty());
182 assert!(spec.triggers.matches_read("crates/foo/bar.rs"));
183 assert!(!spec.triggers.matches_read("other/file.py"));
184 }
185
186 #[test]
187 fn discover_dual_use_prompt() {
188 let dir = project().skill(
189 "explain",
190 "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
191 );
192
193 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
194 let spec = &catalog.all()[0];
195 assert!(spec.user_invocable);
196 assert!(spec.agent_invocable);
197 assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
198
199 let user: Vec<_> = catalog.slash_commands().collect();
200 assert_eq!(user.len(), 1);
201 let agent: Vec<_> = catalog.skills().collect();
202 assert_eq!(agent.len(), 1);
203 }
204
205 #[test]
206 fn reject_duplicate_names() {
207 let dir = project()
208 .skill("foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.")
210 .skill("bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.");
211
212 let result = PromptCatalog::from_dir(dir.root());
213 assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. })));
214 }
215
216 #[test]
217 fn empty_description_defaults_to_name() {
218 let dir = project().skill("bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
219
220 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
221 assert_eq!(catalog.all().len(), 1);
222 assert_eq!(catalog.all()[0].description, "bad");
223 }
224
225 #[test]
226 fn skill_without_activation_surface_defaults_to_user_invocable() {
227 let dir = project().skill("noop", "---\ndescription: Does nothing\n---\nContent.");
228
229 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
230 assert_eq!(catalog.all().len(), 1);
231 assert!(catalog.all()[0].user_invocable);
232 }
233
234 #[test]
235 fn flat_md_without_activation_surface_is_skipped() {
236 let dir = project().file("noop.md", "---\ndescription: Does nothing\nagent-invocable: false\n---\nContent.");
237
238 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
239 assert!(catalog.all().is_empty());
240 }
241
242 #[test]
243 fn name_defaults_to_directory_name() {
244 let dir = project().skill("my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent.");
245
246 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
247 assert_eq!(catalog.all()[0].name, "my-skill");
248 }
249
250 #[test]
251 fn name_from_frontmatter_overrides_directory() {
252 let dir = project()
253 .skill("dir-name", "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.");
254
255 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
256 assert_eq!(catalog.all()[0].name, "custom-name");
257 }
258
259 #[test]
260 fn matching_read_rules_finds_matches() {
261 let dir = project()
262 .skill(
263 "rust-rules",
264 "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.",
265 )
266 .skill("ts-rules", "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.")
267 .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
268
269 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
270 let matches = catalog.matching_rules("src/main.rs");
271 assert_eq!(matches.len(), 1);
272 assert_eq!(matches[0].name, "rust-rules");
273
274 let matches = catalog.matching_rules("src/app.ts");
275 assert_eq!(matches.len(), 1);
276 assert_eq!(matches[0].name, "ts-rules");
277
278 let matches = catalog.matching_rules("README.md");
279 assert!(matches.is_empty());
280 }
281
282 #[test]
283 fn pure_flat_rule_not_in_user_or_agent_invocable() {
284 let dir = project().file(
285 "rule.md",
286 "---\ndescription: A rule\nagent-invocable: false\ntriggers:\n read:\n - \"*.rs\"\n---\nRule content.",
287 );
288
289 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
290 assert_eq!(catalog.all().len(), 1);
291 assert_eq!(catalog.slash_commands().count(), 0);
292 assert_eq!(catalog.skills().count(), 0);
293 }
294
295 #[test]
296 fn skips_hidden_directories() {
297 let dir = project()
298 .skill(".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.")
299 .skill("visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
300
301 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
302 assert_eq!(catalog.all().len(), 1);
303 assert_eq!(catalog.all()[0].name, "visible");
304 }
305
306 #[test]
307 fn preserves_tags_and_metadata() {
308 let dir = project().skill(
309 "tagged",
310 "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n - rust\n - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
311 );
312
313 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
314 let spec = &catalog.all()[0];
315 assert_eq!(spec.tags, vec!["rust", "testing"]);
316 assert!(spec.agent_authored);
317 assert_eq!(spec.helpful, 5);
318 assert_eq!(spec.harmful, 1);
319 }
320
321 #[test]
322 fn from_dirs_last_wins() {
323 let dir_a = project().skill("rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
324 let dir_b = project().skill("rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
325
326 let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
327 assert_eq!(catalog.all().len(), 1);
328
329 let spec = &catalog.all()[0];
330 assert_eq!(spec.name, "rust");
331 assert_eq!(spec.description, "Rust B");
332 assert!(spec.body.contains("From dir B."));
333 }
334
335 #[test]
336 fn from_dirs_union() {
337 let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
338 let dir_b = project().skill("python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
339
340 let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
341 assert_eq!(catalog.all().len(), 2);
342
343 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
344 assert!(names.contains(&"rust"));
345 assert!(names.contains(&"python"));
346 }
347
348 #[test]
349 fn from_dirs_skips_missing() {
350 let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
351 let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
352
353 let catalog = PromptCatalog::from_dirs(&[missing, dir_a.root().to_path_buf()]);
354 assert_eq!(catalog.all().len(), 1);
355 assert_eq!(catalog.all()[0].name, "rust");
356 }
357
358 #[test]
359 fn discover_flat_md_rule_with_globs() {
360 let dir = project().file(
361 "rust-conventions.md",
362 "---\ndescription: Rust conventions\nglobs:\n - \"**/*.rs\"\n---\nFollow Rust conventions.",
363 );
364
365 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
366 assert_eq!(catalog.all().len(), 1);
367
368 let spec = &catalog.all()[0];
369 assert_eq!(spec.name, "rust-conventions");
370 assert_eq!(spec.description, "Rust conventions");
371 assert!(spec.triggers.matches_read("src/main.rs"));
372 assert!(!spec.triggers.matches_read("README.md"));
373 }
374
375 #[test]
376 fn discover_flat_md_rule_with_paths() {
377 let dir = project()
378 .file("ts-rules.md", "---\ndescription: TS rules\npaths:\n - \"**/*.ts\"\n---\nTypeScript rules.");
379
380 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
381 assert_eq!(catalog.all().len(), 1);
382
383 let spec = &catalog.all()[0];
384 assert_eq!(spec.name, "ts-rules");
385 assert!(spec.triggers.matches_read("src/index.ts"));
386 }
387
388 #[test]
389 fn discover_mixed_skill_md_and_flat_rules() {
390 let dir = project()
391 .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.")
392 .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.");
393
394 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
395 assert_eq!(catalog.all().len(), 2);
396
397 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
398 assert!(names.contains(&"commit"));
399 assert!(names.contains(&"rust-rules"));
400 }
401
402 #[test]
403 fn from_dirs_merges_flat_rules() {
404 let dir_a = project().skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
405 let dir_b = project()
406 .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.");
407
408 let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
409 assert_eq!(catalog.all().len(), 2);
410
411 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
412 assert!(names.contains(&"commit"));
413 assert!(names.contains(&"rust-rules"));
414 }
415
416 #[test]
417 fn flat_rule_without_description_uses_name() {
418 let dir = project().file("my-rule.md", "---\nglobs:\n - \"**/*.rs\"\n---\nRule body.");
419
420 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
421 assert_eq!(catalog.all().len(), 1);
422
423 let spec = &catalog.all()[0];
424 assert_eq!(spec.name, "my-rule");
425 assert_eq!(spec.description, "my-rule");
426 }
427
428 #[test]
429 fn skips_hidden_flat_md_files() {
430 let dir = project()
431 .file(".hidden-rule.md", "---\ndescription: Hidden\nglobs:\n - \"**/*.rs\"\n---\nHidden.")
432 .file("visible-rule.md", "---\ndescription: Visible\nglobs:\n - \"**/*.ts\"\n---\nVisible.");
433
434 let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
435 assert_eq!(catalog.all().len(), 1);
436 assert_eq!(catalog.all()[0].name, "visible-rule");
437 }
438}