Skip to main content

alef_e2e/
validate.rs

1//! JSON Schema and semantic validation for e2e fixture files.
2
3use crate::config::E2eConfig;
4use crate::fixture::{Fixture, group_fixtures};
5use anyhow::{Context, Result};
6use std::fmt;
7use std::path::Path;
8
9static FIXTURE_SCHEMA: &str = include_str!("../schema/fixture.schema.json");
10
11/// Severity level for validation diagnostics.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Severity {
14    /// Hard error — fixture is broken and will not produce correct tests.
15    Error,
16    /// Warning — fixture may not behave as intended.
17    Warning,
18}
19
20impl fmt::Display for Severity {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Severity::Error => write!(f, "error"),
24            Severity::Warning => write!(f, "warning"),
25        }
26    }
27}
28
29/// A validation error with its source file and message.
30#[derive(Debug, Clone)]
31pub struct ValidationError {
32    /// Relative path of the fixture file that failed validation.
33    pub file: String,
34    /// Human-readable error message.
35    pub message: String,
36    /// Severity level.
37    pub severity: Severity,
38}
39
40impl fmt::Display for ValidationError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(f, "[{}] {}: {}", self.severity, self.file, self.message)
43    }
44}
45
46/// Validate all JSON fixture files in a directory against the fixture schema.
47///
48/// Returns a list of validation errors. An empty list means all fixtures are valid.
49pub fn validate_fixtures(fixtures_dir: &Path) -> Result<Vec<ValidationError>> {
50    let schema_value: serde_json::Value =
51        serde_json::from_str(FIXTURE_SCHEMA).context("failed to parse embedded fixture schema")?;
52    let validator = jsonschema::validator_for(&schema_value).context("failed to compile fixture schema")?;
53
54    let mut errors = Vec::new();
55    validate_recursive(fixtures_dir, fixtures_dir, &validator, &mut errors)?;
56    Ok(errors)
57}
58
59/// Perform semantic validation on loaded fixtures against e2e configuration.
60///
61/// Checks for:
62/// 1. Fixtures skipped for all languages (empty `skip.languages`)
63/// 2. Unknown call references not in `[e2e.calls.*]`
64/// 3. Categories where all fixtures are skipped (produces 0 test functions)
65/// 4. Missing required input fields for the resolved call config
66pub fn validate_fixtures_semantic(
67    fixtures: &[Fixture],
68    e2e_config: &E2eConfig,
69    languages: &[String],
70) -> Vec<ValidationError> {
71    let mut errors = Vec::new();
72
73    // Per-fixture checks
74    for fixture in fixtures {
75        // Check 1: skip-all detection
76        if let Some(skip) = &fixture.skip {
77            if skip.languages.is_empty() {
78                let reason = skip.reason.as_deref().unwrap_or("no reason given");
79                errors.push(ValidationError {
80                    file: fixture.source.clone(),
81                    message: format!(
82                        "fixture '{}' is skipped for all languages (skip.languages is empty). Reason: {}",
83                        fixture.id, reason
84                    ),
85                    severity: Severity::Warning,
86                });
87            }
88        }
89
90        // Check 2: unknown call reference
91        if let Some(call_name) = &fixture.call {
92            if !e2e_config.calls.contains_key(call_name) {
93                errors.push(ValidationError {
94                    file: fixture.source.clone(),
95                    message: format!(
96                        "fixture '{}' references unknown call '{}', will fall back to default [e2e.call]",
97                        fixture.id, call_name
98                    ),
99                    severity: Severity::Error,
100                });
101            }
102        }
103
104        // Check 4: missing required input fields
105        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
106        for arg in &call_config.args {
107            if arg.optional {
108                continue;
109            }
110            // Extract the input field name from the field path (e.g., "input.path" -> "path")
111            let input_field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
112            if !fixture.input.is_null() {
113                if let Some(obj) = fixture.input.as_object() {
114                    if !obj.contains_key(input_field) {
115                        // Skip check for error-type assertions (they may intentionally omit fields)
116                        let is_error_test = fixture.assertions.iter().any(|a| a.assertion_type == "error");
117                        if !is_error_test {
118                            errors.push(ValidationError {
119                                file: fixture.source.clone(),
120                                message: format!(
121                                    "fixture '{}' is missing required input field '{}' for call '{}'",
122                                    fixture.id,
123                                    input_field,
124                                    fixture.call.as_deref().unwrap_or("<default>")
125                                ),
126                                severity: Severity::Warning,
127                            });
128                        }
129                    }
130                }
131            }
132        }
133    }
134
135    // Check 3: empty categories (all fixtures skipped for all languages)
136    if !languages.is_empty() {
137        let groups = group_fixtures(fixtures);
138        for group in &groups {
139            let has_any_non_skipped = group.fixtures.iter().any(|f| {
140                match &f.skip {
141                    None => true, // no skip → will generate
142                    Some(skip) => {
143                        // At least one language is NOT skipped
144                        languages.iter().any(|lang| !skip.should_skip(lang))
145                    }
146                }
147            });
148
149            if !has_any_non_skipped {
150                errors.push(ValidationError {
151                    file: format!("{}/ (category)", group.category),
152                    message: format!(
153                        "category '{}' produces 0 test functions — all {} fixture(s) are skipped for all languages",
154                        group.category,
155                        group.fixtures.len()
156                    ),
157                    severity: Severity::Error,
158                });
159            }
160        }
161    }
162
163    errors
164}
165
166fn validate_recursive(
167    base: &Path,
168    dir: &Path,
169    validator: &jsonschema::Validator,
170    errors: &mut Vec<ValidationError>,
171) -> Result<()> {
172    let entries = std::fs::read_dir(dir).with_context(|| format!("failed to read directory: {}", dir.display()))?;
173
174    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
175    paths.sort();
176
177    for path in paths {
178        if path.is_dir() {
179            validate_recursive(base, &path, validator, errors)?;
180        } else if path.extension().is_some_and(|ext| ext == "json") {
181            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
182            // Skip schema files and files starting with _
183            if filename == "schema.json" || filename.starts_with('_') {
184                continue;
185            }
186
187            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
188
189            let content = match std::fs::read_to_string(&path) {
190                Ok(c) => c,
191                Err(e) => {
192                    errors.push(ValidationError {
193                        file: relative,
194                        message: format!("failed to read file: {e}"),
195                        severity: Severity::Error,
196                    });
197                    continue;
198                }
199            };
200
201            let value: serde_json::Value = match serde_json::from_str(&content) {
202                Ok(v) => v,
203                Err(e) => {
204                    errors.push(ValidationError {
205                        file: relative,
206                        message: format!("invalid JSON: {e}"),
207                        severity: Severity::Error,
208                    });
209                    continue;
210                }
211            };
212
213            for error in validator.iter_errors(&value) {
214                errors.push(ValidationError {
215                    file: relative.clone(),
216                    message: format!("{} at {}", error, error.instance_path()),
217                    severity: Severity::Error,
218                });
219            }
220        }
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::fixture::SkipDirective;
229    use alef_core::config::e2e::{ArgMapping, CallConfig};
230
231    fn make_fixture(id: &str, source: &str, skip: Option<SkipDirective>, call: Option<&str>) -> Fixture {
232        Fixture {
233            id: id.to_string(),
234            category: None,
235            description: format!("Test {id}"),
236            tags: vec![],
237            skip,
238            call: call.map(|s| s.to_string()),
239            input: serde_json::json!({"path": "test.pdf"}),
240            mock_response: None,
241            visitor: None,
242            assertions: vec![],
243            source: source.to_string(),
244            http: None,
245        }
246    }
247
248    fn make_e2e_config(calls: Vec<(&str, CallConfig)>) -> E2eConfig {
249        E2eConfig {
250            calls: calls.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
251            ..Default::default()
252        }
253    }
254
255    #[test]
256    fn test_skip_all_languages_detected() {
257        let fixtures = vec![make_fixture(
258            "test_skipped",
259            "code/test.json",
260            Some(SkipDirective {
261                languages: vec![],
262                reason: Some("Requires feature X".to_string()),
263            }),
264            None,
265        )];
266        let config = make_e2e_config(vec![]);
267        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
268        assert!(errors.iter().any(|e| e.message.contains("skipped for all languages")));
269    }
270
271    #[test]
272    fn test_unknown_call_detected() {
273        let fixtures = vec![make_fixture("test_bad_call", "test.json", None, Some("nonexistent"))];
274        let config = make_e2e_config(vec![]);
275        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
276        assert!(errors.iter().any(|e| e.message.contains("unknown call 'nonexistent'")));
277    }
278
279    #[test]
280    fn test_known_call_not_flagged() {
281        let fixtures = vec![make_fixture("test_good_call", "test.json", None, Some("embed"))];
282        let config = make_e2e_config(vec![("embed", CallConfig::default())]);
283        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
284        assert!(!errors.iter().any(|e| e.message.contains("unknown call")));
285    }
286
287    #[test]
288    fn test_empty_category_detected() {
289        let fixtures = vec![
290            make_fixture(
291                "test_a",
292                "orphan/a.json",
293                Some(SkipDirective {
294                    languages: vec![],
295                    reason: Some("skip all".to_string()),
296                }),
297                None,
298            ),
299            make_fixture(
300                "test_b",
301                "orphan/b.json",
302                Some(SkipDirective {
303                    languages: vec![],
304                    reason: Some("skip all".to_string()),
305                }),
306                None,
307            ),
308        ];
309        let config = make_e2e_config(vec![]);
310        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
311        assert!(errors.iter().any(|e| e.message.contains("produces 0 test functions")));
312    }
313
314    #[test]
315    fn test_missing_required_input_field() {
316        let fixture = Fixture {
317            id: "test_missing".to_string(),
318            category: None,
319            description: "Test".to_string(),
320            tags: vec![],
321            skip: None,
322            call: Some("extract_bytes".to_string()),
323            input: serde_json::json!({"data": "abc"}), // missing "mime_type"
324            mock_response: None,
325            visitor: None,
326            assertions: vec![],
327            source: "test.json".to_string(),
328            http: None,
329        };
330        let call = CallConfig {
331            function: "extract_bytes".to_string(),
332            args: vec![
333                ArgMapping {
334                    name: "data".to_string(),
335                    field: "input.data".to_string(),
336                    arg_type: "bytes".to_string(),
337                    optional: false,
338                },
339                ArgMapping {
340                    name: "mime_type".to_string(),
341                    field: "input.mime_type".to_string(),
342                    arg_type: "string".to_string(),
343                    optional: false,
344                },
345            ],
346            ..Default::default()
347        };
348        let config = make_e2e_config(vec![("extract_bytes", call)]);
349        let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
350        assert!(
351            errors
352                .iter()
353                .any(|e| e.message.contains("missing required input field 'mime_type'"))
354        );
355    }
356
357    #[test]
358    fn test_no_errors_for_valid_fixture() {
359        let fixtures = vec![make_fixture("test_valid", "contract/test.json", None, None)];
360        let config = make_e2e_config(vec![]);
361        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
362        // Only check for errors/warnings beyond the expected "missing input" ones
363        // (default call config has no args, so no input field checks)
364        assert!(errors.is_empty());
365    }
366}