1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Severity {
14 Error,
16 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#[derive(Debug, Clone)]
31pub struct ValidationError {
32 pub file: String,
34 pub message: String,
36 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
46pub 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
59pub 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 for fixture in fixtures {
75 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 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 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 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 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 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, Some(skip) => {
143 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 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"}), 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 assert!(errors.is_empty());
365 }
366}