1use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MockResponse {
11 pub status: u16,
13 #[serde(default)]
15 pub body: Option<serde_json::Value>,
16 #[serde(default)]
19 pub stream_chunks: Option<Vec<serde_json::Value>>,
20 #[serde(default)]
23 pub headers: HashMap<String, String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisitorSpec {
29 pub callbacks: HashMap<String, CallbackAction>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "action")]
36pub enum CallbackAction {
37 #[serde(rename = "skip")]
39 Skip,
40 #[serde(rename = "continue")]
42 Continue,
43 #[serde(rename = "preserve_html")]
45 PreserveHtml,
46 #[serde(rename = "custom")]
48 Custom {
49 output: String,
51 },
52 #[serde(rename = "custom_template")]
54 CustomTemplate {
55 template: String,
57 },
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Fixture {
63 pub id: String,
65 #[serde(default)]
67 pub category: Option<String>,
68 pub description: String,
70 #[serde(default)]
72 pub tags: Vec<String>,
73 #[serde(default)]
75 pub skip: Option<SkipDirective>,
76 #[serde(default)]
79 pub call: Option<String>,
80 #[serde(default)]
82 pub input: serde_json::Value,
83 #[serde(default)]
85 pub mock_response: Option<MockResponse>,
86 #[serde(default)]
88 pub visitor: Option<VisitorSpec>,
89 #[serde(default)]
91 pub assertions: Vec<Assertion>,
92 #[serde(skip)]
94 pub source: String,
95 #[serde(default)]
98 pub http: Option<HttpFixture>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct HttpFixture {
104 pub handler: HttpHandler,
106 pub request: HttpRequest,
108 pub expected_response: HttpExpectedResponse,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HttpHandler {
115 pub route: String,
117 pub method: String,
119 #[serde(default)]
121 pub body_schema: Option<serde_json::Value>,
122 #[serde(default)]
124 pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
125 #[serde(default)]
127 pub middleware: Option<HttpMiddleware>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct HttpRequest {
133 pub method: String,
134 pub path: String,
135 #[serde(default)]
136 pub headers: HashMap<String, String>,
137 #[serde(default)]
138 pub query_params: HashMap<String, serde_json::Value>,
139 #[serde(default)]
140 pub cookies: HashMap<String, String>,
141 #[serde(default)]
142 pub body: Option<serde_json::Value>,
143 #[serde(default)]
144 pub content_type: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct HttpExpectedResponse {
150 pub status_code: u16,
151 #[serde(default)]
153 pub body: Option<serde_json::Value>,
154 #[serde(default)]
156 pub body_partial: Option<serde_json::Value>,
157 #[serde(default)]
159 pub headers: HashMap<String, String>,
160 #[serde(default)]
162 pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ValidationErrorExpectation {
168 pub loc: Vec<String>,
169 pub msg: String,
170 #[serde(rename = "type")]
171 pub error_type: String,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct CorsConfig {
177 #[serde(default)]
179 pub allow_origins: Vec<String>,
180 #[serde(default)]
182 pub allow_methods: Vec<String>,
183 #[serde(default)]
185 pub allow_headers: Vec<String>,
186 #[serde(default)]
188 pub expose_headers: Vec<String>,
189 #[serde(default)]
191 pub max_age: Option<u64>,
192 #[serde(default)]
194 pub allow_credentials: bool,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct StaticFile {
200 pub path: String,
202 pub content: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct StaticFilesConfig {
209 pub route_prefix: String,
211 #[serde(default)]
213 pub files: Vec<StaticFile>,
214 #[serde(default)]
216 pub index_file: bool,
217 #[serde(default)]
219 pub cache_control: Option<String>,
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct HttpMiddleware {
225 #[serde(default)]
226 pub jwt_auth: Option<serde_json::Value>,
227 #[serde(default)]
228 pub api_key_auth: Option<serde_json::Value>,
229 #[serde(default)]
230 pub compression: Option<serde_json::Value>,
231 #[serde(default)]
232 pub rate_limit: Option<serde_json::Value>,
233 #[serde(default)]
234 pub request_timeout: Option<serde_json::Value>,
235 #[serde(default)]
236 pub request_id: Option<serde_json::Value>,
237 #[serde(default)]
239 pub cors: Option<CorsConfig>,
240 #[serde(default)]
242 pub static_files: Option<Vec<StaticFilesConfig>>,
243}
244
245impl Fixture {
246 pub fn is_http_test(&self) -> bool {
248 self.http.is_some()
249 }
250
251 pub fn needs_mock_server(&self) -> bool {
255 self.mock_response.is_some() || self.http.is_some()
256 }
257
258 pub fn as_mock_response(&self) -> Option<MockResponse> {
264 if let Some(mock) = &self.mock_response {
265 return Some(mock.clone());
266 }
267 if let Some(http) = &self.http {
268 return Some(MockResponse {
269 status: http.expected_response.status_code,
270 body: http.expected_response.body.clone(),
271 stream_chunks: None,
272 headers: http.expected_response.headers.clone(),
273 });
274 }
275 None
276 }
277
278 pub fn is_streaming_mock(&self) -> bool {
280 self.mock_response
281 .as_ref()
282 .and_then(|m| m.stream_chunks.as_ref())
283 .map(|c| !c.is_empty())
284 .unwrap_or(false)
285 }
286
287 pub fn resolved_category(&self) -> String {
289 self.category.clone().unwrap_or_else(|| {
290 Path::new(&self.source)
291 .parent()
292 .and_then(|p| p.file_name())
293 .and_then(|n| n.to_str())
294 .unwrap_or("default")
295 .to_string()
296 })
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SkipDirective {
303 #[serde(default)]
305 pub languages: Vec<String>,
306 #[serde(default)]
308 pub reason: Option<String>,
309}
310
311impl SkipDirective {
312 pub fn should_skip(&self, language: &str) -> bool {
314 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Assertion {
321 #[serde(rename = "type")]
323 pub assertion_type: String,
324 #[serde(default)]
326 pub field: Option<String>,
327 #[serde(default)]
329 pub value: Option<serde_json::Value>,
330 #[serde(default)]
332 pub values: Option<Vec<serde_json::Value>>,
333 #[serde(default)]
335 pub method: Option<String>,
336 #[serde(default)]
338 pub check: Option<String>,
339 #[serde(default)]
341 pub args: Option<serde_json::Value>,
342}
343
344#[derive(Debug, Clone)]
346pub struct FixtureGroup {
347 pub category: String,
348 pub fixtures: Vec<Fixture>,
349}
350
351pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
353 let mut fixtures = Vec::new();
354 load_fixtures_recursive(dir, dir, &mut fixtures)?;
355
356 let mut seen: HashMap<String, String> = HashMap::new();
358 for f in &fixtures {
359 if let Some(prev_source) = seen.get(&f.id) {
360 bail!(
361 "duplicate fixture ID '{}': found in '{}' and '{}'",
362 f.id,
363 prev_source,
364 f.source
365 );
366 }
367 seen.insert(f.id.clone(), f.source.clone());
368 }
369
370 fixtures.sort_by(|a, b| {
372 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
373 cat_cmp.then_with(|| a.id.cmp(&b.id))
374 });
375
376 Ok(fixtures)
377}
378
379fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
380 let entries =
381 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
382
383 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
384 paths.sort();
385
386 for path in paths {
387 if path.is_dir() {
388 load_fixtures_recursive(base, &path, fixtures)?;
389 } else if path.extension().is_some_and(|ext| ext == "json") {
390 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
391 if filename == "schema.json" || filename.starts_with('_') {
393 continue;
394 }
395 let content = std::fs::read_to_string(&path)
396 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
397 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
398
399 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
401 serde_json::from_str(&content)
402 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
403 } else {
404 let single: Fixture = serde_json::from_str(&content)
405 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
406 vec![single]
407 };
408
409 for mut fixture in parsed {
410 fixture.source = relative.clone();
411 expand_json_templates(&mut fixture.input);
414 if let Some(ref mut http) = fixture.http {
415 for (_, v) in http.request.headers.iter_mut() {
416 *v = crate::escape::expand_fixture_templates(v);
417 }
418 if let Some(ref mut body) = http.request.body {
419 expand_json_templates(body);
420 }
421 }
422 fixtures.push(fixture);
423 }
424 }
425 }
426 Ok(())
427}
428
429pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
431 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
432 for f in fixtures {
433 groups.entry(f.resolved_category()).or_default().push(f.clone());
434 }
435 let mut result: Vec<FixtureGroup> = groups
436 .into_iter()
437 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
438 .collect();
439 result.sort_by(|a, b| a.category.cmp(&b.category));
440 result
441}
442
443fn expand_json_templates(value: &mut serde_json::Value) {
445 match value {
446 serde_json::Value::String(s) => {
447 let expanded = crate::escape::expand_fixture_templates(s);
448 if expanded != *s {
449 *s = expanded;
450 }
451 }
452 serde_json::Value::Array(arr) => {
453 for item in arr {
454 expand_json_templates(item);
455 }
456 }
457 serde_json::Value::Object(map) => {
458 for (_, v) in map.iter_mut() {
459 expand_json_templates(v);
460 }
461 }
462 _ => {}
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_fixture_with_mock_response() {
472 let json = r#"{
473 "id": "test_chat",
474 "description": "Test chat",
475 "call": "chat",
476 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
477 "mock_response": {
478 "status": 200,
479 "body": {"choices": [{"message": {"content": "hello"}}]}
480 },
481 "assertions": [{"type": "not_error"}]
482 }"#;
483 let fixture: Fixture = serde_json::from_str(json).unwrap();
484 assert!(fixture.needs_mock_server());
485 assert!(!fixture.is_streaming_mock());
486 assert_eq!(fixture.mock_response.unwrap().status, 200);
487 }
488
489 #[test]
490 fn test_fixture_with_streaming_mock_response() {
491 let json = r#"{
492 "id": "test_stream",
493 "description": "Test streaming",
494 "input": {},
495 "mock_response": {
496 "status": 200,
497 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
498 },
499 "assertions": []
500 }"#;
501 let fixture: Fixture = serde_json::from_str(json).unwrap();
502 assert!(fixture.needs_mock_server());
503 assert!(fixture.is_streaming_mock());
504 }
505
506 #[test]
507 fn test_fixture_without_mock_response() {
508 let json = r#"{
509 "id": "test_no_mock",
510 "description": "No mock",
511 "input": {},
512 "assertions": []
513 }"#;
514 let fixture: Fixture = serde_json::from_str(json).unwrap();
515 assert!(!fixture.needs_mock_server());
516 assert!(!fixture.is_streaming_mock());
517 }
518}