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        if let Some(skip) = &fixture.skip {
79            if skip.languages.is_empty() {
80                let reason = skip.reason.as_deref().unwrap_or("no reason given");
81                errors.push(ValidationError {
82                    file: fixture.source.clone(),
83                    message: format!(
84                        "fixture '{}' is skipped for all languages (skip.languages is empty). Reason: {}",
85                        fixture.id, reason
86                    ),
87                    severity: Severity::Warning,
88                });
89            }
90        }
91
92        // Check 2: unknown call reference
93        if let Some(call_name) = &fixture.call {
94            if !e2e_config.calls.contains_key(call_name) {
95                errors.push(ValidationError {
96                    file: fixture.source.clone(),
97                    message: format!(
98                        "fixture '{}' references unknown call '{}', will fall back to default [e2e.call]",
99                        fixture.id, call_name
100                    ),
101                    severity: Severity::Error,
102                });
103            }
104        }
105
106        // Check 4: missing required input fields
107        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
108        for arg in &call_config.args {
109            if arg.optional {
110                continue;
111            }
112            // Extract the input field name from the field path (e.g., "input.path" -> "path")
113            let input_field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
114            if !fixture.input.is_null() {
115                if let Some(obj) = fixture.input.as_object() {
116                    if !obj.contains_key(input_field) {
117                        // Skip check for error-type assertions (they may intentionally omit fields)
118                        let is_error_test = fixture.assertions.iter().any(|a| a.assertion_type == "error");
119                        if !is_error_test {
120                            errors.push(ValidationError {
121                                file: fixture.source.clone(),
122                                message: format!(
123                                    "fixture '{}' is missing required input field '{}' for call '{}'",
124                                    fixture.id,
125                                    input_field,
126                                    fixture.call.as_deref().unwrap_or("<default>")
127                                ),
128                                severity: Severity::Warning,
129                            });
130                        }
131                    }
132                }
133            }
134        }
135    }
136
137    // Check 3: empty categories (all fixtures skipped for all languages)
138    if !languages.is_empty() {
139        let groups = group_fixtures(fixtures);
140        for group in &groups {
141            let has_any_non_skipped = group.fixtures.iter().any(|f| {
142                match &f.skip {
143                    None => true, // no skip → will generate
144                    Some(skip) => {
145                        // At least one language is NOT skipped
146                        languages.iter().any(|lang| !skip.should_skip(lang))
147                    }
148                }
149            });
150
151            if !has_any_non_skipped {
152                errors.push(ValidationError {
153                    file: format!("{}/ (category)", group.category),
154                    message: format!(
155                        "category '{}' produces 0 test functions — all {} fixture(s) are skipped for all languages",
156                        group.category,
157                        group.fixtures.len()
158                    ),
159                    severity: Severity::Error,
160                });
161            }
162        }
163    }
164
165    errors
166}
167
168fn validate_recursive(
169    base: &Path,
170    dir: &Path,
171    validator: &jsonschema::Validator,
172    errors: &mut Vec<ValidationError>,
173) -> Result<()> {
174    let entries = std::fs::read_dir(dir).with_context(|| format!("failed to read directory: {}", dir.display()))?;
175
176    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
177    paths.sort();
178
179    for path in paths {
180        if path.is_dir() {
181            validate_recursive(base, &path, validator, errors)?;
182        } else if path.extension().is_some_and(|ext| ext == "json") {
183            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
184            // Skip schema files and files starting with _
185            if filename == "schema.json" || filename.starts_with('_') {
186                continue;
187            }
188
189            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
190
191            let content = match std::fs::read_to_string(&path) {
192                Ok(c) => c,
193                Err(e) => {
194                    errors.push(ValidationError {
195                        file: relative,
196                        message: format!("failed to read file: {e}"),
197                        severity: Severity::Error,
198                    });
199                    continue;
200                }
201            };
202
203            let value: serde_json::Value = match serde_json::from_str(&content) {
204                Ok(v) => v,
205                Err(e) => {
206                    errors.push(ValidationError {
207                        file: relative,
208                        message: format!("invalid JSON: {e}"),
209                        severity: Severity::Error,
210                    });
211                    continue;
212                }
213            };
214
215            for error in validator.iter_errors(&value) {
216                errors.push(ValidationError {
217                    file: relative.clone(),
218                    message: format!("{} at {}", error, error.instance_path()),
219                    severity: Severity::Error,
220                });
221            }
222        }
223    }
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::fixture::SkipDirective;
231    use alef_core::config::e2e::{ArgMapping, CallConfig};
232
233    fn make_fixture(id: &str, source: &str, skip: Option<SkipDirective>, call: Option<&str>) -> Fixture {
234        Fixture {
235            id: id.to_string(),
236            category: None,
237            description: format!("Test {id}"),
238            tags: vec![],
239            skip,
240            env: None,
241            call: call.map(|s| s.to_string()),
242            input: serde_json::json!({"path": "test.pdf"}),
243            mock_response: None,
244            visitor: None,
245            assertions: vec![],
246            source: source.to_string(),
247            http: None,
248        }
249    }
250
251    fn make_e2e_config(calls: Vec<(&str, CallConfig)>) -> E2eConfig {
252        E2eConfig {
253            calls: calls.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
254            ..Default::default()
255        }
256    }
257
258    #[test]
259    fn test_skip_all_languages_detected() {
260        let fixtures = vec![make_fixture(
261            "test_skipped",
262            "code/test.json",
263            Some(SkipDirective {
264                languages: vec![],
265                reason: Some("Requires feature X".to_string()),
266            }),
267            None,
268        )];
269        let config = make_e2e_config(vec![]);
270        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
271        assert!(errors.iter().any(|e| e.message.contains("skipped for all languages")));
272    }
273
274    #[test]
275    fn test_unknown_call_detected() {
276        let fixtures = vec![make_fixture("test_bad_call", "test.json", None, Some("nonexistent"))];
277        let config = make_e2e_config(vec![]);
278        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
279        assert!(errors.iter().any(|e| e.message.contains("unknown call 'nonexistent'")));
280    }
281
282    #[test]
283    fn test_known_call_not_flagged() {
284        let fixtures = vec![make_fixture("test_good_call", "test.json", None, Some("embed"))];
285        let config = make_e2e_config(vec![("embed", CallConfig::default())]);
286        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
287        assert!(!errors.iter().any(|e| e.message.contains("unknown call")));
288    }
289
290    #[test]
291    fn test_empty_category_detected() {
292        let fixtures = vec![
293            make_fixture(
294                "test_a",
295                "orphan/a.json",
296                Some(SkipDirective {
297                    languages: vec![],
298                    reason: Some("skip all".to_string()),
299                }),
300                None,
301            ),
302            make_fixture(
303                "test_b",
304                "orphan/b.json",
305                Some(SkipDirective {
306                    languages: vec![],
307                    reason: Some("skip all".to_string()),
308                }),
309                None,
310            ),
311        ];
312        let config = make_e2e_config(vec![]);
313        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
314        assert!(errors.iter().any(|e| e.message.contains("produces 0 test functions")));
315    }
316
317    #[test]
318    fn test_missing_required_input_field() {
319        let fixture = Fixture {
320            id: "test_missing".to_string(),
321            category: None,
322            description: "Test".to_string(),
323            tags: vec![],
324            skip: None,
325            env: None,
326            call: Some("extract_bytes".to_string()),
327            input: serde_json::json!({"data": "abc"}), // missing "mime_type"
328            mock_response: None,
329            visitor: None,
330            assertions: vec![],
331            source: "test.json".to_string(),
332            http: None,
333        };
334        let call = CallConfig {
335            function: "extract_bytes".to_string(),
336            args: vec![
337                ArgMapping {
338                    name: "data".to_string(),
339                    field: "input.data".to_string(),
340                    arg_type: "bytes".to_string(),
341                    optional: false,
342                    owned: false,
343                    element_type: None,
344                    go_type: None,
345                },
346                ArgMapping {
347                    name: "mime_type".to_string(),
348                    field: "input.mime_type".to_string(),
349                    arg_type: "string".to_string(),
350                    optional: false,
351                    owned: false,
352                    element_type: None,
353                    go_type: None,
354                },
355            ],
356            ..Default::default()
357        };
358        let config = make_e2e_config(vec![("extract_bytes", call)]);
359        let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
360        assert!(
361            errors
362                .iter()
363                .any(|e| e.message.contains("missing required input field 'mime_type'"))
364        );
365    }
366
367    #[test]
368    fn test_no_errors_for_valid_fixture() {
369        let fixtures = vec![make_fixture("test_valid", "contract/test.json", None, None)];
370        let config = make_e2e_config(vec![]);
371        let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
372        // Only check for errors/warnings beyond the expected "missing input" ones
373        // (default call config has no args, so no input field checks)
374        assert!(errors.is_empty());
375    }
376}