Skip to main content

rustant_core/skills/
parser.rs

1//! Skill parser — reads SKILL.md files with YAML frontmatter.
2//!
3//! A SKILL.md file has the format:
4//! ```text
5//! ---
6//! name: my-skill
7//! version: 1.0.0
8//! description: A skill that does X
9//! requires:
10//!   - type: tool
11//!     name: shell_exec
12//!   - type: secret
13//!     name: API_KEY
14//! ---
15//!
16//! ## Tools
17//!
18//! ### tool_name
19//!
20//! Description of the tool.
21//!
22//! **Parameters:**
23//! ```json
24//! {"type": "object", "properties": {"input": {"type": "string"}}}
25//! ```
26//!
27//! **Body:**
28//! ```text
29//! Execute: shell_exec with input
30//! ```
31
32use super::types::{SkillDefinition, SkillRequirement, SkillToolDef};
33
34/// Error when parsing a SKILL.md file.
35#[derive(Debug, thiserror::Error)]
36pub enum ParseError {
37    #[error("No YAML frontmatter found (expected --- delimiters)")]
38    NoFrontmatter,
39    #[error("Invalid YAML frontmatter: {0}")]
40    InvalidYaml(String),
41    #[error("Missing required field: {0}")]
42    MissingField(String),
43}
44
45/// Parsed YAML frontmatter from a SKILL.md file.
46#[derive(Debug, serde::Deserialize)]
47struct SkillFrontmatter {
48    name: Option<String>,
49    version: Option<String>,
50    description: Option<String>,
51    author: Option<String>,
52    #[serde(default)]
53    requires: Vec<RequirementYaml>,
54}
55
56#[derive(Debug, serde::Deserialize)]
57struct RequirementYaml {
58    #[serde(rename = "type")]
59    req_type: String,
60    name: String,
61}
62
63/// Parse a SKILL.md file content into a SkillDefinition.
64pub fn parse_skill_md(content: &str) -> Result<SkillDefinition, ParseError> {
65    let (frontmatter_str, body) = extract_frontmatter(content)?;
66
67    let fm: SkillFrontmatter = serde_yaml::from_str(&frontmatter_str)
68        .map_err(|e| ParseError::InvalidYaml(e.to_string()))?;
69
70    let name = fm.name.ok_or(ParseError::MissingField("name".into()))?;
71    let version = fm.version.unwrap_or_else(|| "0.1.0".into());
72    let description = fm.description.unwrap_or_else(|| "No description".into());
73
74    let requires: Vec<SkillRequirement> = fm
75        .requires
76        .into_iter()
77        .map(|r| SkillRequirement {
78            req_type: r.req_type,
79            name: r.name,
80        })
81        .collect();
82
83    let tools = parse_tools_section(&body);
84
85    Ok(SkillDefinition {
86        name,
87        version,
88        description,
89        author: fm.author,
90        requires,
91        tools,
92        config: Default::default(),
93        risk_level: super::types::SkillRiskLevel::Low,
94        source_path: None,
95    })
96}
97
98/// Extract YAML frontmatter from content between --- delimiters.
99fn extract_frontmatter(content: &str) -> Result<(String, String), ParseError> {
100    let trimmed = content.trim_start();
101    if !trimmed.starts_with("---") {
102        return Err(ParseError::NoFrontmatter);
103    }
104
105    let after_first = &trimmed[3..];
106    let end_pos = after_first.find("\n---").ok_or(ParseError::NoFrontmatter)?;
107
108    let frontmatter = after_first[..end_pos].trim().to_string();
109    let body = after_first[end_pos + 4..].to_string();
110
111    Ok((frontmatter, body))
112}
113
114/// Parse the ## Tools section from the markdown body.
115fn parse_tools_section(body: &str) -> Vec<SkillToolDef> {
116    let mut tools = Vec::new();
117    let mut current_tool: Option<(String, String)> = None;
118    let mut in_params_block = false;
119    let mut in_body_block = false;
120    let mut params_json = String::new();
121    let mut body_text = String::new();
122
123    for line in body.lines() {
124        // Detect ### tool_name headers
125        if let Some(stripped) = line.strip_prefix("### ") {
126            // Save previous tool if any
127            if let Some((name, description)) = current_tool.take() {
128                let params = if params_json.is_empty() {
129                    serde_json::json!({"type": "object"})
130                } else {
131                    serde_json::from_str(&params_json)
132                        .unwrap_or(serde_json::json!({"type": "object"}))
133                };
134                tools.push(SkillToolDef {
135                    name,
136                    description,
137                    parameters: params,
138                    body: body_text.trim().to_string(),
139                });
140                params_json.clear();
141                body_text.clear();
142            }
143            let tool_name = stripped.trim().to_string();
144            current_tool = Some((tool_name, String::new()));
145            continue;
146        }
147
148        // If we're inside a tool definition, collect description, params, body
149        if let Some((_, ref mut description)) = current_tool {
150            if line.starts_with("**Parameters:**") {
151                continue;
152            }
153            if line.starts_with("**Body:**") {
154                continue;
155            }
156            if line.starts_with("```json") {
157                in_params_block = true;
158                continue;
159            }
160            if line.starts_with("```") && !line.starts_with("```json") {
161                if in_params_block {
162                    in_params_block = false;
163                    continue;
164                }
165                if in_body_block {
166                    in_body_block = false;
167                    continue;
168                }
169                // Start body block
170                in_body_block = true;
171                continue;
172            }
173            if in_params_block {
174                params_json.push_str(line);
175                params_json.push('\n');
176                continue;
177            }
178            if in_body_block {
179                body_text.push_str(line);
180                body_text.push('\n');
181                continue;
182            }
183            // Regular line — add to description if description is empty
184            let trimmed = line.trim();
185            if !trimmed.is_empty() && description.is_empty() {
186                *description = trimmed.to_string();
187            }
188        }
189    }
190
191    // Save last tool
192    if let Some((name, description)) = current_tool.take() {
193        let params = if params_json.is_empty() {
194            serde_json::json!({"type": "object"})
195        } else {
196            serde_json::from_str(&params_json).unwrap_or(serde_json::json!({"type": "object"}))
197        };
198        tools.push(SkillToolDef {
199            name,
200            description,
201            parameters: params,
202            body: body_text.trim().to_string(),
203        });
204    }
205
206    tools
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    const VALID_SKILL: &str = r#"---
214name: github
215version: 1.0.0
216description: GitHub integration skill
217author: Test Author
218requires:
219  - type: tool
220    name: shell_exec
221  - type: secret
222    name: GITHUB_TOKEN
223---
224
225## Tools
226
227### github_pr_list
228
229List open pull requests for a repository.
230
231**Parameters:**
232```json
233{"type": "object", "properties": {"repo": {"type": "string"}}, "required": ["repo"]}
234```
235
236**Body:**
237```
238shell_exec: gh pr list --repo {{repo}}
239```
240
241### github_issue_create
242
243Create a new GitHub issue.
244
245**Parameters:**
246```json
247{"type": "object", "properties": {"repo": {"type": "string"}, "title": {"type": "string"}}}
248```
249
250**Body:**
251```
252shell_exec: gh issue create --repo {{repo}} --title "{{title}}"
253```
254"#;
255
256    #[test]
257    fn test_parse_valid_skill() {
258        let skill = parse_skill_md(VALID_SKILL).unwrap();
259        assert_eq!(skill.name, "github");
260        assert_eq!(skill.version, "1.0.0");
261        assert_eq!(skill.description, "GitHub integration skill");
262        assert_eq!(skill.author, Some("Test Author".into()));
263        assert_eq!(skill.requires.len(), 2);
264        assert_eq!(skill.requires[0].req_type, "tool");
265        assert_eq!(skill.requires[0].name, "shell_exec");
266        assert_eq!(skill.requires[1].req_type, "secret");
267        assert_eq!(skill.requires[1].name, "GITHUB_TOKEN");
268        assert_eq!(skill.tools.len(), 2);
269        assert_eq!(skill.tools[0].name, "github_pr_list");
270        assert_eq!(skill.tools[1].name, "github_issue_create");
271        assert!(skill.tools[0].body.contains("gh pr list"));
272    }
273
274    #[test]
275    fn test_parse_missing_name() {
276        let content = "---\nversion: 1.0.0\n---\n";
277        let result = parse_skill_md(content);
278        assert!(matches!(result, Err(ParseError::MissingField(_))));
279    }
280
281    #[test]
282    fn test_parse_no_frontmatter() {
283        let content = "# Just markdown\nNo frontmatter here.";
284        let result = parse_skill_md(content);
285        assert!(matches!(result, Err(ParseError::NoFrontmatter)));
286    }
287
288    #[test]
289    fn test_parse_no_tools_section() {
290        let content = "---\nname: empty-skill\n---\n\nJust some text.";
291        let skill = parse_skill_md(content).unwrap();
292        assert_eq!(skill.name, "empty-skill");
293        assert!(skill.tools.is_empty());
294    }
295
296    #[test]
297    fn test_parse_minimal_skill() {
298        let content = "---\nname: minimal\n---\n";
299        let skill = parse_skill_md(content).unwrap();
300        assert_eq!(skill.name, "minimal");
301        assert_eq!(skill.version, "0.1.0");
302        assert_eq!(skill.description, "No description");
303    }
304
305    #[test]
306    fn test_extract_frontmatter() {
307        let (fm, body) = extract_frontmatter("---\nname: test\n---\nbody here").unwrap();
308        assert_eq!(fm, "name: test");
309        assert!(body.contains("body here"));
310    }
311
312    #[test]
313    fn test_tool_parameters_parsed() {
314        let skill = parse_skill_md(VALID_SKILL).unwrap();
315        let tool = &skill.tools[0];
316        assert!(tool.parameters.is_object());
317        assert!(tool.parameters["properties"]["repo"].is_object());
318    }
319}