Skip to main content

adk_skill/
parser.rs

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
8/// Parses a skill Markdown file that uses YAML frontmatter.
9///
10/// The file must begin with a `---` delimited frontmatter block containing at
11/// least `name` and `description` fields. Everything after the closing `---`
12/// delimiter is captured as the skill body.
13pub 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
94/// Parses an instruction Markdown file, choosing the strategy based on its path.
95///
96/// Files inside `.skills/` are parsed strictly with required YAML frontmatter.
97/// Known convention files (e.g. `AGENTS.md`, `CLAUDE.md`) are parsed leniently,
98/// deriving name, description, and tags from the filename and content when
99/// frontmatter is absent.
100pub 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    // Convention files often use plain markdown without frontmatter. If frontmatter is present
114    // and valid, we still honor it for compatibility.
115    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        // Prefer first markdown heading if present.
194        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}