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 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, Some(skip) => {
150 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 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"}), 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 assert!(errors.is_empty());
380 }
381}