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 slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
83 self.specs.iter().filter(|s| s.user_invocable)
84 }
85
86 pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
88 self.specs.iter().filter(|s| s.agent_invocable)
89 }
90
91 pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
93 self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
94 }
95}
96
97fn get_path(entry: &DirEntry) -> Option<PathBuf> {
98 let path = entry.path();
99 if entry.file_name().to_string_lossy().starts_with('.') {
100 return None;
101 }
102 if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
103 Some(path.join(SKILL_FILENAME))
104 } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
105 Some(path)
106 } else {
107 None
108 }
109}
110
111fn validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
112 let mut seen_names = HashSet::new();
113 for spec in specs {
114 if !seen_names.insert(&spec.name) {
115 return Err(SettingsError::DuplicatePromptName { name: spec.name.clone() });
116 }
117 }
118 Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::fs;
125 use tempfile::TempDir;
126
127 fn create_temp_project() -> TempDir {
128 tempfile::tempdir().unwrap()
129 }
130
131 fn write_skill(dir: &Path, name: &str, content: &str) {
132 let skill_dir = dir.join(name);
133 fs::create_dir_all(&skill_dir).unwrap();
134 fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
135 }
136
137 #[test]
138 fn discover_empty_project() {
139 let dir = create_temp_project();
140 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
141 assert!(catalog.all().is_empty());
142 }
143
144 #[test]
145 fn discover_user_only_prompt() {
146 let dir = create_temp_project();
147 write_skill(
148 dir.path(),
149 "commit",
150 "---\ndescription: Generate commit messages\nuser-invocable: true\n---\nGenerate a commit message.",
151 );
152
153 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
154 assert_eq!(catalog.all().len(), 1);
155
156 let spec = &catalog.all()[0];
157 assert_eq!(spec.name, "commit");
158 assert!(spec.user_invocable);
159 assert!(!spec.agent_invocable);
160 assert!(spec.triggers.is_empty());
161 }
162
163 #[test]
164 fn discover_agent_only_prompt() {
165 let dir = create_temp_project();
166 write_skill(
167 dir.path(),
168 "explain-code",
169 "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
170 );
171
172 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
173 assert_eq!(catalog.all().len(), 1);
174
175 let spec = &catalog.all()[0];
176 assert!(spec.agent_invocable);
177 assert!(spec.user_invocable);
178 }
179
180 #[test]
181 fn discover_rule_only_prompt() {
182 let dir = create_temp_project();
183 write_skill(
184 dir.path(),
185 "rust-rules",
186 "---\ndescription: Rust conventions\ntriggers:\n read:\n - \"packages/**/*.rs\"\n---\nFollow Rust conventions.",
187 );
188
189 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
190 assert_eq!(catalog.all().len(), 1);
191
192 let spec = &catalog.all()[0];
193 assert!(spec.user_invocable);
194 assert!(!spec.agent_invocable);
195 assert!(!spec.triggers.is_empty());
196 assert!(spec.triggers.matches_read("packages/foo/bar.rs"));
197 assert!(!spec.triggers.matches_read("other/file.py"));
198 }
199
200 #[test]
201 fn discover_dual_use_prompt() {
202 let dir = create_temp_project();
203 write_skill(
204 dir.path(),
205 "explain",
206 "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
207 );
208
209 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
210 let spec = &catalog.all()[0];
211 assert!(spec.user_invocable);
212 assert!(spec.agent_invocable);
213 assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
214
215 let user: Vec<_> = catalog.slash_commands().collect();
216 assert_eq!(user.len(), 1);
217 let agent: Vec<_> = catalog.skills().collect();
218 assert_eq!(agent.len(), 1);
219 }
220
221 #[test]
222 fn reject_duplicate_names() {
223 let dir = create_temp_project();
224 write_skill(dir.path(), "foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.");
225 write_skill(dir.path(), "bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.");
227
228 let result = PromptCatalog::from_dir(dir.path());
229 assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. })));
230 }
231
232 #[test]
233 fn empty_description_defaults_to_name() {
234 let dir = create_temp_project();
235 write_skill(dir.path(), "bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
236
237 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
238 assert_eq!(catalog.all().len(), 1);
239 assert_eq!(catalog.all()[0].description, "bad");
240 }
241
242 #[test]
243 fn skill_without_activation_surface_defaults_to_user_invocable() {
244 let dir = create_temp_project();
245 write_skill(dir.path(), "noop", "---\ndescription: Does nothing\n---\nContent.");
246
247 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
248 assert_eq!(catalog.all().len(), 1);
249 assert!(catalog.all()[0].user_invocable);
250 }
251
252 #[test]
253 fn flat_md_without_activation_surface_is_skipped() {
254 let dir = create_temp_project();
255 write_flat_rule(dir.path(), "noop.md", "---\ndescription: Does nothing\n---\nContent.");
256
257 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
258 assert!(catalog.all().is_empty());
259 }
260
261 #[test]
262 fn name_defaults_to_directory_name() {
263 let dir = create_temp_project();
264 write_skill(dir.path(), "my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent.");
265
266 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
267 assert_eq!(catalog.all()[0].name, "my-skill");
268 }
269
270 #[test]
271 fn name_from_frontmatter_overrides_directory() {
272 let dir = create_temp_project();
273 write_skill(
274 dir.path(),
275 "dir-name",
276 "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
277 );
278
279 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
280 assert_eq!(catalog.all()[0].name, "custom-name");
281 }
282
283 #[test]
284 fn matching_read_rules_finds_matches() {
285 let dir = create_temp_project();
286 write_skill(
287 dir.path(),
288 "rust-rules",
289 "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.",
290 );
291 write_skill(
292 dir.path(),
293 "ts-rules",
294 "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.",
295 );
296 write_skill(dir.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
297
298 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
299 let matches = catalog.matching_rules("src/main.rs");
300 assert_eq!(matches.len(), 1);
301 assert_eq!(matches[0].name, "rust-rules");
302
303 let matches = catalog.matching_rules("src/app.ts");
304 assert_eq!(matches.len(), 1);
305 assert_eq!(matches[0].name, "ts-rules");
306
307 let matches = catalog.matching_rules("README.md");
308 assert!(matches.is_empty());
309 }
310
311 #[test]
312 fn pure_flat_rule_not_in_user_or_agent_invocable() {
313 let dir = create_temp_project();
314 write_flat_rule(
315 dir.path(),
316 "rule.md",
317 "---\ndescription: A rule\ntriggers:\n read:\n - \"*.rs\"\n---\nRule content.",
318 );
319
320 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
321 assert_eq!(catalog.all().len(), 1);
322 assert_eq!(catalog.slash_commands().count(), 0);
323 assert_eq!(catalog.skills().count(), 0);
324 }
325
326 #[test]
327 fn skips_hidden_directories() {
328 let dir = create_temp_project();
329 write_skill(dir.path(), ".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.");
330 write_skill(dir.path(), "visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
331
332 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
333 assert_eq!(catalog.all().len(), 1);
334 assert_eq!(catalog.all()[0].name, "visible");
335 }
336
337 #[test]
338 fn preserves_tags_and_metadata() {
339 let dir = create_temp_project();
340 write_skill(
341 dir.path(),
342 "tagged",
343 "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n - rust\n - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
344 );
345
346 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
347 let spec = &catalog.all()[0];
348 assert_eq!(spec.tags, vec!["rust", "testing"]);
349 assert!(spec.agent_authored);
350 assert_eq!(spec.helpful, 5);
351 assert_eq!(spec.harmful, 1);
352 }
353
354 #[test]
355 fn from_dirs_last_wins() {
356 let dir_a = create_temp_project();
357 let dir_b = create_temp_project();
358 write_skill(dir_a.path(), "rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
359 write_skill(dir_b.path(), "rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
360
361 let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
362 assert_eq!(catalog.all().len(), 1);
363
364 let spec = &catalog.all()[0];
365 assert_eq!(spec.name, "rust");
366 assert_eq!(spec.description, "Rust B");
367 assert!(spec.body.contains("From dir B."));
368 }
369
370 #[test]
371 fn from_dirs_union() {
372 let dir_a = create_temp_project();
373 let dir_b = create_temp_project();
374 write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
375 write_skill(dir_b.path(), "python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
376
377 let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
378 assert_eq!(catalog.all().len(), 2);
379
380 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
381 assert!(names.contains(&"rust"));
382 assert!(names.contains(&"python"));
383 }
384
385 #[test]
386 fn from_dirs_skips_missing() {
387 let dir_a = create_temp_project();
388 let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
389 write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
390
391 let catalog = PromptCatalog::from_dirs(&[missing, dir_a.path().to_path_buf()]);
392 assert_eq!(catalog.all().len(), 1);
393 assert_eq!(catalog.all()[0].name, "rust");
394 }
395
396 fn write_flat_rule(dir: &Path, filename: &str, content: &str) {
397 fs::write(dir.join(filename), content).unwrap();
398 }
399
400 #[test]
401 fn discover_flat_md_rule_with_globs() {
402 let dir = create_temp_project();
403 write_flat_rule(
404 dir.path(),
405 "rust-conventions.md",
406 "---\ndescription: Rust conventions\nglobs:\n - \"**/*.rs\"\n---\nFollow Rust conventions.",
407 );
408
409 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
410 assert_eq!(catalog.all().len(), 1);
411
412 let spec = &catalog.all()[0];
413 assert_eq!(spec.name, "rust-conventions");
414 assert_eq!(spec.description, "Rust conventions");
415 assert!(spec.triggers.matches_read("src/main.rs"));
416 assert!(!spec.triggers.matches_read("README.md"));
417 }
418
419 #[test]
420 fn discover_flat_md_rule_with_paths() {
421 let dir = create_temp_project();
422 write_flat_rule(
423 dir.path(),
424 "ts-rules.md",
425 "---\ndescription: TS rules\npaths:\n - \"**/*.ts\"\n---\nTypeScript rules.",
426 );
427
428 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
429 assert_eq!(catalog.all().len(), 1);
430
431 let spec = &catalog.all()[0];
432 assert_eq!(spec.name, "ts-rules");
433 assert!(spec.triggers.matches_read("src/index.ts"));
434 }
435
436 #[test]
437 fn discover_mixed_skill_md_and_flat_rules() {
438 let dir = create_temp_project();
439 write_skill(dir.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.");
440 write_flat_rule(
441 dir.path(),
442 "rust-rules.md",
443 "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.",
444 );
445
446 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
447 assert_eq!(catalog.all().len(), 2);
448
449 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
450 assert!(names.contains(&"commit"));
451 assert!(names.contains(&"rust-rules"));
452 }
453
454 #[test]
455 fn from_dirs_merges_flat_rules() {
456 let dir_a = create_temp_project();
457 let dir_b = create_temp_project();
458 write_skill(dir_a.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
459 write_flat_rule(
460 dir_b.path(),
461 "rust-rules.md",
462 "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions.",
463 );
464
465 let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
466 assert_eq!(catalog.all().len(), 2);
467
468 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
469 assert!(names.contains(&"commit"));
470 assert!(names.contains(&"rust-rules"));
471 }
472
473 #[test]
474 fn flat_rule_without_description_uses_name() {
475 let dir = create_temp_project();
476 write_flat_rule(dir.path(), "my-rule.md", "---\nglobs:\n - \"**/*.rs\"\n---\nRule body.");
477
478 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
479 assert_eq!(catalog.all().len(), 1);
480
481 let spec = &catalog.all()[0];
482 assert_eq!(spec.name, "my-rule");
483 assert_eq!(spec.description, "my-rule");
484 }
485
486 #[test]
487 fn skips_hidden_flat_md_files() {
488 let dir = create_temp_project();
489 write_flat_rule(
490 dir.path(),
491 ".hidden-rule.md",
492 "---\ndescription: Hidden\nglobs:\n - \"**/*.rs\"\n---\nHidden.",
493 );
494 write_flat_rule(
495 dir.path(),
496 "visible-rule.md",
497 "---\ndescription: Visible\nglobs:\n - \"**/*.ts\"\n---\nVisible.",
498 );
499
500 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
501 assert_eq!(catalog.all().len(), 1);
502 assert_eq!(catalog.all()[0].name, "visible-rule");
503 }
504}