1use crate::error::SettingsError;
10use crate::prompt_file::{PromptFile, SKILL_FILENAME};
11use std::collections::{HashMap, HashSet};
12use std::fs::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 prompts: Vec<PromptFile> = read_dir(skills_dir)
27 .map_err(|e| SettingsError::IoError(e.to_string()))?
28 .filter_map(Result::ok)
29 .filter(|e| e.path().is_dir() && !e.file_name().to_string_lossy().starts_with('.'))
30 .filter(|e| e.path().join(SKILL_FILENAME).is_file())
31 .filter_map(|e| match PromptFile::parse(&e.path().join(SKILL_FILENAME)) {
32 Ok(spec) => Some(spec),
33 Err(err) => {
34 tracing::warn!("Skipping invalid skill at {}: {err}", e.path().display());
35 None
36 }
37 })
38 .collect();
39
40 validate_catalog(&prompts)?;
41
42 Ok(Self { specs: prompts })
43 }
44
45 pub fn from_dirs(skills_dirs: &[PathBuf]) -> Self {
49 let mut seen: HashMap<String, PromptFile> = HashMap::new();
50
51 for dir in skills_dirs {
52 let Ok(entries) = read_dir(dir) else {
53 tracing::warn!("Skills directory does not exist, skipping: {}", dir.display());
54 continue;
55 };
56
57 for entry in entries
58 .filter_map(Result::ok)
59 .filter(|e| e.path().is_dir() && !e.file_name().to_string_lossy().starts_with('.'))
60 .filter(|e| e.path().join(SKILL_FILENAME).is_file())
61 {
62 match PromptFile::parse(&entry.path().join(SKILL_FILENAME)) {
63 Ok(spec) => {
64 seen.insert(spec.name.clone(), spec);
65 }
66 Err(err) => {
67 tracing::warn!("Skipping invalid skill at {}: {err}", entry.path().display());
68 }
69 }
70 }
71 }
72
73 Self { specs: seen.into_values().collect() }
74 }
75
76 pub fn empty() -> Self {
78 Self { specs: Vec::new() }
79 }
80
81 pub fn all(&self) -> &[PromptFile] {
83 &self.specs
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 validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
103 let mut seen_names = HashSet::new();
104 for spec in specs {
105 if !seen_names.insert(&spec.name) {
106 return Err(SettingsError::DuplicatePromptName { name: spec.name.clone() });
107 }
108 }
109 Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use std::fs;
116 use tempfile::TempDir;
117
118 fn create_temp_project() -> TempDir {
119 tempfile::tempdir().unwrap()
120 }
121
122 fn write_skill(dir: &Path, name: &str, content: &str) {
123 let skill_dir = dir.join(name);
124 fs::create_dir_all(&skill_dir).unwrap();
125 fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
126 }
127
128 #[test]
129 fn discover_empty_project() {
130 let dir = create_temp_project();
131 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
132 assert!(catalog.all().is_empty());
133 }
134
135 #[test]
136 fn discover_user_only_prompt() {
137 let dir = create_temp_project();
138 write_skill(
139 dir.path(),
140 "commit",
141 "---\ndescription: Generate commit messages\nuser-invocable: true\n---\nGenerate a commit message.",
142 );
143
144 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
145 assert_eq!(catalog.all().len(), 1);
146
147 let spec = &catalog.all()[0];
148 assert_eq!(spec.name, "commit");
149 assert!(spec.user_invocable);
150 assert!(!spec.agent_invocable);
151 assert!(spec.triggers.is_empty());
152 }
153
154 #[test]
155 fn discover_agent_only_prompt() {
156 let dir = create_temp_project();
157 write_skill(
158 dir.path(),
159 "explain-code",
160 "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
161 );
162
163 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
164 assert_eq!(catalog.all().len(), 1);
165
166 let spec = &catalog.all()[0];
167 assert!(spec.agent_invocable);
168 assert!(!spec.user_invocable);
169 }
170
171 #[test]
172 fn discover_rule_only_prompt() {
173 let dir = create_temp_project();
174 write_skill(
175 dir.path(),
176 "rust-rules",
177 "---\ndescription: Rust conventions\ntriggers:\n read:\n - \"packages/**/*.rs\"\n---\nFollow Rust conventions.",
178 );
179
180 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
181 assert_eq!(catalog.all().len(), 1);
182
183 let spec = &catalog.all()[0];
184 assert!(!spec.user_invocable);
185 assert!(!spec.agent_invocable);
186 assert!(!spec.triggers.is_empty());
187 assert!(spec.triggers.matches_read("packages/foo/bar.rs"));
188 assert!(!spec.triggers.matches_read("other/file.py"));
189 }
190
191 #[test]
192 fn discover_dual_use_prompt() {
193 let dir = create_temp_project();
194 write_skill(
195 dir.path(),
196 "explain",
197 "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
198 );
199
200 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
201 let spec = &catalog.all()[0];
202 assert!(spec.user_invocable);
203 assert!(spec.agent_invocable);
204 assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
205
206 let user: Vec<_> = catalog.slash_commands().collect();
207 assert_eq!(user.len(), 1);
208 let agent: Vec<_> = catalog.skills().collect();
209 assert_eq!(agent.len(), 1);
210 }
211
212 #[test]
213 fn reject_duplicate_names() {
214 let dir = create_temp_project();
215 write_skill(dir.path(), "foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.");
216 write_skill(dir.path(), "bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.");
218
219 let result = PromptCatalog::from_dir(dir.path());
220 assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. })));
221 }
222
223 #[test]
224 fn reject_missing_description() {
225 let dir = create_temp_project();
226 write_skill(dir.path(), "bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
227
228 let catalog = PromptCatalog::from_dir(dir.path());
229 assert!(catalog.unwrap().all().is_empty());
231 }
232
233 #[test]
234 fn reject_no_activation_surface() {
235 let dir = create_temp_project();
236 write_skill(dir.path(), "noop", "---\ndescription: Does nothing\n---\nContent.");
237
238 let catalog = PromptCatalog::from_dir(dir.path());
239 assert!(catalog.unwrap().all().is_empty());
241 }
242
243 #[test]
244 fn name_defaults_to_directory_name() {
245 let dir = create_temp_project();
246 write_skill(dir.path(), "my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent.");
247
248 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
249 assert_eq!(catalog.all()[0].name, "my-skill");
250 }
251
252 #[test]
253 fn name_from_frontmatter_overrides_directory() {
254 let dir = create_temp_project();
255 write_skill(
256 dir.path(),
257 "dir-name",
258 "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
259 );
260
261 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
262 assert_eq!(catalog.all()[0].name, "custom-name");
263 }
264
265 #[test]
266 fn matching_read_rules_finds_matches() {
267 let dir = create_temp_project();
268 write_skill(
269 dir.path(),
270 "rust-rules",
271 "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.",
272 );
273 write_skill(
274 dir.path(),
275 "ts-rules",
276 "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.",
277 );
278 write_skill(dir.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
279
280 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
281 let matches = catalog.matching_rules("src/main.rs");
282 assert_eq!(matches.len(), 1);
283 assert_eq!(matches[0].name, "rust-rules");
284
285 let matches = catalog.matching_rules("src/app.ts");
286 assert_eq!(matches.len(), 1);
287 assert_eq!(matches[0].name, "ts-rules");
288
289 let matches = catalog.matching_rules("README.md");
290 assert!(matches.is_empty());
291 }
292
293 #[test]
294 fn pure_rule_not_in_user_or_agent_invocable() {
295 let dir = create_temp_project();
296 write_skill(
297 dir.path(),
298 "rule",
299 "---\ndescription: A rule\ntriggers:\n read:\n - \"*.rs\"\n---\nRule content.",
300 );
301
302 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
303 assert_eq!(catalog.all().len(), 1);
304 assert_eq!(catalog.slash_commands().count(), 0);
305 assert_eq!(catalog.skills().count(), 0);
306 }
307
308 #[test]
309 fn skips_hidden_directories() {
310 let dir = create_temp_project();
311 write_skill(dir.path(), ".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.");
312 write_skill(dir.path(), "visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
313
314 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
315 assert_eq!(catalog.all().len(), 1);
316 assert_eq!(catalog.all()[0].name, "visible");
317 }
318
319 #[test]
320 fn preserves_tags_and_metadata() {
321 let dir = create_temp_project();
322 write_skill(
323 dir.path(),
324 "tagged",
325 "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n - rust\n - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
326 );
327
328 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
329 let spec = &catalog.all()[0];
330 assert_eq!(spec.tags, vec!["rust", "testing"]);
331 assert!(spec.agent_authored);
332 assert_eq!(spec.helpful, 5);
333 assert_eq!(spec.harmful, 1);
334 }
335
336 #[test]
337 fn from_dirs_last_wins() {
338 let dir_a = create_temp_project();
339 let dir_b = create_temp_project();
340 write_skill(dir_a.path(), "rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
341 write_skill(dir_b.path(), "rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
342
343 let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
344 assert_eq!(catalog.all().len(), 1);
345
346 let spec = &catalog.all()[0];
347 assert_eq!(spec.name, "rust");
348 assert_eq!(spec.description, "Rust B");
349 assert!(spec.body.contains("From dir B."));
350 }
351
352 #[test]
353 fn from_dirs_union() {
354 let dir_a = create_temp_project();
355 let dir_b = create_temp_project();
356 write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
357 write_skill(dir_b.path(), "python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
358
359 let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
360 assert_eq!(catalog.all().len(), 2);
361
362 let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
363 assert!(names.contains(&"rust"));
364 assert!(names.contains(&"python"));
365 }
366
367 #[test]
368 fn from_dirs_skips_missing() {
369 let dir_a = create_temp_project();
370 let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
371 write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
372
373 let catalog = PromptCatalog::from_dirs(&[missing, dir_a.path().to_path_buf()]);
374 assert_eq!(catalog.all().len(), 1);
375 assert_eq!(catalog.all()[0].name, "rust");
376 }
377}