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::HashSet;
12use std::fs::read_dir;
13use std::path::Path;
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    /// Create an empty catalog.
46    pub fn empty() -> Self {
47        Self { specs: Vec::new() }
48    }
49
50    /// All prompt specs in catalog order.
51    pub fn all(&self) -> &[PromptFile] {
52        &self.specs
53    }
54
55    /// Iterate over user-invocable prompts (slash commands).
56    pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
57        self.specs.iter().filter(|s| s.user_invocable)
58    }
59
60    /// Iterate over agent-invocable prompts (skills).
61    pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
62        self.specs.iter().filter(|s| s.agent_invocable)
63    }
64
65    /// Find all prompt specs whose read triggers match the given project-relative path.
66    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        // Second skill with explicit name override to "foo"
186        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        // Should be skipped with a warning (parsed OK but validation fails in parse_skill_file)
199        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        // Should be skipped with a warning
209        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}