Skip to main content

ralph_core/
skill.rs

1//! Skill data types and frontmatter parser.
2//!
3//! Skills are markdown documents with YAML frontmatter that provide knowledge
4//! and tool instructions to agents during orchestration loops.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// A discovered skill with parsed frontmatter and content.
10#[derive(Debug, Clone)]
11pub struct SkillEntry {
12    /// Unique identifier (derived from filename or frontmatter `name`).
13    pub name: String,
14    /// Human-readable description from frontmatter.
15    pub description: String,
16    /// Full markdown content (frontmatter stripped).
17    pub content: String,
18    /// Source: built-in or filesystem path.
19    pub source: SkillSource,
20    /// Optional: restrict to specific hats.
21    pub hats: Vec<String>,
22    /// Optional: restrict to specific backends.
23    pub backends: Vec<String>,
24    /// Optional: tags for categorization.
25    pub tags: Vec<String>,
26    /// Whether to inject full content into every prompt (not just index entry).
27    pub auto_inject: bool,
28}
29
30/// Where a skill was loaded from.
31#[derive(Debug, Clone)]
32pub enum SkillSource {
33    /// Compiled into the binary via include_str!
34    BuiltIn,
35    /// Loaded from a filesystem path.
36    File(PathBuf),
37}
38
39/// Parsed YAML frontmatter from a skill file.
40#[derive(Debug, Clone, Default, Deserialize)]
41pub struct SkillFrontmatter {
42    pub name: Option<String>,
43    pub description: Option<String>,
44    #[serde(default)]
45    pub hats: Vec<String>,
46    #[serde(default)]
47    pub backends: Vec<String>,
48    #[serde(default)]
49    pub tags: Vec<String>,
50}
51
52/// Parse YAML frontmatter from a markdown document.
53///
54/// Returns the parsed frontmatter (if valid) and the body content
55/// with frontmatter delimiters stripped.
56///
57/// Frontmatter format:
58/// ```text
59/// ---
60/// name: my-skill
61/// description: A useful skill
62/// ---
63/// Body content here...
64/// ```
65pub fn parse_frontmatter(raw: &str) -> (Option<SkillFrontmatter>, String) {
66    let trimmed = raw.trim_start();
67
68    // Must start with `---`
69    if !trimmed.starts_with("---") {
70        return (None, raw.to_string());
71    }
72
73    // Find the closing `---` (skip the opening one)
74    let after_open = &trimmed[3..];
75    let closing_pos = after_open.find("\n---");
76
77    match closing_pos {
78        Some(pos) => {
79            let yaml_str = &after_open[..pos];
80            let body_start = pos + 4; // skip \n---
81            let body = after_open[body_start..].trim_start_matches('\n');
82
83            match serde_yaml::from_str::<SkillFrontmatter>(yaml_str) {
84                Ok(fm) => (Some(fm), body.to_string()),
85                Err(_) => {
86                    // Invalid YAML — return None frontmatter but still strip the block
87                    (None, body.to_string())
88                }
89            }
90        }
91        None => {
92            // No closing delimiter — treat entire content as body, no frontmatter
93            (None, raw.to_string())
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_parse_valid_frontmatter_all_fields() {
104        let raw = r"---
105name: my-skill
106description: A useful skill
107hats: [builder, reviewer]
108backends: [claude, gemini]
109tags: [testing, tdd]
110---
111
112# My Skill
113
114Body content here.
115";
116        let (fm, body) = parse_frontmatter(raw);
117        let fm = fm.expect("should parse frontmatter");
118        assert_eq!(fm.name.as_deref(), Some("my-skill"));
119        assert_eq!(fm.description.as_deref(), Some("A useful skill"));
120        assert_eq!(fm.hats, vec!["builder", "reviewer"]);
121        assert_eq!(fm.backends, vec!["claude", "gemini"]);
122        assert_eq!(fm.tags, vec!["testing", "tdd"]);
123        assert!(body.contains("# My Skill"));
124        assert!(body.contains("Body content here."));
125        // Frontmatter delimiters should be stripped
126        assert!(!body.contains("---"));
127    }
128
129    #[test]
130    fn test_parse_frontmatter_name_and_description_only() {
131        let raw = r"---
132name: memories
133description: Persistent learning across sessions
134---
135
136# Memories
137
138Content.
139";
140        let (fm, body) = parse_frontmatter(raw);
141        let fm = fm.expect("should parse frontmatter");
142        assert_eq!(fm.name.as_deref(), Some("memories"));
143        assert_eq!(
144            fm.description.as_deref(),
145            Some("Persistent learning across sessions")
146        );
147        assert!(fm.hats.is_empty());
148        assert!(fm.backends.is_empty());
149        assert!(fm.tags.is_empty());
150        assert!(body.starts_with("# Memories"));
151    }
152
153    #[test]
154    fn test_parse_no_frontmatter() {
155        let raw = "# Just Markdown\n\nNo frontmatter here.\n";
156        let (fm, body) = parse_frontmatter(raw);
157        assert!(fm.is_none());
158        assert_eq!(body, raw);
159    }
160
161    #[test]
162    fn test_parse_invalid_yaml_frontmatter() {
163        let raw = r"---
164this: is: not: valid: yaml: [[[
165---
166
167Body content.
168";
169        let (fm, body) = parse_frontmatter(raw);
170        assert!(fm.is_none());
171        assert!(body.contains("Body content."));
172    }
173
174    #[test]
175    fn test_parse_no_closing_delimiter() {
176        let raw = "---\nname: broken\nNo closing delimiter\n";
177        let (fm, body) = parse_frontmatter(raw);
178        assert!(fm.is_none());
179        assert_eq!(body, raw);
180    }
181
182    #[test]
183    fn test_content_body_strips_frontmatter_delimiters() {
184        let raw = "---\nname: test\n---\nFirst line of body.\nSecond line.\n";
185        let (fm, body) = parse_frontmatter(raw);
186        assert!(fm.is_some());
187        assert!(body.starts_with("First line of body."));
188        assert!(body.contains("Second line."));
189        assert!(!body.contains("---"));
190        assert!(!body.contains("name: test"));
191    }
192
193    #[test]
194    fn test_empty_frontmatter() {
195        let raw = "---\n---\nBody only.\n";
196        let (fm, body) = parse_frontmatter(raw);
197        // Empty YAML is valid and parses to defaults
198        let fm = fm.expect("empty frontmatter should parse");
199        assert!(fm.name.is_none());
200        assert!(fm.description.is_none());
201        assert!(body.contains("Body only."));
202    }
203}