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