use std::fs;
use std::io;
use std::path::Path;
use crate::validation::evals::validate_evals_config;
#[derive(Debug, Clone)]
pub struct FileOutcome {
pub skill: String,
pub rel_path: String,
pub error: Option<String>,
}
#[derive(Debug, Default)]
pub struct ValidationReport {
pub outcomes: Vec<FileOutcome>,
}
impl ValidationReport {
pub fn validated(&self) -> usize {
self.outcomes.iter().filter(|o| o.error.is_none()).count()
}
pub fn failed(&self) -> usize {
self.outcomes.iter().filter(|o| o.error.is_some()).count()
}
}
pub fn validate_all(skill_dir: &Path) -> io::Result<ValidationReport> {
let mut skills: Vec<String> = fs::read_dir(skill_dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
entry
.path()
.is_dir()
.then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
skills.sort();
let mut report = ValidationReport::default();
for skill in skills {
let evals_path = skill_dir.join(&skill).join("evals").join("evals.json");
if !evals_path.exists() {
continue;
}
let rel_path = format!("{skill}/evals/evals.json");
let error = match fs::read_to_string(&evals_path) {
Err(e) => Some(format!("{rel_path}: {e}")),
Ok(contents) => match serde_json::from_str(&contents) {
Err(e) => Some(format!("{rel_path}: invalid JSON: {e}")),
Ok(raw) => validate_evals_config(&raw, &rel_path)
.err()
.map(|e| e.to_string()),
},
};
report.outcomes.push(FileOutcome {
skill,
rel_path,
error,
});
}
Ok(report)
}
pub fn validate_one(skill_subdir: &Path) -> io::Result<ValidationReport> {
let skill = skill_subdir
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "skill".to_string());
let evals_path = skill_subdir.join("evals").join("evals.json");
let rel_path = "evals/evals.json".to_string();
let error = match fs::read_to_string(&evals_path) {
Err(e) => Some(format!("{rel_path}: {e}")),
Ok(contents) => match serde_json::from_str(&contents) {
Err(e) => Some(format!("{rel_path}: invalid JSON: {e}")),
Ok(raw) => validate_evals_config(&raw, &rel_path)
.err()
.map(|e| e.to_string()),
},
};
Ok(ValidationReport {
outcomes: vec![FileOutcome {
skill,
rel_path,
error,
}],
})
}
#[cfg(test)]
mod tests {
use super::validate_all;
use std::fs;
use tempfile::TempDir;
const VALID: &str = r#"{ "skill_name": "demo", "evals": [
{ "id": "e1", "prompt": "p", "expected_output": "o" } ] }"#;
fn write_evals(root: &std::path::Path, skill: &str, contents: &str) {
let dir = root.join(skill).join("evals");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("evals.json"), contents).unwrap();
}
#[test]
fn reports_one_outcome_per_skill_with_an_evals_file() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_evals(root, "good", VALID);
write_evals(root, "bad", r#"{ "skill_name": "x", "evals": [] }"#); write_evals(root, "broken", "{ not json"); fs::create_dir_all(root.join("nofile")).unwrap();
fs::write(root.join("README.md"), "hi").unwrap();
let report = validate_all(root).unwrap();
assert_eq!(report.validated(), 1);
assert_eq!(report.failed(), 2);
let good = report.outcomes.iter().find(|o| o.skill == "good").unwrap();
assert!(good.error.is_none());
assert_eq!(good.rel_path, "good/evals/evals.json");
let bad = report.outcomes.iter().find(|o| o.skill == "bad").unwrap();
assert!(bad.error.is_some());
let broken = report
.outcomes
.iter()
.find(|o| o.skill == "broken")
.unwrap();
assert!(broken.error.is_some());
assert!(report.outcomes.iter().all(|o| o.skill != "nofile"));
assert!(report.outcomes.iter().all(|o| o.skill != "README.md"));
}
#[test]
fn an_all_valid_dir_has_no_failures() {
let tmp = TempDir::new().unwrap();
write_evals(tmp.path(), "a", VALID);
write_evals(tmp.path(), "b", VALID);
let report = validate_all(tmp.path()).unwrap();
assert_eq!(report.validated(), 2);
assert_eq!(report.failed(), 0);
assert!(report.outcomes.iter().all(|o| o.error.is_none()));
}
}