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