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(
69 fixtures: &[Fixture],
70 e2e_config: &E2eConfig,
71 languages: &[String],
72) -> Vec<ValidationError> {
73 let mut errors = Vec::new();
74
75 for fixture in fixtures {
77 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 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 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 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 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 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, Some(skip) => {
145 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 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 call: call.map(|s| s.to_string()),
241 input: serde_json::json!({"path": "test.pdf"}),
242 mock_response: None,
243 visitor: None,
244 assertions: vec![],
245 source: source.to_string(),
246 http: None,
247 }
248 }
249
250 fn make_e2e_config(calls: Vec<(&str, CallConfig)>) -> E2eConfig {
251 E2eConfig {
252 calls: calls.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
253 ..Default::default()
254 }
255 }
256
257 #[test]
258 fn test_skip_all_languages_detected() {
259 let fixtures = vec![make_fixture(
260 "test_skipped",
261 "code/test.json",
262 Some(SkipDirective {
263 languages: vec![],
264 reason: Some("Requires feature X".to_string()),
265 }),
266 None,
267 )];
268 let config = make_e2e_config(vec![]);
269 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
270 assert!(errors.iter().any(|e| e.message.contains("skipped for all languages")));
271 }
272
273 #[test]
274 fn test_unknown_call_detected() {
275 let fixtures = vec![make_fixture("test_bad_call", "test.json", None, Some("nonexistent"))];
276 let config = make_e2e_config(vec![]);
277 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
278 assert!(errors.iter().any(|e| e.message.contains("unknown call 'nonexistent'")));
279 }
280
281 #[test]
282 fn test_known_call_not_flagged() {
283 let fixtures = vec![make_fixture("test_good_call", "test.json", None, Some("embed"))];
284 let config = make_e2e_config(vec![("embed", CallConfig::default())]);
285 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
286 assert!(!errors.iter().any(|e| e.message.contains("unknown call")));
287 }
288
289 #[test]
290 fn test_empty_category_detected() {
291 let fixtures = vec![
292 make_fixture(
293 "test_a",
294 "orphan/a.json",
295 Some(SkipDirective {
296 languages: vec![],
297 reason: Some("skip all".to_string()),
298 }),
299 None,
300 ),
301 make_fixture(
302 "test_b",
303 "orphan/b.json",
304 Some(SkipDirective {
305 languages: vec![],
306 reason: Some("skip all".to_string()),
307 }),
308 None,
309 ),
310 ];
311 let config = make_e2e_config(vec![]);
312 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
313 assert!(errors.iter().any(|e| e.message.contains("produces 0 test functions")));
314 }
315
316 #[test]
317 fn test_missing_required_input_field() {
318 let fixture = Fixture {
319 id: "test_missing".to_string(),
320 category: None,
321 description: "Test".to_string(),
322 tags: vec![],
323 skip: None,
324 call: Some("extract_bytes".to_string()),
325 input: serde_json::json!({"data": "abc"}), mock_response: None,
327 visitor: None,
328 assertions: vec![],
329 source: "test.json".to_string(),
330 http: None,
331 };
332 let call = CallConfig {
333 function: "extract_bytes".to_string(),
334 args: vec![
335 ArgMapping {
336 name: "data".to_string(),
337 field: "input.data".to_string(),
338 arg_type: "bytes".to_string(),
339 optional: false,
340 owned: false,
341 element_type: None,
342 },
343 ArgMapping {
344 name: "mime_type".to_string(),
345 field: "input.mime_type".to_string(),
346 arg_type: "string".to_string(),
347 optional: false,
348 owned: false,
349 element_type: None,
350 },
351 ],
352 ..Default::default()
353 };
354 let config = make_e2e_config(vec![("extract_bytes", call)]);
355 let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
356 assert!(
357 errors
358 .iter()
359 .any(|e| e.message.contains("missing required input field 'mime_type'"))
360 );
361 }
362
363 #[test]
364 fn test_no_errors_for_valid_fixture() {
365 let fixtures = vec![make_fixture("test_valid", "contract/test.json", None, None)];
366 let config = make_e2e_config(vec![]);
367 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
368 assert!(errors.is_empty());
371 }
372}