claude_priority/validators/
frontmatter.rs1use 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 return result;
22 }
23
24 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 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 if !content.starts_with("---") {
83 result.add_error(format!("Missing frontmatter in skill: {}", skill_name));
84 return;
85 }
86
87 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 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 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 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 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 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, };
167
168 if !content.starts_with("---") {
170 return; }
172
173 let frontmatter_str = match extract_frontmatter(&content) {
175 Some(fm) => fm,
176 None => return,
177 };
178
179 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 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}