Skip to main content

agentctl/hub/
validate.rs

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            // skip hidden directories (e.g. .git)
44            !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        // Flat hierarchy: no nested skill dirs
66        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        // create a valid skill dir
251        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        // create a hidden dir that should be ignored
259        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}