rustant_core/skills/
parser.rs1use super::types::{SkillDefinition, SkillRequirement, SkillToolDef};
33
34#[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#[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
63pub 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
98fn 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
114fn 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 if let Some(stripped) = line.strip_prefix("### ") {
126 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(¶ms_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 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 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 let trimmed = line.trim();
185 if !trimmed.is_empty() && description.is_empty() {
186 *description = trimmed.to_string();
187 }
188 }
189 }
190
191 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(¶ms_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}