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        triggers: fm.triggers,
91        body,
92    })
93}
94
95/// Parses an instruction Markdown file, choosing the strategy based on its path.
96///
97/// Files inside `.skills/` are parsed strictly with required YAML frontmatter.
98/// Known convention files (e.g. `AGENTS.md`, `CLAUDE.md`) are parsed leniently,
99/// deriving name, description, and tags from the filename and content when
100/// frontmatter is absent.
101pub 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    // Convention files often use plain markdown without frontmatter. If frontmatter is present
115    // and valid, we still honor it for compatibility.
116    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        // Prefer first markdown heading if present.
196        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}