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