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> {
9 let normalized = content.replace("\r\n", "\n");
10 let mut lines = normalized.lines();
11
12 let first = lines.next().unwrap_or_default().trim();
13 if first != "---" {
14 return Err(SkillError::InvalidFrontmatter {
15 path: path.to_path_buf(),
16 message: "missing opening frontmatter delimiter (`---`)".to_string(),
17 });
18 }
19
20 let mut frontmatter_lines = Vec::new();
21 let mut found_end = false;
22 for line in lines.by_ref() {
23 if line.trim() == "---" {
24 found_end = true;
25 break;
26 }
27 frontmatter_lines.push(line);
28 }
29
30 if !found_end {
31 return Err(SkillError::InvalidFrontmatter {
32 path: path.to_path_buf(),
33 message: "missing closing frontmatter delimiter (`---`)".to_string(),
34 });
35 }
36
37 let frontmatter_raw = frontmatter_lines.join("\n");
38 let fm: SkillFrontmatter = serde_yaml::from_str(&frontmatter_raw)?;
39
40 let name = fm.name.trim().to_string();
41 if name.is_empty() {
42 return Err(SkillError::MissingField { path: path.to_path_buf(), field: "name" });
43 }
44
45 let description = fm.description.trim().to_string();
46 if description.is_empty() {
47 return Err(SkillError::MissingField { path: path.to_path_buf(), field: "description" });
48 }
49
50 let tags = fm
51 .tags
52 .into_iter()
53 .map(|t| t.trim().to_string())
54 .filter(|t| !t.is_empty())
55 .collect::<Vec<_>>();
56
57 let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
58
59 Ok(ParsedSkill { name, description, tags, body })
60}
61
62pub fn parse_instruction_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
63 if is_skill_file_path(path) {
64 return parse_skill_markdown(path, content);
65 }
66
67 if is_convention_file(path) {
68 return parse_convention_markdown(path, content);
69 }
70
71 parse_skill_markdown(path, content)
72}
73
74fn parse_convention_markdown(path: &Path, content: &str) -> SkillResult<ParsedSkill> {
75 if content.lines().next().is_some_and(|line| line.trim() == "---") {
78 if let Ok(parsed) = parse_skill_markdown(path, content) {
79 return Ok(parsed);
80 }
81 }
82
83 let normalized = content.replace("\r\n", "\n");
84 let body = normalized.trim().to_string();
85
86 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction.md");
87
88 let (name, tags) = match file_name.to_ascii_uppercase().as_str() {
89 "AGENTS.MD" | "AGENT.MD" => {
90 ("agents".to_string(), vec!["convention".to_string(), "agents-md".to_string()])
91 }
92 "CLAUDE.MD" => {
93 ("claude".to_string(), vec!["convention".to_string(), "claude-md".to_string()])
94 }
95 "GEMINI.MD" => {
96 ("gemini".to_string(), vec!["convention".to_string(), "gemini-md".to_string()])
97 }
98 "COPILOT.MD" => {
99 ("copilot".to_string(), vec!["convention".to_string(), "copilot-md".to_string()])
100 }
101 "SKILLS.MD" => {
102 ("skills".to_string(), vec!["convention".to_string(), "skills-md".to_string()])
103 }
104 "SOUL.MD" => ("soul".to_string(), vec!["convention".to_string(), "soul-md".to_string()]),
105 _ => (
106 path.file_stem()
107 .and_then(|stem| stem.to_str())
108 .unwrap_or("instruction")
109 .to_ascii_lowercase(),
110 vec!["convention".to_string()],
111 ),
112 };
113
114 let description = extract_convention_description(&body).unwrap_or_else(|| {
115 format!(
116 "Instructions loaded from {}",
117 path.file_name().and_then(|n| n.to_str()).unwrap_or("instruction file")
118 )
119 });
120
121 Ok(ParsedSkill { name, description, tags, body })
122}
123
124fn is_skill_file_path(path: &Path) -> bool {
125 path.components()
126 .any(|component| component.as_os_str().to_string_lossy().eq_ignore_ascii_case(".skills"))
127}
128
129fn is_convention_file(path: &Path) -> bool {
130 path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
131 CONVENTION_FILES.iter().any(|candidate| name.eq_ignore_ascii_case(candidate))
132 })
133}
134
135fn extract_convention_description(body: &str) -> Option<String> {
136 for line in body.lines() {
137 let trimmed = line.trim();
138 if trimmed.is_empty() {
139 continue;
140 }
141
142 if trimmed.starts_with('#') {
144 let heading = trimmed.trim_start_matches('#').trim();
145 if !heading.is_empty() {
146 return Some(heading.to_string());
147 }
148 }
149
150 return Some(trimmed.chars().take(120).collect::<String>());
151 }
152
153 None
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn parses_valid_skill() {
162 let content = r#"---
163name: repo_search
164description: Search the codebase quickly
165tags:
166 - code
167 - search
168---
169Use ripgrep first.
170"#;
171 let parsed = parse_skill_markdown(Path::new(".skills/repo_search.md"), content).unwrap();
172 assert_eq!(parsed.name, "repo_search");
173 assert_eq!(parsed.description, "Search the codebase quickly");
174 assert_eq!(parsed.tags, vec!["code", "search"]);
175 assert!(parsed.body.contains("Use ripgrep first."));
176 }
177
178 #[test]
179 fn rejects_missing_required_fields() {
180 let content = r#"---
181name: ""
182description: ""
183---
184body
185"#;
186 let err = parse_skill_markdown(Path::new(".skills/bad.md"), content).unwrap_err();
187 assert!(matches!(err, SkillError::MissingField { .. }));
188 }
189
190 #[test]
191 fn parses_agents_md_without_frontmatter() {
192 let content = "# Project Agent Instructions\nAlways prefer rg over grep.\n";
193 let parsed = parse_instruction_markdown(Path::new("AGENTS.md"), content).unwrap();
194
195 assert_eq!(parsed.name, "agents");
196 assert_eq!(parsed.description, "Project Agent Instructions");
197 assert!(parsed.tags.contains(&"convention".to_string()));
198 assert!(parsed.tags.contains(&"agents-md".to_string()));
199 assert!(parsed.body.contains("Always prefer rg"));
200 }
201
202 #[test]
203 fn parses_soul_md_without_frontmatter() {
204 let content = "# Soul Profile\nPrioritize deliberate planning before execution.\n";
205 let parsed = parse_instruction_markdown(Path::new("SOUL.MD"), content).unwrap();
206
207 assert_eq!(parsed.name, "soul");
208 assert_eq!(parsed.description, "Soul Profile");
209 assert!(parsed.tags.contains(&"convention".to_string()));
210 assert!(parsed.tags.contains(&"soul-md".to_string()));
211 assert!(parsed.body.contains("deliberate planning"));
212 }
213
214 #[test]
215 fn keeps_strict_frontmatter_for_skills_directory() {
216 let content = "# Missing frontmatter";
217 let err = parse_instruction_markdown(Path::new(".skills/missing.md"), content).unwrap_err();
218 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
219 }
220}