1use crate::error::SettingsError;
10use crate::prompt_file::{PromptFile, SKILL_FILENAME};
11use std::collections::HashSet;
12use std::fs::read_dir;
13use std::path::Path;
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(
32 |e| match PromptFile::parse(&e.path().join(SKILL_FILENAME)) {
33 Ok(spec) => Some(spec),
34 Err(err) => {
35 tracing::warn!("Skipping invalid skill at {}: {err}", e.path().display());
36 None
37 }
38 },
39 )
40 .collect();
41
42 validate_catalog(&prompts)?;
43
44 Ok(Self { specs: prompts })
45 }
46
47 pub fn empty() -> Self {
49 Self { specs: Vec::new() }
50 }
51
52 pub fn all(&self) -> &[PromptFile] {
54 &self.specs
55 }
56
57 pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
59 self.specs.iter().filter(|s| s.user_invocable)
60 }
61
62 pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
64 self.specs.iter().filter(|s| s.agent_invocable)
65 }
66
67 pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
69 self.specs
70 .iter()
71 .filter(|s| s.triggers.matches_read(relative_path))
72 .collect()
73 }
74}
75
76fn validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
77 let mut seen_names = HashSet::new();
78 for spec in specs {
79 if !seen_names.insert(&spec.name) {
80 return Err(SettingsError::DuplicatePromptName {
81 name: spec.name.clone(),
82 });
83 }
84 }
85 Ok(())
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use std::fs;
92 use tempfile::TempDir;
93
94 fn create_temp_project() -> TempDir {
95 tempfile::tempdir().unwrap()
96 }
97
98 fn write_skill(dir: &Path, name: &str, content: &str) {
99 let skill_dir = dir.join(name);
100 fs::create_dir_all(&skill_dir).unwrap();
101 fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
102 }
103
104 #[test]
105 fn discover_empty_project() {
106 let dir = create_temp_project();
107 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
108 assert!(catalog.all().is_empty());
109 }
110
111 #[test]
112 fn discover_user_only_prompt() {
113 let dir = create_temp_project();
114 write_skill(
115 dir.path(),
116 "commit",
117 "---\ndescription: Generate commit messages\nuser-invocable: true\n---\nGenerate a commit message.",
118 );
119
120 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
121 assert_eq!(catalog.all().len(), 1);
122
123 let spec = &catalog.all()[0];
124 assert_eq!(spec.name, "commit");
125 assert!(spec.user_invocable);
126 assert!(!spec.agent_invocable);
127 assert!(spec.triggers.is_empty());
128 }
129
130 #[test]
131 fn discover_agent_only_prompt() {
132 let dir = create_temp_project();
133 write_skill(
134 dir.path(),
135 "explain-code",
136 "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
137 );
138
139 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
140 assert_eq!(catalog.all().len(), 1);
141
142 let spec = &catalog.all()[0];
143 assert!(spec.agent_invocable);
144 assert!(!spec.user_invocable);
145 }
146
147 #[test]
148 fn discover_rule_only_prompt() {
149 let dir = create_temp_project();
150 write_skill(
151 dir.path(),
152 "rust-rules",
153 "---\ndescription: Rust conventions\ntriggers:\n read:\n - \"packages/**/*.rs\"\n---\nFollow Rust conventions.",
154 );
155
156 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
157 assert_eq!(catalog.all().len(), 1);
158
159 let spec = &catalog.all()[0];
160 assert!(!spec.user_invocable);
161 assert!(!spec.agent_invocable);
162 assert!(!spec.triggers.is_empty());
163 assert!(spec.triggers.matches_read("packages/foo/bar.rs"));
164 assert!(!spec.triggers.matches_read("other/file.py"));
165 }
166
167 #[test]
168 fn discover_dual_use_prompt() {
169 let dir = create_temp_project();
170 write_skill(
171 dir.path(),
172 "explain",
173 "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
174 );
175
176 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
177 let spec = &catalog.all()[0];
178 assert!(spec.user_invocable);
179 assert!(spec.agent_invocable);
180 assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
181
182 let user: Vec<_> = catalog.slash_commands().collect();
183 assert_eq!(user.len(), 1);
184 let agent: Vec<_> = catalog.skills().collect();
185 assert_eq!(agent.len(), 1);
186 }
187
188 #[test]
189 fn reject_duplicate_names() {
190 let dir = create_temp_project();
191 write_skill(
192 dir.path(),
193 "foo",
194 "---\ndescription: First\nuser-invocable: true\n---\nContent.",
195 );
196 write_skill(
198 dir.path(),
199 "bar",
200 "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.",
201 );
202
203 let result = PromptCatalog::from_dir(dir.path());
204 assert!(matches!(
205 result,
206 Err(SettingsError::DuplicatePromptName { .. })
207 ));
208 }
209
210 #[test]
211 fn reject_missing_description() {
212 let dir = create_temp_project();
213 write_skill(
214 dir.path(),
215 "bad",
216 "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.",
217 );
218
219 let catalog = PromptCatalog::from_dir(dir.path());
220 assert!(catalog.unwrap().all().is_empty());
222 }
223
224 #[test]
225 fn reject_no_activation_surface() {
226 let dir = create_temp_project();
227 write_skill(
228 dir.path(),
229 "noop",
230 "---\ndescription: Does nothing\n---\nContent.",
231 );
232
233 let catalog = PromptCatalog::from_dir(dir.path());
234 assert!(catalog.unwrap().all().is_empty());
236 }
237
238 #[test]
239 fn name_defaults_to_directory_name() {
240 let dir = create_temp_project();
241 write_skill(
242 dir.path(),
243 "my-skill",
244 "---\ndescription: My skill\nagent-invocable: true\n---\nContent.",
245 );
246
247 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
248 assert_eq!(catalog.all()[0].name, "my-skill");
249 }
250
251 #[test]
252 fn name_from_frontmatter_overrides_directory() {
253 let dir = create_temp_project();
254 write_skill(
255 dir.path(),
256 "dir-name",
257 "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
258 );
259
260 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
261 assert_eq!(catalog.all()[0].name, "custom-name");
262 }
263
264 #[test]
265 fn matching_read_rules_finds_matches() {
266 let dir = create_temp_project();
267 write_skill(
268 dir.path(),
269 "rust-rules",
270 "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.",
271 );
272 write_skill(
273 dir.path(),
274 "ts-rules",
275 "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.",
276 );
277 write_skill(
278 dir.path(),
279 "commit",
280 "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.",
281 );
282
283 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
284 let matches = catalog.matching_rules("src/main.rs");
285 assert_eq!(matches.len(), 1);
286 assert_eq!(matches[0].name, "rust-rules");
287
288 let matches = catalog.matching_rules("src/app.ts");
289 assert_eq!(matches.len(), 1);
290 assert_eq!(matches[0].name, "ts-rules");
291
292 let matches = catalog.matching_rules("README.md");
293 assert!(matches.is_empty());
294 }
295
296 #[test]
297 fn pure_rule_not_in_user_or_agent_invocable() {
298 let dir = create_temp_project();
299 write_skill(
300 dir.path(),
301 "rule",
302 "---\ndescription: A rule\ntriggers:\n read:\n - \"*.rs\"\n---\nRule content.",
303 );
304
305 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
306 assert_eq!(catalog.all().len(), 1);
307 assert_eq!(catalog.slash_commands().count(), 0);
308 assert_eq!(catalog.skills().count(), 0);
309 }
310
311 #[test]
312 fn skips_hidden_directories() {
313 let dir = create_temp_project();
314 write_skill(
315 dir.path(),
316 ".archived",
317 "---\ndescription: Archived\nuser-invocable: true\n---\nOld.",
318 );
319 write_skill(
320 dir.path(),
321 "visible",
322 "---\ndescription: Visible\nuser-invocable: true\n---\nNew.",
323 );
324
325 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
326 assert_eq!(catalog.all().len(), 1);
327 assert_eq!(catalog.all()[0].name, "visible");
328 }
329
330 #[test]
331 fn preserves_tags_and_metadata() {
332 let dir = create_temp_project();
333 write_skill(
334 dir.path(),
335 "tagged",
336 "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n - rust\n - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
337 );
338
339 let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
340 let spec = &catalog.all()[0];
341 assert_eq!(spec.tags, vec!["rust", "testing"]);
342 assert!(spec.agent_authored);
343 assert_eq!(spec.helpful, 5);
344 assert_eq!(spec.harmful, 1);
345 }
346}