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
66/// 5. (D1) Argument arity and type mismatches in call configs
67/// 6. (D2) Field path assertions against simple return types
68pub fn validate_fixtures_semantic(
69    fixtures: &[Fixture],
70    e2e_config: &E2eConfig,
71    languages: &[String],
72) -> Vec<ValidationError> {
73    let mut errors = Vec::new();
74
75    // Per-fixture checks
76    for fixture in fixtures {
77        // Check 1: skip-all detection
78        // Fixtures in excluded categories are intentionally excluded at the
79        // category level; empty skip.languages with no reason is the correct
80        // shape there. Do not warn for them.
81        if !e2e_config.exclude_categories.contains(&fixture.resolved_category()) {
82            if let Some(skip) = &fixture.skip {
83                if skip.languages.is_empty() {
84                    let reason = skip.reason.as_deref().unwrap_or("no reason given");
85                    errors.push(ValidationError {
86                        file: fixture.source.clone(),
87                        message: format!(
88                            "fixture '{}' is skipped for all languages (skip.languages is empty). Reason: {}",
89                            fixture.id, reason
90                        ),
91                        severity: Severity::Warning,
92                    });
93                }
94            }
95        }
96
97        // Check 2: unknown call reference
98        if let Some(call_name) = &fixture.call {
99            if !e2e_config.calls.contains_key(call_name) {
100                errors.push(ValidationError {
101                    file: fixture.source.clone(),
102                    message: format!(
103                        "fixture '{}' references unknown call '{}', will fall back to default [e2e.call]",
104                        fixture.id, call_name
105                    ),
106                    severity: Severity::Error,
107                });
108            }
109        }
110
111        // Check 4: missing required input fields
112        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
113        for arg in &call_config.args {
114            if arg.optional {
115                continue;
116            }
117            // When the arg's field is exactly the top-level "input" path (no dot),
118            // the whole fixture.input object IS the JSON value for that arg — no
119            // sub-key lookup applies. Only dotted paths like "input.foo" require a
120            // specific key to exist inside fixture.input.
121            if !arg.field.starts_with("input.") {
122                continue;
123            }
124            let input_field = arg.field.strip_prefix("input.").expect("starts_with checked above");
125            if !fixture.input.is_null() {
126                if let Some(obj) = fixture.input.as_object() {
127                    if !obj.contains_key(input_field) {
128                        // Skip check for error-type assertions (they may intentionally omit fields)
129                        let is_error_test = fixture.assertions.iter().any(|a| a.assertion_type == "error");
130                        if !is_error_test {
131                            errors.push(ValidationError {
132                                file: fixture.source.clone(),
133                                message: format!(
134                                    "fixture '{}' is missing required input field '{}' for call '{}'",
135                                    fixture.id,
136                                    input_field,
137                                    fixture.call.as_deref().unwrap_or("<default>")
138                                ),
139                                severity: Severity::Warning,
140                            });
141                        }
142                    }
143                }
144            }
145        }
146    }
147
148    // Check 3: empty categories (all fixtures skipped for all languages)
149    if !languages.is_empty() {
150        let groups = group_fixtures(fixtures);
151        for group in &groups {
152            // Categories explicitly excluded from cross-language codegen are
153            // expected to produce 0 test functions; do not warn.
154            if e2e_config.exclude_categories.contains(&group.category) {
155                continue;
156            }
157            let has_any_non_skipped = group.fixtures.iter().any(|f| {
158                match &f.skip {
159                    None => true, // no skip → will generate
160                    Some(skip) => {
161                        // At least one language is NOT skipped
162                        languages.iter().any(|lang| !skip.should_skip(lang))
163                    }
164                }
165            });
166
167            if !has_any_non_skipped {
168                errors.push(ValidationError {
169                    file: format!("{}/ (category)", group.category),
170                    message: format!(
171                        "category '{}' produces 0 test functions — all {} fixture(s) are skipped for all languages",
172                        group.category,
173                        group.fixtures.len()
174                    ),
175                    severity: Severity::Error,
176                });
177            }
178        }
179    }
180
181    errors
182}
183
184fn validate_recursive(
185    base: &Path,
186    dir: &Path,
187    validator: &jsonschema::Validator,
188    errors: &mut Vec<ValidationError>,
189) -> Result<()> {
190    let entries = std::fs::read_dir(dir).with_context(|| format!("failed to read directory: {}", dir.display()))?;
191
192    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
193    paths.sort();
194
195    for path in paths {
196        if path.is_dir() {
197            validate_recursive(base, &path, validator, errors)?;
198        } else if path.extension().is_some_and(|ext| ext == "json") {
199            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
200            // Skip schema files and files starting with _
201            if filename == "schema.json" || filename.starts_with('_') {
202                continue;
203            }
204
205            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
206
207            let content = match std::fs::read_to_string(&path) {
208                Ok(c) => c,
209                Err(e) => {
210                    errors.push(ValidationError {
211                        file: relative,
212                        message: format!("failed to read file: {e}"),
213                        severity: Severity::Error,
214                    });
215                    continue;
216                }
217            };
218
219            let value: serde_json::Value = match serde_json::from_str(&content) {
220                Ok(v) => v,
221                Err(e) => {
222                    errors.push(ValidationError {
223                        file: relative,
224                        message: format!("invalid JSON: {e}"),
225                        severity: Severity::Error,
226                    });
227                    continue;
228                }
229            };
230
231            for error in validator.iter_errors(&value) {
232                errors.push(ValidationError {
233                    file: relative.clone(),
234                    message: format!("{} at {}", error, error.instance_path()),
235                    severity: Severity::Error,
236                });
237            }
238        }
239    }
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::fixture::SkipDirective;
247    use alef_core::config::e2e::{ArgMapping, CallConfig};
248
249    fn make_fixture(id: &str, source: &str, skip: Option<SkipDirective>, call: Option<&str>) -> Fixture {
250        Fixture {
251            id: id.to_string(),
252            category: None,
253            description: format!("Test {id}"),
254            tags: vec![],
255            skip,
256            env: None,
257            call: call.map(|s| s.to_string()),
258            input: serde_json::json!({"path": "test.pdf"}),
259            mock_response: None,
260            visitor: None,
261            assertions: vec![],
262            source: source.to_string(),
263            http: None,
264        }
265    }
266
267    fn make_e2e_config(calls: Vec<(&str, CallConfig)>) -> E2eConfig {
268        E2eConfig {
269            calls: calls.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
270            ..Default::default()
271        }
272    }
273
274    #[test]
275    fn test_skip_all_languages_detected() {
276        let fixtures = vec![make_fixture(
277            "test_skipped",
278            "code/test.json",
279            Some(SkipDirective {
280                languages: vec![],
281                reason: Some("Requires feature X".to_string()),
282            }),
283            None,
284        )];
285        let config = make_e2e_config(vec![]);
286        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
287        assert!(errors.iter().any(|e| e.message.contains("skipped for all languages")));
288    }
289
290    #[test]
291    fn test_unknown_call_detected() {
292        let fixtures = vec![make_fixture("test_bad_call", "test.json", None, Some("nonexistent"))];
293        let config = make_e2e_config(vec![]);
294        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
295        assert!(errors.iter().any(|e| e.message.contains("unknown call 'nonexistent'")));
296    }
297
298    #[test]
299    fn test_known_call_not_flagged() {
300        let fixtures = vec![make_fixture("test_good_call", "test.json", None, Some("embed"))];
301        let config = make_e2e_config(vec![("embed", CallConfig::default())]);
302        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
303        assert!(!errors.iter().any(|e| e.message.contains("unknown call")));
304    }
305
306    #[test]
307    fn test_empty_category_detected() {
308        let fixtures = vec![
309            make_fixture(
310                "test_a",
311                "orphan/a.json",
312                Some(SkipDirective {
313                    languages: vec![],
314                    reason: Some("skip all".to_string()),
315                }),
316                None,
317            ),
318            make_fixture(
319                "test_b",
320                "orphan/b.json",
321                Some(SkipDirective {
322                    languages: vec![],
323                    reason: Some("skip all".to_string()),
324                }),
325                None,
326            ),
327        ];
328        let config = make_e2e_config(vec![]);
329        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
330        assert!(errors.iter().any(|e| e.message.contains("produces 0 test functions")));
331    }
332
333    #[test]
334    fn test_missing_required_input_field() {
335        let fixture = Fixture {
336            id: "test_missing".to_string(),
337            category: None,
338            description: "Test".to_string(),
339            tags: vec![],
340            skip: None,
341            env: None,
342            call: Some("extract_bytes".to_string()),
343            input: serde_json::json!({"data": "abc"}), // missing "mime_type"
344            mock_response: None,
345            visitor: None,
346            assertions: vec![],
347            source: "test.json".to_string(),
348            http: None,
349        };
350        let call = CallConfig {
351            function: "extract_bytes".to_string(),
352            args: vec![
353                ArgMapping {
354                    name: "data".to_string(),
355                    field: "input.data".to_string(),
356                    arg_type: "bytes".to_string(),
357                    optional: false,
358                    owned: false,
359                    element_type: None,
360                    go_type: None,
361                },
362                ArgMapping {
363                    name: "mime_type".to_string(),
364                    field: "input.mime_type".to_string(),
365                    arg_type: "string".to_string(),
366                    optional: false,
367                    owned: false,
368                    element_type: None,
369                    go_type: None,
370                },
371            ],
372            ..Default::default()
373        };
374        let config = make_e2e_config(vec![("extract_bytes", call)]);
375        let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
376        assert!(
377            errors
378                .iter()
379                .any(|e| e.message.contains("missing required input field 'mime_type'"))
380        );
381    }
382
383    #[test]
384    fn test_no_errors_for_valid_fixture() {
385        let fixtures = vec![make_fixture("test_valid", "contract/test.json", None, None)];
386        let config = make_e2e_config(vec![]);
387        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
388        // Only check for errors/warnings beyond the expected "missing input" ones
389        // (default call config has no args, so no input field checks)
390        assert!(errors.is_empty());
391    }
392
393    /// Bare `field = "input"` (no dot) must NOT emit a "missing required input
394    /// field 'input'" warning — the whole fixture.input IS the arg value.
395    #[test]
396    fn test_bare_input_field_no_false_positive_warning() {
397        use alef_core::config::e2e::ArgMapping;
398
399        let fixture = Fixture {
400            id: "basic_chat".to_string(),
401            category: None,
402            description: "Chat completion".to_string(),
403            tags: vec![],
404            skip: None,
405            env: None,
406            call: Some("chat".to_string()),
407            input: serde_json::json!({"model": "gpt-4", "messages": []}),
408            mock_response: None,
409            visitor: None,
410            assertions: vec![],
411            source: "smoke/basic_chat.json".to_string(),
412            http: None,
413        };
414        let call = CallConfig {
415            function: "chat".to_string(),
416            args: vec![ArgMapping {
417                name: "request".to_string(),
418                // Bare "input" — the whole fixture.input is the arg value
419                field: "input".to_string(),
420                arg_type: "ChatCompletionRequest".to_string(),
421                optional: false,
422                owned: true,
423                element_type: None,
424                go_type: None,
425            }],
426            ..Default::default()
427        };
428        let config = make_e2e_config(vec![("chat", call)]);
429        let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
430        assert!(
431            !errors
432                .iter()
433                .any(|e| e.message.contains("missing required input field 'input'")),
434            "bare 'input' field should not produce a false-positive missing-field warning; got: {:?}",
435            errors
436        );
437    }
438
439    /// A fixture in an excluded category with empty `skip.languages` must NOT
440    /// emit a "skipped for all languages" warning — the exclusion is intentional
441    /// at the category level.
442    #[test]
443    fn test_excluded_category_no_skip_all_warning() {
444        use std::collections::HashSet;
445
446        let fixture = Fixture {
447            id: "budget_enforced".to_string(),
448            category: None,
449            description: "Budget enforcement test".to_string(),
450            tags: vec![],
451            skip: Some(SkipDirective {
452                languages: vec![], // empty — would normally trigger the warning
453                reason: None,
454            }),
455            env: None,
456            call: Some("chat".to_string()),
457            input: serde_json::json!({"model": "gpt-4", "messages": []}),
458            mock_response: None,
459            visitor: None,
460            assertions: vec![],
461            // resolved_category() derives "budget" from this path
462            source: "budget/budget_enforced.json".to_string(),
463            http: None,
464        };
465        let mut config = make_e2e_config(vec![]);
466        config.exclude_categories = HashSet::from(["budget".to_string()]);
467        let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
468        assert!(
469            !errors.iter().any(|e| e.message.contains("skipped for all languages")),
470            "excluded-category fixture should not trigger skip-all warning; got: {:?}",
471            errors
472        );
473    }
474}