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 HttpMiddleware {
177 #[serde(default)]
178 pub jwt_auth: Option<serde_json::Value>,
179 #[serde(default)]
180 pub api_key_auth: Option<serde_json::Value>,
181 #[serde(default)]
182 pub compression: Option<serde_json::Value>,
183 #[serde(default)]
184 pub rate_limit: Option<serde_json::Value>,
185 #[serde(default)]
186 pub request_timeout: Option<serde_json::Value>,
187 #[serde(default)]
188 pub request_id: Option<serde_json::Value>,
189}
190
191impl Fixture {
192 pub fn is_http_test(&self) -> bool {
194 self.http.is_some()
195 }
196
197 pub fn needs_mock_server(&self) -> bool {
201 self.mock_response.is_some() || self.http.is_some()
202 }
203
204 pub fn as_mock_response(&self) -> Option<MockResponse> {
210 if let Some(mock) = &self.mock_response {
211 return Some(mock.clone());
212 }
213 if let Some(http) = &self.http {
214 return Some(MockResponse {
215 status: http.expected_response.status_code,
216 body: http.expected_response.body.clone(),
217 stream_chunks: None,
218 headers: http.expected_response.headers.clone(),
219 });
220 }
221 None
222 }
223
224 pub fn is_streaming_mock(&self) -> bool {
226 self.mock_response
227 .as_ref()
228 .and_then(|m| m.stream_chunks.as_ref())
229 .map(|c| !c.is_empty())
230 .unwrap_or(false)
231 }
232
233 pub fn resolved_category(&self) -> String {
235 self.category.clone().unwrap_or_else(|| {
236 Path::new(&self.source)
237 .parent()
238 .and_then(|p| p.file_name())
239 .and_then(|n| n.to_str())
240 .unwrap_or("default")
241 .to_string()
242 })
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SkipDirective {
249 #[serde(default)]
251 pub languages: Vec<String>,
252 #[serde(default)]
254 pub reason: Option<String>,
255}
256
257impl SkipDirective {
258 pub fn should_skip(&self, language: &str) -> bool {
260 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Assertion {
267 #[serde(rename = "type")]
269 pub assertion_type: String,
270 #[serde(default)]
272 pub field: Option<String>,
273 #[serde(default)]
275 pub value: Option<serde_json::Value>,
276 #[serde(default)]
278 pub values: Option<Vec<serde_json::Value>>,
279 #[serde(default)]
281 pub method: Option<String>,
282 #[serde(default)]
284 pub check: Option<String>,
285 #[serde(default)]
287 pub args: Option<serde_json::Value>,
288}
289
290#[derive(Debug, Clone)]
292pub struct FixtureGroup {
293 pub category: String,
294 pub fixtures: Vec<Fixture>,
295}
296
297pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
299 let mut fixtures = Vec::new();
300 load_fixtures_recursive(dir, dir, &mut fixtures)?;
301
302 let mut seen: HashMap<String, String> = HashMap::new();
304 for f in &fixtures {
305 if let Some(prev_source) = seen.get(&f.id) {
306 bail!(
307 "duplicate fixture ID '{}': found in '{}' and '{}'",
308 f.id,
309 prev_source,
310 f.source
311 );
312 }
313 seen.insert(f.id.clone(), f.source.clone());
314 }
315
316 fixtures.sort_by(|a, b| {
318 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
319 cat_cmp.then_with(|| a.id.cmp(&b.id))
320 });
321
322 Ok(fixtures)
323}
324
325fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
326 let entries =
327 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
328
329 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
330 paths.sort();
331
332 for path in paths {
333 if path.is_dir() {
334 load_fixtures_recursive(base, &path, fixtures)?;
335 } else if path.extension().is_some_and(|ext| ext == "json") {
336 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
337 if filename == "schema.json" || filename.starts_with('_') {
339 continue;
340 }
341 let content = std::fs::read_to_string(&path)
342 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
343 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
344
345 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
347 serde_json::from_str(&content)
348 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
349 } else {
350 let single: Fixture = serde_json::from_str(&content)
351 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
352 vec![single]
353 };
354
355 for mut fixture in parsed {
356 fixture.source = relative.clone();
357 expand_json_templates(&mut fixture.input);
360 if let Some(ref mut http) = fixture.http {
361 for (_, v) in http.request.headers.iter_mut() {
362 *v = crate::escape::expand_fixture_templates(v);
363 }
364 if let Some(ref mut body) = http.request.body {
365 expand_json_templates(body);
366 }
367 }
368 fixtures.push(fixture);
369 }
370 }
371 }
372 Ok(())
373}
374
375pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
377 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
378 for f in fixtures {
379 groups.entry(f.resolved_category()).or_default().push(f.clone());
380 }
381 let mut result: Vec<FixtureGroup> = groups
382 .into_iter()
383 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
384 .collect();
385 result.sort_by(|a, b| a.category.cmp(&b.category));
386 result
387}
388
389fn expand_json_templates(value: &mut serde_json::Value) {
391 match value {
392 serde_json::Value::String(s) => {
393 let expanded = crate::escape::expand_fixture_templates(s);
394 if expanded != *s {
395 *s = expanded;
396 }
397 }
398 serde_json::Value::Array(arr) => {
399 for item in arr {
400 expand_json_templates(item);
401 }
402 }
403 serde_json::Value::Object(map) => {
404 for (_, v) in map.iter_mut() {
405 expand_json_templates(v);
406 }
407 }
408 _ => {}
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_fixture_with_mock_response() {
418 let json = r#"{
419 "id": "test_chat",
420 "description": "Test chat",
421 "call": "chat",
422 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
423 "mock_response": {
424 "status": 200,
425 "body": {"choices": [{"message": {"content": "hello"}}]}
426 },
427 "assertions": [{"type": "not_error"}]
428 }"#;
429 let fixture: Fixture = serde_json::from_str(json).unwrap();
430 assert!(fixture.needs_mock_server());
431 assert!(!fixture.is_streaming_mock());
432 assert_eq!(fixture.mock_response.unwrap().status, 200);
433 }
434
435 #[test]
436 fn test_fixture_with_streaming_mock_response() {
437 let json = r#"{
438 "id": "test_stream",
439 "description": "Test streaming",
440 "input": {},
441 "mock_response": {
442 "status": 200,
443 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
444 },
445 "assertions": []
446 }"#;
447 let fixture: Fixture = serde_json::from_str(json).unwrap();
448 assert!(fixture.needs_mock_server());
449 assert!(fixture.is_streaming_mock());
450 }
451
452 #[test]
453 fn test_fixture_without_mock_response() {
454 let json = r#"{
455 "id": "test_no_mock",
456 "description": "No mock",
457 "input": {},
458 "assertions": []
459 }"#;
460 let fixture: Fixture = serde_json::from_str(json).unwrap();
461 assert!(!fixture.needs_mock_server());
462 assert!(!fixture.is_streaming_mock());
463 }
464}