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 triggers: fm.triggers,
91 body,
92 })
93}
94
95pub fn parse_instruction_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
102 if is_skill_file_path(path) {
103 return parse_skill_markdown(path, content);
104 }
105
106 if is_convention_file(path) {
107 return parse_convention_markdown(path, content);
108 }
109
110 parse_skill_markdown(path, content)
111}
112
113fn parse_convention_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
114 if content.lines().next().is_some_and(|line| line.trim() == "---") {
117 if let Ok(parsed) = parse_skill_markdown(path, content) {
118 return Ok(parsed);
119 }
120 }
121
122 let normalized = content.replace("\r\n", "\n");
123 let body = normalized.trim().to_string();
124
125 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction.md");
126
127 let (name, tags) = match file_name.to_ascii_uppercase().as_str() {
128 "AGENTS.MD" | "AGENT.MD" => {
129 ("agents".to_string(), vec!["convention".to_string(), "agents-md".to_string()])
130 }
131 "CLAUDE.MD" => {
132 ("claude".to_string(), vec!["convention".to_string(), "claude-md".to_string()])
133 }
134 "GEMINI.MD" => {
135 ("gemini".to_string(), vec!["convention".to_string(), "gemini-md".to_string()])
136 }
137 "COPILOT.MD" => {
138 ("copilot".to_string(), vec!["convention".to_string(), "copilot-md".to_string()])
139 }
140 "SKILLS.MD" => {
141 ("skills".to_string(), vec!["convention".to_string(), "skills-md".to_string()])
142 }
143 "SOUL.MD" => ("soul".to_string(), vec!["convention".to_string(), "soul-md".to_string()]),
144 _ => (
145 path.file_stem()
146 .and_then(|stem| stem.to_str())
147 .unwrap_or("instruction")
148 .to_ascii_lowercase(),
149 vec!["convention".to_string()],
150 ),
151 };
152
153 let description = extract_convention_description(&body).unwrap_or_else(|| {
154 format!(
155 "Instructions loaded from {}",
156 path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction file")
157 )
158 });
159
160 Ok(ParsedSkill {
161 name,
162 description,
163 version: None,
164 license: None,
165 compatibility: None,
166 tags,
167 allowed_tools: Vec::new(),
168 references: Vec::new(),
169 trigger: false,
170 hint: None,
171 metadata: std::collections::HashMap::new(),
172 triggers: Vec::new(),
173 body,
174 })
175}
176
177fn is_skill_file_path(path: &Path) -> bool {
178 path.components()
179 .any(|component| component.as_os_str().to_string_lossy().eq_ignore_ascii_case(".skills"))
180}
181
182fn is_convention_file(path: &Path) -> bool {
183 path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
184 CONVENTION_FILES.iter().any(|candidate| name.eq_ignore_ascii_case(candidate))
185 })
186}
187
188fn extract_convention_description(body: &str) -> Option<String> {
189 for line in body.lines() {
190 let trimmed = line.trim();
191 if trimmed.is_empty() {
192 continue;
193 }
194
195 if trimmed.starts_with('#') {
197 let heading = trimmed.trim_start_matches('#').trim();
198 if !heading.is_empty() {
199 return Some(heading.to_string());
200 }
201 }
202
203 return Some(trimmed.chars().take(120).collect::<String>());
204 }
205
206 None
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn parses_valid_skill() {
215 let content = r#"---
216name: repo_search
217description: Search the codebase quickly
218tags:
219 - code
220 - search
221---
222Use ripgrep first.
223"#;
224 let parsed = parse_skill_markdown(Path::new(".skills/repo_search.md"), content).unwrap();
225 assert_eq!(parsed.name, "repo_search");
226 assert_eq!(parsed.description, "Search the codebase quickly");
227 assert_eq!(parsed.tags, vec!["code", "search"]);
228 assert!(parsed.body.contains("Use ripgrep first."));
229 }
230
231 #[test]
232 fn parses_skill_with_full_spec() {
233 let content = r#"---
234name: full_spec_agent
235description: An agent with everything
236version: "1.2.3"
237license: MIT
238compatibility: "Requires Python 3.10+"
239allowed-tools:
240 - tool1
241references:
242 - ref1
243trigger: true
244hint: "Say something"
245metadata:
246 custom_key: custom_value
247tags: [tag1]
248---
249Body content.
250"#;
251 let parsed = parse_skill_markdown(Path::new(".skills/full.md"), content).unwrap();
252 assert_eq!(parsed.name, "full_spec_agent");
253 assert_eq!(parsed.version, Some("1.2.3".to_string()));
254 assert_eq!(parsed.license, Some("MIT".to_string()));
255 assert_eq!(parsed.compatibility, Some("Requires Python 3.10+".to_string()));
256 assert_eq!(parsed.allowed_tools, vec!["tool1"]);
257 assert_eq!(parsed.references, vec!["ref1"]);
258 assert!(parsed.trigger);
259 assert_eq!(parsed.hint, Some("Say something".to_string()));
260 assert_eq!(
261 parsed.metadata.get("custom_key").and_then(|v| v.as_str()),
262 Some("custom_value")
263 );
264 }
265
266 #[test]
267 fn rejects_missing_required_fields() {
268 let content = r#"---
269name: ""
270description: ""
271---
272body
273"#;
274 let err = parse_skill_markdown(Path::new(".skills/bad.md"), content).unwrap_err();
275 assert!(matches!(err, SkillError::MissingField { .. }));
276 }
277
278 #[test]
279 fn parses_agents_md_without_frontmatter() {
280 let content = "# Project Agent Instructions\nAlways prefer rg over grep.\n";
281 let parsed = parse_instruction_markdown(Path::new("AGENTS.md"), content).unwrap();
282
283 assert_eq!(parsed.name, "agents");
284 assert_eq!(parsed.description, "Project Agent Instructions");
285 assert!(parsed.tags.contains(&"convention".to_string()));
286 assert!(parsed.tags.contains(&"agents-md".to_string()));
287 assert!(parsed.body.contains("Always prefer rg"));
288 }
289
290 #[test]
291 fn parses_soul_md_without_frontmatter() {
292 let content = "# Soul Profile\nPrioritize deliberate planning before execution.\n";
293 let parsed = parse_instruction_markdown(Path::new("SOUL.MD"), content).unwrap();
294
295 assert_eq!(parsed.name, "soul");
296 assert_eq!(parsed.description, "Soul Profile");
297 assert!(parsed.tags.contains(&"convention".to_string()));
298 assert!(parsed.tags.contains(&"soul-md".to_string()));
299 assert!(parsed.body.contains("deliberate planning"));
300 }
301
302 #[test]
303 fn keeps_strict_frontmatter_for_skills_directory() {
304 let content = "# Missing frontmatter";
305 let err = parse_instruction_markdown(Path::new(".skills/missing.md"), content).unwrap_err();
306 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
307 }
308}