Skip to main content

tycode_core/skills/
parser.rs

1use std::path::Path;
2
3use anyhow::{anyhow, Context, Result};
4use serde::Deserialize;
5
6use super::types::{SkillInstructions, SkillMetadata, SkillSource};
7
8/// Raw frontmatter parsed from SKILL.md YAML header.
9#[derive(Debug, Deserialize)]
10struct RawFrontmatter {
11    name: String,
12    description: String,
13}
14
15/// Parses a SKILL.md file and extracts metadata and instructions.
16///
17/// The file format is:
18/// ```markdown
19/// ---
20/// name: skill-name
21/// description: What this skill does
22/// ---
23///
24/// # Skill Instructions
25/// ...
26/// ```
27pub fn parse_skill_file(
28    path: &Path,
29    source: SkillSource,
30    enabled: bool,
31) -> Result<SkillInstructions> {
32    let content = std::fs::read_to_string(path)
33        .with_context(|| format!("Failed to read skill file: {}", path.display()))?;
34
35    parse_skill_content(&content, path, source, enabled)
36}
37
38/// Parses skill content from a string.
39pub fn parse_skill_content(
40    content: &str,
41    path: &Path,
42    source: SkillSource,
43    enabled: bool,
44) -> Result<SkillInstructions> {
45    let (frontmatter, instructions) = extract_frontmatter(content)?;
46
47    let raw: RawFrontmatter = serde_yaml::from_str(&frontmatter)
48        .with_context(|| format!("Failed to parse YAML frontmatter in {}", path.display()))?;
49
50    // Validate name format
51    if !SkillMetadata::is_valid_name(&raw.name) {
52        return Err(anyhow!(
53            "Invalid skill name '{}': must be lowercase letters, numbers, and hyphens only (max {} chars)",
54            raw.name,
55            super::types::MAX_SKILL_NAME_LENGTH
56        ));
57    }
58
59    // Validate description
60    if !SkillMetadata::is_valid_description(&raw.description) {
61        return Err(anyhow!(
62            "Invalid skill description: must be non-empty and max {} chars",
63            super::types::MAX_SKILL_DESCRIPTION_LENGTH
64        ));
65    }
66
67    let skill_dir = path
68        .parent()
69        .ok_or_else(|| anyhow!("Skill file has no parent directory"))?;
70
71    // Discover reference files (*.md files other than SKILL.md)
72    let reference_files = discover_reference_files(skill_dir);
73
74    // Discover scripts in scripts/ subdirectory
75    let scripts = discover_scripts(skill_dir);
76
77    Ok(SkillInstructions {
78        metadata: SkillMetadata {
79            name: raw.name,
80            description: raw.description,
81            source,
82            path: path.to_path_buf(),
83            enabled,
84        },
85        instructions,
86        reference_files,
87        scripts,
88    })
89}
90
91/// Extracts YAML frontmatter and body from a markdown file.
92///
93/// Frontmatter is delimited by `---` at the start and end.
94fn extract_frontmatter(content: &str) -> Result<(String, String)> {
95    let content = content.trim();
96
97    if !content.starts_with("---") {
98        return Err(anyhow!("SKILL.md must start with YAML frontmatter (---)"));
99    }
100
101    // Find the closing ---
102    let rest = &content[3..];
103    let end_pos = rest
104        .find("\n---")
105        .ok_or_else(|| anyhow!("SKILL.md frontmatter not closed (missing ---)"))?;
106
107    let frontmatter = rest[..end_pos].trim().to_string();
108    let body = rest[end_pos + 4..].trim().to_string();
109
110    if frontmatter.is_empty() {
111        return Err(anyhow!("SKILL.md frontmatter is empty"));
112    }
113
114    Ok((frontmatter, body))
115}
116
117/// Discovers reference markdown files in the skill directory.
118fn discover_reference_files(skill_dir: &Path) -> Vec<std::path::PathBuf> {
119    let mut files = Vec::new();
120
121    if let Ok(entries) = std::fs::read_dir(skill_dir) {
122        for entry in entries.flatten() {
123            let path = entry.path();
124            if path.is_file() {
125                if let Some(ext) = path.extension() {
126                    if ext == "md" {
127                        // Exclude SKILL.md itself
128                        if let Some(name) = path.file_name() {
129                            if name.to_string_lossy().to_uppercase() != "SKILL.MD" {
130                                files.push(path);
131                            }
132                        }
133                    }
134                }
135            }
136        }
137    }
138
139    files.sort();
140    files
141}
142
143/// Discovers script files in the scripts/ subdirectory.
144fn discover_scripts(skill_dir: &Path) -> Vec<std::path::PathBuf> {
145    let scripts_dir = skill_dir.join("scripts");
146    let mut files = Vec::new();
147
148    if scripts_dir.is_dir() {
149        if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
150            for entry in entries.flatten() {
151                let path = entry.path();
152                if path.is_file() {
153                    files.push(path);
154                }
155            }
156        }
157    }
158
159    files.sort();
160    files
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_extract_frontmatter_valid() {
169        let content = r#"---
170name: test-skill
171description: A test skill
172---
173
174# Instructions
175
176Some instructions here.
177"#;
178        let (frontmatter, body) = extract_frontmatter(content).unwrap();
179        assert!(frontmatter.contains("name: test-skill"));
180        assert!(frontmatter.contains("description: A test skill"));
181        assert!(body.contains("# Instructions"));
182        assert!(body.contains("Some instructions here."));
183    }
184
185    #[test]
186    fn test_extract_frontmatter_no_start() {
187        let content = "# No frontmatter\nJust content";
188        assert!(extract_frontmatter(content).is_err());
189    }
190
191    #[test]
192    fn test_extract_frontmatter_no_end() {
193        let content = "---\nname: test\n# No closing delimiter";
194        assert!(extract_frontmatter(content).is_err());
195    }
196
197    #[test]
198    fn test_parse_skill_content_valid() {
199        let content = r#"---
200name: my-skill
201description: Does something useful when you ask
202---
203
204# My Skill
205
206Follow these instructions.
207"#;
208        let path = Path::new("/test/skills/my-skill/SKILL.md");
209        let result = parse_skill_content(content, path, SkillSource::User, true).unwrap();
210
211        assert_eq!(result.metadata.name, "my-skill");
212        assert_eq!(
213            result.metadata.description,
214            "Does something useful when you ask"
215        );
216        assert!(result.metadata.enabled);
217        assert!(result.instructions.contains("# My Skill"));
218    }
219
220    #[test]
221    fn test_parse_skill_content_invalid_name() {
222        let content = r#"---
223name: Invalid_Name
224description: Has invalid name
225---
226
227Instructions
228"#;
229        let path = Path::new("/test/SKILL.md");
230        let result = parse_skill_content(content, path, SkillSource::User, true);
231        assert!(result.is_err());
232        assert!(result
233            .unwrap_err()
234            .to_string()
235            .contains("Invalid skill name"));
236    }
237
238    #[test]
239    fn test_parse_skill_content_empty_description() {
240        let content = r#"---
241name: valid-name
242description: ""
243---
244
245Instructions
246"#;
247        let path = Path::new("/test/SKILL.md");
248        let result = parse_skill_content(content, path, SkillSource::User, true);
249        assert!(result.is_err());
250    }
251}