Skip to main content

aether_project/
prompt_file.rs

1use std::fmt::{Display, Formatter};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use serde::{Deserialize, Serialize};
7
8pub const SKILL_FILENAME: &str = "SKILL.md";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub(crate) struct PromptFrontmatter {
12    pub description: String,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub name: Option<String>,
15    #[serde(default, rename = "user-invocable", skip_serializing_if = "not")]
16    pub user_invocable: bool,
17    #[serde(default, rename = "agent-invocable", skip_serializing_if = "not")]
18    pub agent_invocable: bool,
19    #[serde(default, rename = "argument-hint", skip_serializing_if = "Option::is_none")]
20    pub argument_hint: Option<String>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub tags: Vec<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub triggers: Option<Triggers>,
25    #[serde(default, skip_serializing_if = "not")]
26    pub agent_authored: bool,
27    #[serde(default, skip_serializing_if = "zero")]
28    pub helpful: u32,
29    #[serde(default, skip_serializing_if = "zero")]
30    pub harmful: u32,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34pub struct Triggers {
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub read: Vec<String>,
37}
38
39/// A resolved skill artifact discovered from a `SKILL.md` file.
40#[derive(Debug, Clone)]
41pub struct PromptFile {
42    pub name: String,
43    pub description: String,
44    pub body: String,
45    pub path: PathBuf,
46    pub user_invocable: bool,
47    pub agent_invocable: bool,
48    pub argument_hint: Option<String>,
49    pub tags: Vec<String>,
50    pub triggers: PromptTriggers,
51    pub agent_authored: bool,
52    pub helpful: u32,
53    pub harmful: u32,
54}
55
56impl PromptFile {
57    /// Parse a prompt file at the given path into a `PromptFile`.
58    ///
59    /// The name defaults to the parent directory name unless overridden in frontmatter.
60    pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
61        let raw = fs::read_to_string(path)?;
62
63        let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
64
65        let dir_name =
66            path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
67
68        let name = frontmatter.name.unwrap_or(dir_name);
69        let description = frontmatter.description.trim().to_string();
70
71        if description.is_empty() {
72            return Err(PromptFileError::MissingDescription { name });
73        }
74
75        let has_read_triggers = frontmatter.triggers.as_ref().is_some_and(|t| !t.read.is_empty());
76
77        if !frontmatter.user_invocable && !frontmatter.agent_invocable && !has_read_triggers {
78            return Err(PromptFileError::NoActivationSurface { name });
79        }
80
81        let read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
82        let triggers = PromptTriggers::new(read_globs)?;
83
84        Ok(Self {
85            name,
86            description,
87            body,
88            path: path.to_path_buf(),
89            user_invocable: frontmatter.user_invocable,
90            agent_invocable: frontmatter.agent_invocable,
91            argument_hint: frontmatter.argument_hint,
92            tags: frontmatter.tags,
93            triggers,
94            agent_authored: frontmatter.agent_authored,
95            helpful: frontmatter.helpful,
96            harmful: frontmatter.harmful,
97        })
98    }
99
100    /// Validate this prompt file has a non-empty description and at least one activation surface.
101    pub fn validate(&self) -> Result<(), PromptFileError> {
102        if self.description.trim().is_empty() {
103            return Err(PromptFileError::MissingDescription { name: self.name.clone() });
104        }
105
106        let has_read_triggers = !self.triggers.is_empty();
107        if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
108            return Err(PromptFileError::NoActivationSurface { name: self.name.clone() });
109        }
110
111        Ok(())
112    }
113
114    /// Write this prompt file to the given path, creating parent directories as needed.
115    pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
116        self.validate()?;
117
118        if let Some(parent) = path.parent() {
119            fs::create_dir_all(parent)?;
120        }
121
122        let triggers =
123            if self.triggers.is_empty() { None } else { Some(Triggers { read: self.triggers.patterns().to_vec() }) };
124
125        let frontmatter = PromptFrontmatter {
126            description: self.description.clone(),
127            name: Some(self.name.clone()),
128            user_invocable: self.user_invocable,
129            agent_invocable: self.agent_invocable,
130            argument_hint: self.argument_hint.clone(),
131            tags: self.tags.clone(),
132            triggers,
133            agent_authored: self.agent_authored,
134            helpful: self.helpful,
135            harmful: self.harmful,
136        };
137
138        let yaml = serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
139
140        let file_content =
141            if self.body.is_empty() { format!("---\n{yaml}---\n") } else { format!("---\n{yaml}---\n{}\n", self.body) };
142        fs::write(path, file_content)?;
143        Ok(())
144    }
145
146    /// Confidence score based on helpful/harmful ratings.
147    pub fn confidence(&self) -> f64 {
148        f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
149    }
150
151    /// Parse YAML frontmatter and body from a SKILL.md content string (no I/O).
152    fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
153        let (yaml_str, body) =
154            utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
155
156        let frontmatter: PromptFrontmatter =
157            serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
158
159        Ok((frontmatter, body.to_string()))
160    }
161}
162
163/// Trigger configuration for automatic prompt activation.
164#[derive(Debug, Clone, Default)]
165pub struct PromptTriggers {
166    patterns: Vec<String>,
167    globs: Option<GlobSet>,
168}
169
170impl PromptTriggers {
171    fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
172        if glob_patterns.is_empty() {
173            return Ok(Self { patterns: Vec::new(), globs: None });
174        }
175
176        let mut builder = GlobSetBuilder::new();
177        for pattern in &glob_patterns {
178            let glob = Glob::new(pattern)
179                .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
180            builder.add(glob);
181        }
182
183        let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
184            pattern: glob_patterns.join(", "),
185            error: e.to_string(),
186        })?;
187
188        Ok(Self { patterns: glob_patterns, globs: Some(globs) })
189    }
190
191    pub fn patterns(&self) -> &[String] {
192        &self.patterns
193    }
194
195    pub fn is_empty(&self) -> bool {
196        self.globs.is_none()
197    }
198
199    /// Check if a project-relative path matches any read trigger glob.
200    pub fn matches_read(&self, relative_path: &str) -> bool {
201        self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
202    }
203}
204
205#[derive(Debug)]
206pub enum PromptFileError {
207    Io(std::io::Error),
208    Yaml(String),
209    MissingFrontmatter,
210    MissingDescription { name: String },
211    NoActivationSurface { name: String },
212    InvalidTriggerGlob { pattern: String, error: String },
213    NotFound(String),
214    NotAgentAuthored(String),
215}
216
217impl Display for PromptFileError {
218    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
219        match self {
220            PromptFileError::Io(e) => write!(f, "IO error: {e}"),
221            PromptFileError::Yaml(e) => write!(f, "YAML error: {e}"),
222            PromptFileError::MissingFrontmatter => write!(f, "missing YAML frontmatter"),
223            PromptFileError::MissingDescription { name } => {
224                write!(f, "skill '{name}' has an empty description")
225            }
226            PromptFileError::NoActivationSurface { name } => {
227                write!(f, "skill '{name}' must have at least one of: user-invocable, agent-invocable, or triggers.read")
228            }
229            PromptFileError::InvalidTriggerGlob { pattern, error } => {
230                write!(f, "invalid trigger glob '{pattern}': {error}")
231            }
232            PromptFileError::NotFound(name) => write!(f, "skill not found: {name}"),
233            PromptFileError::NotAgentAuthored(name) => {
234                write!(f, "skill '{name}' is not agent-authored and cannot be modified")
235            }
236        }
237    }
238}
239
240impl std::error::Error for PromptFileError {
241    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
242        match self {
243            PromptFileError::Io(e) => Some(e),
244            _ => None,
245        }
246    }
247}
248
249impl From<std::io::Error> for PromptFileError {
250    fn from(e: std::io::Error) -> Self {
251        PromptFileError::Io(e)
252    }
253}
254
255#[expect(clippy::trivially_copy_pass_by_ref)]
256fn not(b: &bool) -> bool {
257    !b
258}
259
260#[expect(clippy::trivially_copy_pass_by_ref)]
261fn zero(n: &u32) -> bool {
262    *n == 0
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use tempfile::TempDir;
269
270    fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
271        PromptFrontmatter {
272            description: description.to_string(),
273            name: None,
274            user_invocable: false,
275            agent_invocable: false,
276            argument_hint: None,
277            tags: vec![],
278            triggers: None,
279            agent_authored: false,
280            helpful: 0,
281            harmful: 0,
282        }
283    }
284
285    #[test]
286    fn frontmatter_serde_roundtrip() {
287        let fm = minimal_frontmatter("A simple skill");
288
289        let yaml = serde_yml::to_string(&fm).unwrap();
290        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
291        assert_eq!(parsed.description, "A simple skill");
292        assert!(parsed.tags.is_empty());
293        assert!(!parsed.agent_authored);
294    }
295
296    #[test]
297    fn frontmatter_serde_with_all_fields() {
298        let mut fm = minimal_frontmatter("A full skill");
299        fm.tags = vec!["convention".to_string(), "testing".to_string()];
300        fm.agent_authored = true;
301        fm.helpful = 5;
302        fm.harmful = 2;
303
304        let yaml = serde_yml::to_string(&fm).unwrap();
305        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
306        assert_eq!(parsed.description, "A full skill");
307        assert_eq!(parsed.tags, vec!["convention", "testing"]);
308        assert!(parsed.agent_authored);
309        assert_eq!(parsed.helpful, 5);
310        assert_eq!(parsed.harmful, 2);
311    }
312
313    #[test]
314    fn backward_compat_old_frontmatter() {
315        let yaml = "description: An old skill\n";
316        let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
317        assert_eq!(parsed.description, "An old skill");
318        assert!(parsed.tags.is_empty());
319        assert!(!parsed.agent_authored);
320        assert_eq!(parsed.helpful, 0);
321        assert_eq!(parsed.harmful, 0);
322    }
323
324    #[test]
325    fn confidence() {
326        let pf = |helpful, harmful| PromptFile {
327            name: String::new(),
328            description: "test".to_string(),
329            body: String::new(),
330            path: PathBuf::new(),
331            user_invocable: false,
332            agent_invocable: false,
333            argument_hint: None,
334            tags: vec![],
335            triggers: PromptTriggers::default(),
336            agent_authored: true,
337            helpful,
338            harmful,
339        };
340
341        assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
342        assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
343        assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
344        assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
345    }
346
347    #[test]
348    fn parse_frontmatter_from_string() {
349        let content = "---\ndescription: Test skill\ntags:\n  - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
350        let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
351        assert_eq!(fm.description, "Test skill");
352        assert_eq!(fm.tags, vec!["rust"]);
353        assert!(fm.agent_authored);
354        assert_eq!(fm.helpful, 3);
355        assert_eq!(fm.harmful, 1);
356        assert!(body.contains("# My Skill"));
357        assert!(body.contains("Some content here."));
358    }
359
360    #[test]
361    fn write_and_parse_roundtrip() {
362        let temp_dir = TempDir::new().unwrap();
363        let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
364
365        let prompt = PromptFile {
366            name: "my-skill".to_string(),
367            description: "Test skill".to_string(),
368            body: "# My Skill\n\nSome content here.".to_string(),
369            path: skill_path.clone(),
370            user_invocable: false,
371            agent_invocable: true,
372            argument_hint: None,
373            tags: vec!["convention".to_string()],
374            triggers: PromptTriggers::default(),
375            agent_authored: true,
376            helpful: 2,
377            harmful: 1,
378        };
379        prompt.write(&skill_path).unwrap();
380
381        let parsed = PromptFile::parse(&skill_path).unwrap();
382        assert_eq!(parsed.description, "Test skill");
383        assert_eq!(parsed.tags, vec!["convention"]);
384        assert!(parsed.agent_authored);
385        assert_eq!(parsed.helpful, 2);
386        assert_eq!(parsed.harmful, 1);
387        assert!(parsed.body.contains("# My Skill"));
388        assert!(parsed.body.contains("Some content here."));
389    }
390
391    #[test]
392    fn write_empty_body() {
393        let temp_dir = TempDir::new().unwrap();
394        let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
395
396        let prompt = PromptFile {
397            name: "empty-body".to_string(),
398            description: "Empty".to_string(),
399            body: String::new(),
400            path: skill_path.clone(),
401            user_invocable: false,
402            agent_invocable: true,
403            argument_hint: None,
404            tags: vec![],
405            triggers: PromptTriggers::default(),
406            agent_authored: true,
407            helpful: 0,
408            harmful: 0,
409        };
410        prompt.write(&skill_path).unwrap();
411
412        let raw = std::fs::read_to_string(&skill_path).unwrap();
413        assert!(raw.starts_with("---\n"));
414        assert!(raw.contains("description: Empty"));
415    }
416
417    #[test]
418    fn write_and_parse_roundtrip_with_triggers() {
419        let temp_dir = TempDir::new().unwrap();
420        let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
421
422        let triggers = PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()]).unwrap();
423
424        let prompt = PromptFile {
425            name: "rust-rules".to_string(),
426            description: "Rust conventions".to_string(),
427            body: "Follow Rust conventions.".to_string(),
428            path: skill_path.clone(),
429            user_invocable: false,
430            agent_invocable: false,
431            argument_hint: None,
432            tags: vec![],
433            triggers,
434            agent_authored: false,
435            helpful: 0,
436            harmful: 0,
437        };
438        prompt.write(&skill_path).unwrap();
439
440        let parsed = PromptFile::parse(&skill_path).unwrap();
441        assert_eq!(parsed.description, "Rust conventions");
442        assert!(!parsed.triggers.is_empty());
443        assert!(parsed.triggers.matches_read("src/main.rs"));
444        assert!(parsed.triggers.matches_read("tests/integration.rs"));
445        assert!(!parsed.triggers.matches_read("README.md"));
446        assert_eq!(parsed.triggers.patterns(), &["src/**/*.rs", "tests/**/*.rs"]);
447    }
448
449    #[test]
450    fn write_rejects_empty_description() {
451        let temp_dir = TempDir::new().unwrap();
452        let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
453
454        let prompt = PromptFile {
455            name: "bad".to_string(),
456            description: String::new(),
457            body: "content".to_string(),
458            path: skill_path.clone(),
459            user_invocable: true,
460            agent_invocable: false,
461            argument_hint: None,
462            tags: vec![],
463            triggers: PromptTriggers::default(),
464            agent_authored: true,
465            helpful: 0,
466            harmful: 0,
467        };
468        let result = prompt.write(&skill_path);
469        assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
470    }
471
472    #[test]
473    fn write_rejects_no_activation_surface() {
474        let temp_dir = TempDir::new().unwrap();
475        let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
476
477        let prompt = PromptFile {
478            name: "noop".to_string(),
479            description: "Does nothing".to_string(),
480            body: "content".to_string(),
481            path: skill_path.clone(),
482            user_invocable: false,
483            agent_invocable: false,
484            argument_hint: None,
485            tags: vec![],
486            triggers: PromptTriggers::default(),
487            agent_authored: true,
488            helpful: 0,
489            harmful: 0,
490        };
491        let result = prompt.write(&skill_path);
492        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
493    }
494
495    #[test]
496    fn skip_serializing_defaults() {
497        let fm = minimal_frontmatter("Minimal");
498
499        let yaml = serde_yml::to_string(&fm).unwrap();
500        assert!(!yaml.contains("tags"));
501        assert!(!yaml.contains("agent_authored"));
502        assert!(!yaml.contains("helpful"));
503        assert!(!yaml.contains("harmful"));
504    }
505}