1use std::path::Path;
2
3use anyhow::{bail, Result};
4
5use super::config::HubConfig;
6
7#[derive(Debug)]
8pub struct ValidationError {
9 pub file: String,
10 pub line: Option<usize>,
11 pub message: String,
12}
13
14impl std::fmt::Display for ValidationError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self.line {
17 Some(line) => write!(f, "{}:{}: {}", self.file, line, self.message),
18 None => write!(f, "{}: {}", self.file, self.message),
19 }
20 }
21}
22
23pub struct ValidationResult {
24 pub errors: Vec<ValidationError>,
25}
26
27impl ValidationResult {
28 pub fn is_valid(&self) -> bool {
29 self.errors.is_empty()
30 }
31}
32
33pub fn validate_skills_hub(path: &Path) -> Result<ValidationResult> {
34 let mut errors = Vec::new();
35
36 let skill_dirs: Vec<_> = std::fs::read_dir(path)?
37 .filter_map(|e| e.ok())
38 .filter(|e| {
39 let p = e.path();
40 if !p.is_dir() {
41 return false;
42 }
43 !e.file_name().to_string_lossy().starts_with('.')
45 })
46 .collect();
47
48 if skill_dirs.is_empty() {
49 bail!("no skill directories found in {}", path.display());
50 }
51
52 for entry in skill_dirs {
53 let skill_dir = entry.path();
54 let skill_md = skill_dir.join("SKILL.md");
55
56 if !skill_md.exists() {
57 errors.push(ValidationError {
58 file: skill_dir.display().to_string(),
59 line: None,
60 message: "missing SKILL.md".into(),
61 });
62 continue;
63 }
64
65 for nested in std::fs::read_dir(&skill_dir)?.filter_map(|e| e.ok()) {
67 if nested.path().is_dir() {
68 let name = nested.file_name();
69 let name = name.to_string_lossy();
70 if !matches!(name.as_ref(), "scripts" | "references" | "assets") {
71 errors.push(ValidationError {
72 file: nested.path().display().to_string(),
73 line: None,
74 message: "nested skill directories not allowed (flat hierarchy rule)"
75 .into(),
76 });
77 }
78 }
79 }
80
81 errors.extend(validate_skill_frontmatter(&skill_md));
82 }
83
84 Ok(ValidationResult { errors })
85}
86
87pub fn validate_docs_hub(path: &Path) -> Result<ValidationResult> {
88 let mut errors = Vec::new();
89 let cfg = HubConfig::load(path);
90
91 let md_files: Vec<_> = glob_md_files(path, &cfg);
92
93 if md_files.is_empty() {
94 bail!("no .md files found in {}", path.display());
95 }
96
97 for file in md_files {
98 errors.extend(validate_doc_frontmatter(&file));
99 }
100
101 Ok(ValidationResult { errors })
102}
103
104fn validate_skill_frontmatter(path: &Path) -> Vec<ValidationError> {
105 let mut errors = Vec::new();
106 let file = path.display().to_string();
107
108 let content = match std::fs::read_to_string(path) {
109 Ok(c) => c,
110 Err(e) => {
111 return vec![ValidationError {
112 file,
113 line: None,
114 message: e.to_string(),
115 }];
116 }
117 };
118
119 let fm = match parse_frontmatter(&content) {
120 Ok(fm) => fm,
121 Err(e) => {
122 return vec![ValidationError {
123 file,
124 line: Some(1),
125 message: e,
126 }];
127 }
128 };
129
130 for field in ["name", "description"] {
131 if !fm.contains_key(field)
132 || fm[field].is_null()
133 || fm[field].as_str().unwrap_or("").is_empty()
134 {
135 errors.push(ValidationError {
136 file: file.clone(),
137 line: Some(1),
138 message: format!("missing required field: {field}"),
139 });
140 }
141 }
142
143 errors
144}
145
146fn validate_doc_frontmatter(path: &Path) -> Vec<ValidationError> {
147 let mut errors = Vec::new();
148 let file = path.display().to_string();
149
150 let content = match std::fs::read_to_string(path) {
151 Ok(c) => c,
152 Err(e) => {
153 return vec![ValidationError {
154 file,
155 line: None,
156 message: e.to_string(),
157 }];
158 }
159 };
160
161 if !content.starts_with("---") {
162 errors.push(ValidationError {
163 file,
164 line: Some(1),
165 message: "missing YAML frontmatter".into(),
166 });
167 return errors;
168 }
169
170 let fm = match parse_frontmatter(&content) {
171 Ok(fm) => fm,
172 Err(e) => {
173 return vec![ValidationError {
174 file,
175 line: Some(1),
176 message: e,
177 }];
178 }
179 };
180
181 for field in ["title", "summary", "status", "last_updated"] {
182 if !fm.contains_key(field)
183 || fm[field].is_null()
184 || fm[field].as_str().unwrap_or("").is_empty()
185 {
186 errors.push(ValidationError {
187 file: file.clone(),
188 line: Some(1),
189 message: format!("missing required field: {field}"),
190 });
191 }
192 }
193
194 if !fm.contains_key("read_when")
195 || fm["read_when"]
196 .as_sequence()
197 .map(|s| s.is_empty())
198 .unwrap_or(true)
199 {
200 errors.push(ValidationError {
201 file: file.clone(),
202 line: Some(1),
203 message: "missing required field: read_when (must be non-empty list)".into(),
204 });
205 }
206
207 errors
208}
209
210fn parse_frontmatter(content: &str) -> Result<serde_yaml::Mapping, String> {
211 let parts: Vec<&str> = content.splitn(3, "---").collect();
212 if parts.len() < 3 {
213 return Err("invalid frontmatter: missing closing ---".into());
214 }
215 serde_yaml::from_str(parts[1]).map_err(|e| format!("invalid YAML: {e}"))
216}
217
218fn glob_md_files(path: &Path, cfg: &HubConfig) -> Vec<std::path::PathBuf> {
219 fn collect_files(dir: &Path, base: &Path, cfg: &HubConfig, files: &mut Vec<std::path::PathBuf>) {
220 if let Ok(entries) = std::fs::read_dir(dir) {
221 for entry in entries.filter_map(|e| e.ok()) {
222 let p = entry.path();
223 if p.is_dir() {
224 collect_files(&p, base, cfg, files);
225 } else if p.extension().and_then(|e| e.to_str()) == Some("md") {
226 let relative_path = p.strip_prefix(base).unwrap_or(&p);
227 let path_str = relative_path.to_string_lossy().replace('\\', "/");
228 if !cfg.is_ignored(&path_str) {
229 files.push(p);
230 }
231 }
232 }
233 }
234 }
235
236 let mut files = Vec::new();
237 collect_files(path, path, cfg, &mut files);
238 files
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::fs;
245 use tempfile::tempdir;
246
247 #[test]
248 fn hidden_dirs_are_ignored() {
249 let dir = tempdir().unwrap();
250 let skill_dir = dir.path().join("my-skill");
252 fs::create_dir(&skill_dir).unwrap();
253 fs::write(
254 skill_dir.join("SKILL.md"),
255 "---\nname: my-skill\ndescription: test\n---\n",
256 )
257 .unwrap();
258 let git_dir = dir.path().join(".git");
260 fs::create_dir(&git_dir).unwrap();
261
262 let result = validate_skills_hub(dir.path()).unwrap();
263 assert!(result.is_valid(), "errors: {:?}", result.errors);
264 }
265}