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