1use crate::error::{SkillError, SkillResult};
2use crate::model::{ParsedSkill, SkillFrontmatter};
3use std::path::Path;
4
5const CONVENTION_FILES: &[&str] =
6 &["AGENTS.md", "AGENT.md", "CLAUDE.md", "GEMINI.md", "COPILOT.md", "SKILLS.md", "SOUL.md"];
7
8pub fn parse_skill_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
14 let normalized = content.replace("\r\n", "\n");
15 let mut lines = normalized.lines();
16
17 let first = lines.next().unwrap_or_default().trim();
18 if first != "---" {
19 return Err(SkillError::InvalidFrontmatter {
20 path: path.to_path_buf(),
21 message: "missing opening frontmatter delimiter (`---`)".to_string(),
22 });
23 }
24
25 let mut frontmatter_lines = Vec::new();
26 let mut found_end = false;
27 for line in lines.by_ref() {
28 if line.trim() == "---" {
29 found_end = true;
30 break;
31 }
32 frontmatter_lines.push(line);
33 }
34
35 if !found_end {
36 return Err(SkillError::InvalidFrontmatter {
37 path: path.to_path_buf(),
38 message: "missing closing frontmatter delimiter (`---`)".to_string(),
39 });
40 }
41
42 let frontmatter_raw = frontmatter_lines.join("\n");
43 let fm: SkillFrontmatter = serde_yaml::from_str(&frontmatter_raw)?;
44
45 let name = fm.name.trim().to_string();
46 if name.is_empty() {
47 return Err(SkillError::MissingField { path: path.to_path_buf(), field: "name" });
48 }
49
50 let description = fm.description.trim().to_string();
51 if description.is_empty() {
52 return Err(SkillError::MissingField { path: path.to_path_buf(), field: "description" });
53 }
54
55 let tags = fm
56 .tags
57 .into_iter()
58 .map(|t| t.trim().to_string())
59 .filter(|t| !t.is_empty())
60 .collect::<Vec<_>>();
61
62 let allowed_tools = fm
63 .allowed_tools
64 .into_iter()
65 .map(|t| t.trim().to_string())
66 .filter(|t| !t.is_empty())
67 .collect::<Vec<_>>();
68
69 let references = fm
70 .references
71 .into_iter()
72 .map(|t| t.trim().to_string())
73 .filter(|t| !t.is_empty())
74 .collect::<Vec<_>>();
75
76 let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
77
78 Ok(ParsedSkill {
79 name,
80 description,
81 version: fm.version,
82 license: fm.license,
83 compatibility: fm.compatibility,
84 tags,
85 allowed_tools,
86 references,
87 trigger: fm.trigger.unwrap_or(false),
88 hint: fm.hint,
89 metadata: fm.metadata,
90 body,
91 })
92}
93
94pub fn parse_instruction_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
101 if is_skill_file_path(path) {
102 return parse_skill_markdown(path, content);
103 }
104
105 if is_convention_file(path) {
106 return parse_convention_markdown(path, content);
107 }
108
109 parse_skill_markdown(path, content)
110}
111
112fn parse_convention_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
113 if content.lines().next().is_some_and(|line| line.trim() == "---") {
116 if let Ok(parsed) = parse_skill_markdown(path, content) {
117 return Ok(parsed);
118 }
119 }
120
121 let normalized = content.replace("\r\n", "\n");
122 let body = normalized.trim().to_string();
123
124 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction.md");
125
126 let (name, tags) = match file_name.to_ascii_uppercase().as_str() {
127 "AGENTS.MD" | "AGENT.MD" => {
128 ("agents".to_string(), vec!["convention".to_string(), "agents-md".to_string()])
129 }
130 "CLAUDE.MD" => {
131 ("claude".to_string(), vec!["convention".to_string(), "claude-md".to_string()])
132 }
133 "GEMINI.MD" => {
134 ("gemini".to_string(), vec!["convention".to_string(), "gemini-md".to_string()])
135 }
136 "COPILOT.MD" => {
137 ("copilot".to_string(), vec!["convention".to_string(), "copilot-md".to_string()])
138 }
139 "SKILLS.MD" => {
140 ("skills".to_string(), vec!["convention".to_string(), "skills-md".to_string()])
141 }
142 "SOUL.MD" => ("soul".to_string(), vec!["convention".to_string(), "soul-md".to_string()]),
143 _ => (
144 path.file_stem()
145 .and_then(|stem| stem.to_str())
146 .unwrap_or("instruction")
147 .to_ascii_lowercase(),
148 vec!["convention".to_string()],
149 ),
150 };
151
152 let description = extract_convention_description(&body).unwrap_or_else(|| {
153 format!(
154 "Instructions loaded from {}",
155 path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction file")
156 )
157 });
158
159 Ok(ParsedSkill {
160 name,
161 description,
162 version: None,
163 license: None,
164 compatibility: None,
165 tags,
166 allowed_tools: Vec::new(),
167 references: Vec::new(),
168 trigger: false,
169 hint: None,
170 metadata: std::collections::HashMap::new(),
171 body,
172 })
173}
174
175fn is_skill_file_path(path: &Path) -> bool {
176 path.components()
177 .any(|component| component.as_os_str().to_string_lossy().eq_ignore_ascii_case(".skills"))
178}
179
180fn is_convention_file(path: &Path) -> bool {
181 path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
182 CONVENTION_FILES.iter().any(|candidate| name.eq_ignore_ascii_case(candidate))
183 })
184}
185
186fn extract_convention_description(body: &str) -> Option<String> {
187 for line in body.lines() {
188 let trimmed = line.trim();
189 if trimmed.is_empty() {
190 continue;
191 }
192
193 if trimmed.starts_with('#') {
195 let heading = trimmed.trim_start_matches('#').trim();
196 if !heading.is_empty() {
197 return Some(heading.to_string());
198 }
199 }
200
201 return Some(trimmed.chars().take(120).collect::<String>());
202 }
203
204 None
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn parses_valid_skill() {
213 let content = r#"---
214name: repo_search
215description: Search the codebase quickly
216tags:
217 - code
218 - search
219---
220Use ripgrep first.
221"#;
222 let parsed = parse_skill_markdown(Path::new(".skills/repo_search.md"), content).unwrap();
223 assert_eq!(parsed.name, "repo_search");
224 assert_eq!(parsed.description, "Search the codebase quickly");
225 assert_eq!(parsed.tags, vec!["code", "search"]);
226 assert!(parsed.body.contains("Use ripgrep first."));
227 }
228
229 #[test]
230 fn parses_skill_with_full_spec() {
231 let content = r#"---
232name: full_spec_agent
233description: An agent with everything
234version: "1.2.3"
235license: MIT
236compatibility: "Requires Python 3.10+"
237allowed-tools:
238 - tool1
239references:
240 - ref1
241trigger: true
242hint: "Say something"
243metadata:
244 custom_key: custom_value
245tags: [tag1]
246---
247Body content.
248"#;
249 let parsed = parse_skill_markdown(Path::new(".skills/full.md"), content).unwrap();
250 assert_eq!(parsed.name, "full_spec_agent");
251 assert_eq!(parsed.version, Some("1.2.3".to_string()));
252 assert_eq!(parsed.license, Some("MIT".to_string()));
253 assert_eq!(parsed.compatibility, Some("Requires Python 3.10+".to_string()));
254 assert_eq!(parsed.allowed_tools, vec!["tool1"]);
255 assert_eq!(parsed.references, vec!["ref1"]);
256 assert!(parsed.trigger);
257 assert_eq!(parsed.hint, Some("Say something".to_string()));
258 assert_eq!(
259 parsed.metadata.get("custom_key").and_then(|v| v.as_str()),
260 Some("custom_value")
261 );
262 }
263
264 #[test]
265 fn rejects_missing_required_fields() {
266 let content = r#"---
267name: ""
268description: ""
269---
270body
271"#;
272 let err = parse_skill_markdown(Path::new(".skills/bad.md"), content).unwrap_err();
273 assert!(matches!(err, SkillError::MissingField { .. }));
274 }
275
276 #[test]
277 fn parses_agents_md_without_frontmatter() {
278 let content = "# Project Agent Instructions\nAlways prefer rg over grep.\n";
279 let parsed = parse_instruction_markdown(Path::new("AGENTS.md"), content).unwrap();
280
281 assert_eq!(parsed.name, "agents");
282 assert_eq!(parsed.description, "Project Agent Instructions");
283 assert!(parsed.tags.contains(&"convention".to_string()));
284 assert!(parsed.tags.contains(&"agents-md".to_string()));
285 assert!(parsed.body.contains("Always prefer rg"));
286 }
287
288 #[test]
289 fn parses_soul_md_without_frontmatter() {
290 let content = "# Soul Profile\nPrioritize deliberate planning before execution.\n";
291 let parsed = parse_instruction_markdown(Path::new("SOUL.MD"), content).unwrap();
292
293 assert_eq!(parsed.name, "soul");
294 assert_eq!(parsed.description, "Soul Profile");
295 assert!(parsed.tags.contains(&"convention".to_string()));
296 assert!(parsed.tags.contains(&"soul-md".to_string()));
297 assert!(parsed.body.contains("deliberate planning"));
298 }
299
300 #[test]
301 fn keeps_strict_frontmatter_for_skills_directory() {
302 let content = "# Missing frontmatter";
303 let err = parse_instruction_markdown(Path::new(".skills/missing.md"), content).unwrap_err();
304 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
305 }
306}