claude_priority/validators/
frontmatter.rs

1use crate::models::{CheckType, SkillFrontmatter, ValidationResult};
2use std::fs;
3use std::path::Path;
4use walkdir::WalkDir;
5
6const VALID_LICENSES: &[&str] = &[
7    "MIT",
8    "Apache-2.0",
9    "GPL-3.0",
10    "BSD-3-Clause",
11    "CC-BY-SA",
12    "Unlicense",
13];
14
15pub fn validate_frontmatter(plugin_path: &Path) -> ValidationResult {
16    let mut result = ValidationResult::new(CheckType::Frontmatter);
17    let skills_dir = plugin_path.join("skills");
18
19    if !skills_dir.exists() || !skills_dir.is_dir() {
20        // No skills directory is okay
21        return result;
22    }
23
24    // Find all skill.md files
25    for entry in WalkDir::new(&skills_dir)
26        .min_depth(2)
27        .max_depth(2)
28        .into_iter()
29        .filter_map(|e| e.ok())
30    {
31        if entry.file_type().is_file() && entry.file_name() == "skill.md" {
32            let skill_name = entry
33                .path()
34                .parent()
35                .and_then(|p| p.file_name())
36                .and_then(|n| n.to_str())
37                .unwrap_or("unknown");
38
39            validate_skill_frontmatter(entry.path(), skill_name, &mut result);
40        }
41    }
42
43    // Check commands if they exist
44    let commands_dir = plugin_path.join("commands");
45    if commands_dir.exists() && commands_dir.is_dir() {
46        for entry in WalkDir::new(&commands_dir)
47            .min_depth(2)
48            .max_depth(2)
49            .into_iter()
50            .filter_map(|e| e.ok())
51        {
52            if entry.file_type().is_file() && entry.file_name() == "command.md" {
53                let command_name = entry
54                    .path()
55                    .parent()
56                    .and_then(|p| p.file_name())
57                    .and_then(|n| n.to_str())
58                    .unwrap_or("unknown");
59
60                validate_command_frontmatter(entry.path(), command_name, &mut result);
61            }
62        }
63    }
64
65    result
66}
67
68fn validate_skill_frontmatter(
69    skill_md: &Path,
70    skill_name: &str,
71    result: &mut ValidationResult,
72) {
73    let content = match fs::read_to_string(skill_md) {
74        Ok(c) => c,
75        Err(e) => {
76            result.add_error(format!("Failed to read skill.md in '{}': {}", skill_name, e));
77            return;
78        }
79    };
80
81    // Check for frontmatter markers
82    if !content.starts_with("---") {
83        result.add_error(format!("Missing frontmatter in skill: {}", skill_name));
84        return;
85    }
86
87    // Extract frontmatter
88    let frontmatter_str = match extract_frontmatter(&content) {
89        Some(fm) => fm,
90        None => {
91            result.add_error(format!("Empty frontmatter in skill: {}", skill_name));
92            return;
93        }
94    };
95
96    // Parse YAML frontmatter
97    let frontmatter: SkillFrontmatter = match serde_yaml::from_str(&frontmatter_str) {
98        Ok(fm) => fm,
99        Err(e) => {
100            result.add_error(format!(
101                "Invalid YAML frontmatter in skill '{}': {}",
102                skill_name, e
103            ));
104            return;
105        }
106    };
107
108    // Check required fields
109    if frontmatter.name.is_empty() {
110        result.add_error(format!("Missing 'name' in frontmatter: {}", skill_name));
111    }
112
113    if frontmatter.description.is_empty() {
114        result.add_error(format!(
115            "Missing 'description' in frontmatter: {}",
116            skill_name
117        ));
118    } else {
119        // Validate description length (10-200 chars for skills)
120        let desc_len = frontmatter.description.len();
121        if desc_len < 10 {
122            result.add_error(format!(
123                "Description too short in skill '{}' ({} chars, minimum 10)",
124                skill_name, desc_len
125            ));
126        } else if desc_len > 200 {
127            result.add_warning(format!(
128                "Description very long in skill '{}' ({} chars, recommended max 200)",
129                skill_name, desc_len
130            ));
131        }
132    }
133
134    // Validate license if present
135    if let Some(license) = &frontmatter.license {
136        if !VALID_LICENSES.contains(&license.as_str()) {
137            result.add_warning(format!(
138                "License '{}' in skill '{}' is not in the approved list: {:?}",
139                license, skill_name, VALID_LICENSES
140            ));
141        }
142    }
143
144    // Check that title heading matches skill name
145    if let Some(title_line) = content.lines().skip_while(|l| l.starts_with("---") || l.trim().is_empty()).next() {
146        if title_line.starts_with("# ") {
147            let title = title_line.trim_start_matches("# ").trim();
148            if title.to_lowercase().replace(' ', "-") != skill_name {
149                result.add_warning(format!(
150                    "Title heading '{}' doesn't match skill directory '{}'",
151                    title, skill_name
152                ));
153            }
154        }
155    }
156}
157
158fn validate_command_frontmatter(
159    command_md: &Path,
160    command_name: &str,
161    result: &mut ValidationResult,
162) {
163    let content = match fs::read_to_string(command_md) {
164        Ok(c) => c,
165        Err(_) => return, // Commands frontmatter is optional
166    };
167
168    // Check for frontmatter markers
169    if !content.starts_with("---") {
170        return; // Optional for commands
171    }
172
173    // Extract frontmatter
174    let frontmatter_str = match extract_frontmatter(&content) {
175        Some(fm) => fm,
176        None => return,
177    };
178
179    // Parse YAML frontmatter
180    let frontmatter: SkillFrontmatter = match serde_yaml::from_str(&frontmatter_str) {
181        Ok(fm) => fm,
182        Err(e) => {
183            result.add_error(format!(
184                "Invalid YAML frontmatter in command '{}': {}",
185                command_name, e
186            ));
187            return;
188        }
189    };
190
191    // For commands, validate description length (5-100 chars)
192    if !frontmatter.description.is_empty() {
193        let desc_len = frontmatter.description.len();
194        if desc_len < 5 {
195            result.add_warning(format!(
196                "Description too short in command '{}' ({} chars, minimum 5)",
197                command_name, desc_len
198            ));
199        } else if desc_len > 100 {
200            result.add_warning(format!(
201                "Description long in command '{}' ({} chars, recommended max 100)",
202                command_name, desc_len
203            ));
204        }
205    }
206}
207
208fn extract_frontmatter(content: &str) -> Option<String> {
209    let lines: Vec<&str> = content.lines().collect();
210    if lines.is_empty() || lines[0] != "---" {
211        return None;
212    }
213
214    let mut frontmatter_lines = Vec::new();
215    for line in lines.iter().skip(1) {
216        if *line == "---" {
217            break;
218        }
219        frontmatter_lines.push(*line);
220    }
221
222    if frontmatter_lines.is_empty() {
223        return None;
224    }
225
226    Some(frontmatter_lines.join("\n"))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_extract_frontmatter() {
235        let content = "---\nname: test\ndescription: Test\n---\nContent";
236        let fm = extract_frontmatter(content).unwrap();
237        assert!(fm.contains("name: test"));
238    }
239
240    #[test]
241    fn test_extract_frontmatter_empty() {
242        let content = "---\n---\nContent";
243        assert!(extract_frontmatter(content).is_none());
244    }
245
246    #[test]
247    fn test_extract_frontmatter_missing() {
248        let content = "No frontmatter here";
249        assert!(extract_frontmatter(content).is_none());
250    }
251}