Skip to main content

aether_project/
prompt_catalog.rs

1//! Unified prompt catalog for discovering and validating `.aether/skills/*/SKILL.md` artifacts.
2//!
3//! A single `SKILL.md` artifact can serve as:
4//! - A **slash command** (`user-invocable: true`)
5//! - A **skill** (`agent-invocable: true`)
6//! - A **rule** (`triggers.read` globs)
7//! - Any combination of the above
8
9use 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/// A catalog of prompt artifacts discovered from `.aether/skills/`.
16#[derive(Debug, Clone)]
17pub struct PromptCatalog {
18    specs: Vec<PromptFile>,
19}
20
21impl PromptCatalog {
22    /// Discover and validate all prompt artifacts under `skills_dir/*/SKILL.md`.
23    ///
24    /// `skills_dir` is typically `<project_root>/.aether/skills` or `<base_dir>/skills`.
25    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    /// Discover and merge prompt artifacts from multiple skill directories.
46    ///
47    /// On name collision, the last directory wins. Directories that don't exist are skipped.
48    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    /// Create an empty catalog.
77    pub fn empty() -> Self {
78        Self { specs: Vec::new() }
79    }
80
81    /// All prompt specs in catalog order.
82    pub fn all(&self) -> &[PromptFile] {
83        &self.specs
84    }
85
86    /// Iterate over user-invocable prompts (slash commands).
87    pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
88        self.specs.iter().filter(|s| s.user_invocable)
89    }
90
91    /// Iterate over agent-invocable prompts (skills).
92    pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
93        self.specs.iter().filter(|s| s.agent_invocable)
94    }
95
96    /// Find all prompt specs whose read triggers match the given project-relative path.
97    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        // Second skill with explicit name override to "foo"
217        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        // Should be skipped with a warning (parsed OK but validation fails in parse_skill_file)
230        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        // Should be skipped with a warning
240        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}