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 !e2e_config.exclude_categories.contains(&fixture.resolved_category()) {
82 if let Some(skip) = &fixture.skip {
83 if skip.languages.is_empty() {
84 let reason = skip.reason.as_deref().unwrap_or("no reason given");
85 errors.push(ValidationError {
86 file: fixture.source.clone(),
87 message: format!(
88 "fixture '{}' is skipped for all languages (skip.languages is empty). Reason: {}",
89 fixture.id, reason
90 ),
91 severity: Severity::Warning,
92 });
93 }
94 }
95 }
96
97 if let Some(call_name) = &fixture.call {
99 if !e2e_config.calls.contains_key(call_name) {
100 errors.push(ValidationError {
101 file: fixture.source.clone(),
102 message: format!(
103 "fixture '{}' references unknown call '{}', will fall back to default [e2e.call]",
104 fixture.id, call_name
105 ),
106 severity: Severity::Error,
107 });
108 }
109 }
110
111 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
113 for arg in &call_config.args {
114 if arg.optional {
115 continue;
116 }
117 if !arg.field.starts_with("input.") {
122 continue;
123 }
124 let input_field = arg.field.strip_prefix("input.").expect("starts_with checked above");
125 if !fixture.input.is_null() {
126 if let Some(obj) = fixture.input.as_object() {
127 if !obj.contains_key(input_field) {
128 let is_error_test = fixture.assertions.iter().any(|a| a.assertion_type == "error");
130 if !is_error_test {
131 errors.push(ValidationError {
132 file: fixture.source.clone(),
133 message: format!(
134 "fixture '{}' is missing required input field '{}' for call '{}'",
135 fixture.id,
136 input_field,
137 fixture.call.as_deref().unwrap_or("<default>")
138 ),
139 severity: Severity::Warning,
140 });
141 }
142 }
143 }
144 }
145 }
146 }
147
148 if !languages.is_empty() {
150 let groups = group_fixtures(fixtures);
151 for group in &groups {
152 if e2e_config.exclude_categories.contains(&group.category) {
155 continue;
156 }
157 let has_any_non_skipped = group.fixtures.iter().any(|f| {
158 match &f.skip {
159 None => true, Some(skip) => {
161 languages.iter().any(|lang| !skip.should_skip(lang))
163 }
164 }
165 });
166
167 if !has_any_non_skipped {
168 errors.push(ValidationError {
169 file: format!("{}/ (category)", group.category),
170 message: format!(
171 "category '{}' produces 0 test functions — all {} fixture(s) are skipped for all languages",
172 group.category,
173 group.fixtures.len()
174 ),
175 severity: Severity::Error,
176 });
177 }
178 }
179 }
180
181 errors
182}
183
184fn validate_recursive(
185 base: &Path,
186 dir: &Path,
187 validator: &jsonschema::Validator,
188 errors: &mut Vec<ValidationError>,
189) -> Result<()> {
190 let entries = std::fs::read_dir(dir).with_context(|| format!("failed to read directory: {}", dir.display()))?;
191
192 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
193 paths.sort();
194
195 for path in paths {
196 if path.is_dir() {
197 validate_recursive(base, &path, validator, errors)?;
198 } else if path.extension().is_some_and(|ext| ext == "json") {
199 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
200 if filename == "schema.json" || filename.starts_with('_') {
202 continue;
203 }
204
205 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
206
207 let content = match std::fs::read_to_string(&path) {
208 Ok(c) => c,
209 Err(e) => {
210 errors.push(ValidationError {
211 file: relative,
212 message: format!("failed to read file: {e}"),
213 severity: Severity::Error,
214 });
215 continue;
216 }
217 };
218
219 let value: serde_json::Value = match serde_json::from_str(&content) {
220 Ok(v) => v,
221 Err(e) => {
222 errors.push(ValidationError {
223 file: relative,
224 message: format!("invalid JSON: {e}"),
225 severity: Severity::Error,
226 });
227 continue;
228 }
229 };
230
231 for error in validator.iter_errors(&value) {
232 errors.push(ValidationError {
233 file: relative.clone(),
234 message: format!("{} at {}", error, error.instance_path()),
235 severity: Severity::Error,
236 });
237 }
238 }
239 }
240 Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::fixture::SkipDirective;
247 use alef_core::config::e2e::{ArgMapping, CallConfig};
248
249 fn make_fixture(id: &str, source: &str, skip: Option<SkipDirective>, call: Option<&str>) -> Fixture {
250 Fixture {
251 id: id.to_string(),
252 category: None,
253 description: format!("Test {id}"),
254 tags: vec![],
255 skip,
256 env: None,
257 call: call.map(|s| s.to_string()),
258 input: serde_json::json!({"path": "test.pdf"}),
259 mock_response: None,
260 visitor: None,
261 assertions: vec![],
262 source: source.to_string(),
263 http: None,
264 }
265 }
266
267 fn make_e2e_config(calls: Vec<(&str, CallConfig)>) -> E2eConfig {
268 E2eConfig {
269 calls: calls.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
270 ..Default::default()
271 }
272 }
273
274 #[test]
275 fn test_skip_all_languages_detected() {
276 let fixtures = vec![make_fixture(
277 "test_skipped",
278 "code/test.json",
279 Some(SkipDirective {
280 languages: vec![],
281 reason: Some("Requires feature X".to_string()),
282 }),
283 None,
284 )];
285 let config = make_e2e_config(vec![]);
286 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
287 assert!(errors.iter().any(|e| e.message.contains("skipped for all languages")));
288 }
289
290 #[test]
291 fn test_unknown_call_detected() {
292 let fixtures = vec![make_fixture("test_bad_call", "test.json", None, Some("nonexistent"))];
293 let config = make_e2e_config(vec![]);
294 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
295 assert!(errors.iter().any(|e| e.message.contains("unknown call 'nonexistent'")));
296 }
297
298 #[test]
299 fn test_known_call_not_flagged() {
300 let fixtures = vec![make_fixture("test_good_call", "test.json", None, Some("embed"))];
301 let config = make_e2e_config(vec![("embed", CallConfig::default())]);
302 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
303 assert!(!errors.iter().any(|e| e.message.contains("unknown call")));
304 }
305
306 #[test]
307 fn test_empty_category_detected() {
308 let fixtures = vec![
309 make_fixture(
310 "test_a",
311 "orphan/a.json",
312 Some(SkipDirective {
313 languages: vec![],
314 reason: Some("skip all".to_string()),
315 }),
316 None,
317 ),
318 make_fixture(
319 "test_b",
320 "orphan/b.json",
321 Some(SkipDirective {
322 languages: vec![],
323 reason: Some("skip all".to_string()),
324 }),
325 None,
326 ),
327 ];
328 let config = make_e2e_config(vec![]);
329 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
330 assert!(errors.iter().any(|e| e.message.contains("produces 0 test functions")));
331 }
332
333 #[test]
334 fn test_missing_required_input_field() {
335 let fixture = Fixture {
336 id: "test_missing".to_string(),
337 category: None,
338 description: "Test".to_string(),
339 tags: vec![],
340 skip: None,
341 env: None,
342 call: Some("extract_bytes".to_string()),
343 input: serde_json::json!({"data": "abc"}), mock_response: None,
345 visitor: None,
346 assertions: vec![],
347 source: "test.json".to_string(),
348 http: None,
349 };
350 let call = CallConfig {
351 function: "extract_bytes".to_string(),
352 args: vec![
353 ArgMapping {
354 name: "data".to_string(),
355 field: "input.data".to_string(),
356 arg_type: "bytes".to_string(),
357 optional: false,
358 owned: false,
359 element_type: None,
360 go_type: None,
361 },
362 ArgMapping {
363 name: "mime_type".to_string(),
364 field: "input.mime_type".to_string(),
365 arg_type: "string".to_string(),
366 optional: false,
367 owned: false,
368 element_type: None,
369 go_type: None,
370 },
371 ],
372 ..Default::default()
373 };
374 let config = make_e2e_config(vec![("extract_bytes", call)]);
375 let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
376 assert!(
377 errors
378 .iter()
379 .any(|e| e.message.contains("missing required input field 'mime_type'"))
380 );
381 }
382
383 #[test]
384 fn test_no_errors_for_valid_fixture() {
385 let fixtures = vec![make_fixture("test_valid", "contract/test.json", None, None)];
386 let config = make_e2e_config(vec![]);
387 let errors = validate_fixtures_semantic(&fixtures, &config, &["rust".to_string()]);
388 assert!(errors.is_empty());
391 }
392
393 #[test]
396 fn test_bare_input_field_no_false_positive_warning() {
397 use alef_core::config::e2e::ArgMapping;
398
399 let fixture = Fixture {
400 id: "basic_chat".to_string(),
401 category: None,
402 description: "Chat completion".to_string(),
403 tags: vec![],
404 skip: None,
405 env: None,
406 call: Some("chat".to_string()),
407 input: serde_json::json!({"model": "gpt-4", "messages": []}),
408 mock_response: None,
409 visitor: None,
410 assertions: vec![],
411 source: "smoke/basic_chat.json".to_string(),
412 http: None,
413 };
414 let call = CallConfig {
415 function: "chat".to_string(),
416 args: vec![ArgMapping {
417 name: "request".to_string(),
418 field: "input".to_string(),
420 arg_type: "ChatCompletionRequest".to_string(),
421 optional: false,
422 owned: true,
423 element_type: None,
424 go_type: None,
425 }],
426 ..Default::default()
427 };
428 let config = make_e2e_config(vec![("chat", call)]);
429 let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
430 assert!(
431 !errors
432 .iter()
433 .any(|e| e.message.contains("missing required input field 'input'")),
434 "bare 'input' field should not produce a false-positive missing-field warning; got: {:?}",
435 errors
436 );
437 }
438
439 #[test]
443 fn test_excluded_category_no_skip_all_warning() {
444 use std::collections::HashSet;
445
446 let fixture = Fixture {
447 id: "budget_enforced".to_string(),
448 category: None,
449 description: "Budget enforcement test".to_string(),
450 tags: vec![],
451 skip: Some(SkipDirective {
452 languages: vec![], reason: None,
454 }),
455 env: None,
456 call: Some("chat".to_string()),
457 input: serde_json::json!({"model": "gpt-4", "messages": []}),
458 mock_response: None,
459 visitor: None,
460 assertions: vec![],
461 source: "budget/budget_enforced.json".to_string(),
463 http: None,
464 };
465 let mut config = make_e2e_config(vec![]);
466 config.exclude_categories = HashSet::from(["budget".to_string()]);
467 let errors = validate_fixtures_semantic(&[fixture], &config, &["rust".to_string()]);
468 assert!(
469 !errors.iter().any(|e| e.message.contains("skipped for all languages")),
470 "excluded-category fixture should not trigger skip-all warning; got: {:?}",
471 errors
472 );
473 }
474}