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