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