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
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    // Convention files often use plain markdown without frontmatter. If frontmatter is present
76    // and valid, we still honor it for compatibility.
77    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        // Prefer first markdown heading if present.
143        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}