Skip to main content

eval_magic/validation/
batch.rs

1//! Batch validation of every `<skill>/evals/evals.json` under a skills dir.
2//!
3//! Pure logic: it returns a [`ValidationReport`] instead of printing. The CLI
4//! handler renders the report (the `✓`/`✗` lines, the summary, the exit code),
5//! which keeps this batch logic unit-testable without capturing stdout.
6
7use std::fs;
8use std::io;
9use std::path::Path;
10
11use crate::validation::evals::validate_evals_config;
12
13/// The result of validating one `<skill>/evals/evals.json`.
14#[derive(Debug, Clone)]
15pub struct FileOutcome {
16    /// The skill directory name (used to label summary lines).
17    pub skill: String,
18    /// Display path relative to the skills dir: `<skill>/evals/evals.json`.
19    pub rel_path: String,
20    /// `None` if the file validated; otherwise the failure message.
21    pub error: Option<String>,
22}
23
24/// The outcome of a batch run, one entry per skill that had an `evals.json`.
25#[derive(Debug, Default)]
26pub struct ValidationReport {
27    pub outcomes: Vec<FileOutcome>,
28}
29
30impl ValidationReport {
31    /// How many files validated cleanly.
32    pub fn validated(&self) -> usize {
33        self.outcomes.iter().filter(|o| o.error.is_none()).count()
34    }
35
36    /// How many files failed validation.
37    pub fn failed(&self) -> usize {
38        self.outcomes.iter().filter(|o| o.error.is_some()).count()
39    }
40}
41
42/// Validate `<skill>/evals/evals.json` for every immediate child directory of
43/// `skill_dir` that has one. Directories without an `evals.json` are skipped;
44/// a parse or validation failure is recorded as a [`FileOutcome`] rather than
45/// aborting the batch. Skills are processed in sorted order for deterministic
46/// output.
47pub fn validate_all(skill_dir: &Path) -> io::Result<ValidationReport> {
48    // `entry.path().is_dir()` follows symlinks (matching the original's
49    // `statSync().isDirectory()`), so a symlinked skill directory is still seen.
50    let mut skills: Vec<String> = fs::read_dir(skill_dir)?
51        .filter_map(|entry| {
52            let entry = entry.ok()?;
53            entry
54                .path()
55                .is_dir()
56                .then(|| entry.file_name().to_string_lossy().into_owned())
57        })
58        .collect();
59    skills.sort();
60
61    let mut report = ValidationReport::default();
62    for skill in skills {
63        let evals_path = skill_dir.join(&skill).join("evals").join("evals.json");
64        if !evals_path.exists() {
65            continue;
66        }
67
68        let rel_path = format!("{skill}/evals/evals.json");
69        let error = match fs::read_to_string(&evals_path) {
70            Err(e) => Some(format!("{rel_path}: {e}")),
71            Ok(contents) => match serde_json::from_str(&contents) {
72                Err(e) => Some(format!("{rel_path}: invalid JSON: {e}")),
73                Ok(raw) => validate_evals_config(&raw, &rel_path)
74                    .err()
75                    .map(|e| e.to_string()),
76            },
77        };
78
79        report.outcomes.push(FileOutcome {
80            skill,
81            rel_path,
82            error,
83        });
84    }
85
86    Ok(report)
87}
88
89/// Validate exactly one skill's `evals/evals.json`, reporting paths relative to
90/// that skill directory.
91pub fn validate_one(skill_subdir: &Path) -> io::Result<ValidationReport> {
92    let skill = skill_subdir
93        .file_name()
94        .map(|name| name.to_string_lossy().into_owned())
95        .unwrap_or_else(|| "skill".to_string());
96    let evals_path = skill_subdir.join("evals").join("evals.json");
97    let rel_path = "evals/evals.json".to_string();
98    let error = match fs::read_to_string(&evals_path) {
99        Err(e) => Some(format!("{rel_path}: {e}")),
100        Ok(contents) => match serde_json::from_str(&contents) {
101            Err(e) => Some(format!("{rel_path}: invalid JSON: {e}")),
102            Ok(raw) => validate_evals_config(&raw, &rel_path)
103                .err()
104                .map(|e| e.to_string()),
105        },
106    };
107
108    Ok(ValidationReport {
109        outcomes: vec![FileOutcome {
110            skill,
111            rel_path,
112            error,
113        }],
114    })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::validate_all;
120    use std::fs;
121    use tempfile::TempDir;
122
123    /// A minimal valid `evals.json` body.
124    const VALID: &str = r#"{ "skill_name": "demo", "evals": [
125        { "id": "e1", "prompt": "p", "expected_output": "o" } ] }"#;
126
127    /// Write `<root>/<skill>/evals/evals.json` with the given contents.
128    fn write_evals(root: &std::path::Path, skill: &str, contents: &str) {
129        let dir = root.join(skill).join("evals");
130        fs::create_dir_all(&dir).unwrap();
131        fs::write(dir.join("evals.json"), contents).unwrap();
132    }
133
134    #[test]
135    fn reports_one_outcome_per_skill_with_an_evals_file() {
136        let tmp = TempDir::new().unwrap();
137        let root = tmp.path();
138        write_evals(root, "good", VALID);
139        write_evals(root, "bad", r#"{ "skill_name": "x", "evals": [] }"#); // empty evals
140        write_evals(root, "broken", "{ not json"); // parse failure
141        // A skill directory with no evals.json is skipped entirely.
142        fs::create_dir_all(root.join("nofile")).unwrap();
143        // A plain file at the top level is not a skill directory.
144        fs::write(root.join("README.md"), "hi").unwrap();
145
146        let report = validate_all(root).unwrap();
147
148        assert_eq!(report.validated(), 1);
149        assert_eq!(report.failed(), 2);
150
151        let good = report.outcomes.iter().find(|o| o.skill == "good").unwrap();
152        assert!(good.error.is_none());
153        assert_eq!(good.rel_path, "good/evals/evals.json");
154
155        let bad = report.outcomes.iter().find(|o| o.skill == "bad").unwrap();
156        assert!(bad.error.is_some());
157
158        let broken = report
159            .outcomes
160            .iter()
161            .find(|o| o.skill == "broken")
162            .unwrap();
163        assert!(broken.error.is_some());
164
165        assert!(report.outcomes.iter().all(|o| o.skill != "nofile"));
166        assert!(report.outcomes.iter().all(|o| o.skill != "README.md"));
167    }
168
169    #[test]
170    fn an_all_valid_dir_has_no_failures() {
171        let tmp = TempDir::new().unwrap();
172        write_evals(tmp.path(), "a", VALID);
173        write_evals(tmp.path(), "b", VALID);
174
175        let report = validate_all(tmp.path()).unwrap();
176
177        assert_eq!(report.validated(), 2);
178        assert_eq!(report.failed(), 0);
179        assert!(report.outcomes.iter().all(|o| o.error.is_none()));
180    }
181}