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}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct VisitorSpec {
25 pub callbacks: HashMap<String, CallbackAction>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "action")]
32pub enum CallbackAction {
33 #[serde(rename = "skip")]
35 Skip,
36 #[serde(rename = "continue")]
38 Continue,
39 #[serde(rename = "preserve_html")]
41 PreserveHtml,
42 #[serde(rename = "custom")]
44 Custom {
45 output: String,
47 },
48 #[serde(rename = "custom_template")]
50 CustomTemplate {
51 template: String,
53 },
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Fixture {
59 pub id: String,
61 #[serde(default)]
63 pub category: Option<String>,
64 pub description: String,
66 #[serde(default)]
68 pub tags: Vec<String>,
69 #[serde(default)]
71 pub skip: Option<SkipDirective>,
72 #[serde(default)]
75 pub call: Option<String>,
76 #[serde(default)]
78 pub input: serde_json::Value,
79 #[serde(default)]
81 pub mock_response: Option<MockResponse>,
82 #[serde(default)]
84 pub visitor: Option<VisitorSpec>,
85 #[serde(default)]
87 pub assertions: Vec<Assertion>,
88 #[serde(skip)]
90 pub source: String,
91 #[serde(default)]
94 pub http: Option<HttpFixture>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct HttpFixture {
100 pub handler: HttpHandler,
102 pub request: HttpRequest,
104 pub expected_response: HttpExpectedResponse,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct HttpHandler {
111 pub route: String,
113 pub method: String,
115 #[serde(default)]
117 pub body_schema: Option<serde_json::Value>,
118 #[serde(default)]
120 pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
121 #[serde(default)]
123 pub middleware: Option<HttpMiddleware>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct HttpRequest {
129 pub method: String,
130 pub path: String,
131 #[serde(default)]
132 pub headers: HashMap<String, String>,
133 #[serde(default)]
134 pub query_params: HashMap<String, serde_json::Value>,
135 #[serde(default)]
136 pub cookies: HashMap<String, String>,
137 #[serde(default)]
138 pub body: Option<serde_json::Value>,
139 #[serde(default)]
140 pub content_type: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct HttpExpectedResponse {
146 pub status_code: u16,
147 #[serde(default)]
149 pub body: Option<serde_json::Value>,
150 #[serde(default)]
152 pub body_partial: Option<serde_json::Value>,
153 #[serde(default)]
155 pub headers: HashMap<String, String>,
156 #[serde(default)]
158 pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ValidationErrorExpectation {
164 pub loc: Vec<String>,
165 pub msg: String,
166 #[serde(rename = "type")]
167 pub error_type: String,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct HttpMiddleware {
173 #[serde(default)]
174 pub jwt_auth: Option<serde_json::Value>,
175 #[serde(default)]
176 pub api_key_auth: Option<serde_json::Value>,
177 #[serde(default)]
178 pub compression: Option<serde_json::Value>,
179 #[serde(default)]
180 pub rate_limit: Option<serde_json::Value>,
181 #[serde(default)]
182 pub request_timeout: Option<serde_json::Value>,
183 #[serde(default)]
184 pub request_id: Option<serde_json::Value>,
185}
186
187impl Fixture {
188 pub fn is_http_test(&self) -> bool {
190 self.http.is_some()
191 }
192
193 pub fn needs_mock_server(&self) -> bool {
195 self.mock_response.is_some()
196 }
197
198 pub fn is_streaming_mock(&self) -> bool {
200 self.mock_response
201 .as_ref()
202 .and_then(|m| m.stream_chunks.as_ref())
203 .map(|c| !c.is_empty())
204 .unwrap_or(false)
205 }
206
207 pub fn resolved_category(&self) -> String {
209 self.category.clone().unwrap_or_else(|| {
210 Path::new(&self.source)
211 .parent()
212 .and_then(|p| p.file_name())
213 .and_then(|n| n.to_str())
214 .unwrap_or("default")
215 .to_string()
216 })
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct SkipDirective {
223 #[serde(default)]
225 pub languages: Vec<String>,
226 #[serde(default)]
228 pub reason: Option<String>,
229}
230
231impl SkipDirective {
232 pub fn should_skip(&self, language: &str) -> bool {
234 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Assertion {
241 #[serde(rename = "type")]
243 pub assertion_type: String,
244 #[serde(default)]
246 pub field: Option<String>,
247 #[serde(default)]
249 pub value: Option<serde_json::Value>,
250 #[serde(default)]
252 pub values: Option<Vec<serde_json::Value>>,
253 #[serde(default)]
255 pub method: Option<String>,
256 #[serde(default)]
258 pub check: Option<String>,
259 #[serde(default)]
261 pub args: Option<serde_json::Value>,
262}
263
264#[derive(Debug, Clone)]
266pub struct FixtureGroup {
267 pub category: String,
268 pub fixtures: Vec<Fixture>,
269}
270
271pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
273 let mut fixtures = Vec::new();
274 load_fixtures_recursive(dir, dir, &mut fixtures)?;
275
276 let mut seen: HashMap<String, String> = HashMap::new();
278 for f in &fixtures {
279 if let Some(prev_source) = seen.get(&f.id) {
280 bail!(
281 "duplicate fixture ID '{}': found in '{}' and '{}'",
282 f.id,
283 prev_source,
284 f.source
285 );
286 }
287 seen.insert(f.id.clone(), f.source.clone());
288 }
289
290 fixtures.sort_by(|a, b| {
292 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
293 cat_cmp.then_with(|| a.id.cmp(&b.id))
294 });
295
296 Ok(fixtures)
297}
298
299fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
300 let entries =
301 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
302
303 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
304 paths.sort();
305
306 for path in paths {
307 if path.is_dir() {
308 load_fixtures_recursive(base, &path, fixtures)?;
309 } else if path.extension().is_some_and(|ext| ext == "json") {
310 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
311 if filename == "schema.json" || filename.starts_with('_') {
313 continue;
314 }
315 let content = std::fs::read_to_string(&path)
316 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
317 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
318
319 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
321 serde_json::from_str(&content)
322 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
323 } else {
324 let single: Fixture = serde_json::from_str(&content)
325 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
326 vec![single]
327 };
328
329 for mut fixture in parsed {
330 fixture.source = relative.clone();
331 fixtures.push(fixture);
332 }
333 }
334 }
335 Ok(())
336}
337
338pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
340 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
341 for f in fixtures {
342 groups.entry(f.resolved_category()).or_default().push(f.clone());
343 }
344 let mut result: Vec<FixtureGroup> = groups
345 .into_iter()
346 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
347 .collect();
348 result.sort_by(|a, b| a.category.cmp(&b.category));
349 result
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_fixture_with_mock_response() {
358 let json = r#"{
359 "id": "test_chat",
360 "description": "Test chat",
361 "call": "chat",
362 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
363 "mock_response": {
364 "status": 200,
365 "body": {"choices": [{"message": {"content": "hello"}}]}
366 },
367 "assertions": [{"type": "not_error"}]
368 }"#;
369 let fixture: Fixture = serde_json::from_str(json).unwrap();
370 assert!(fixture.needs_mock_server());
371 assert!(!fixture.is_streaming_mock());
372 assert_eq!(fixture.mock_response.unwrap().status, 200);
373 }
374
375 #[test]
376 fn test_fixture_with_streaming_mock_response() {
377 let json = r#"{
378 "id": "test_stream",
379 "description": "Test streaming",
380 "input": {},
381 "mock_response": {
382 "status": 200,
383 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
384 },
385 "assertions": []
386 }"#;
387 let fixture: Fixture = serde_json::from_str(json).unwrap();
388 assert!(fixture.needs_mock_server());
389 assert!(fixture.is_streaming_mock());
390 }
391
392 #[test]
393 fn test_fixture_without_mock_response() {
394 let json = r#"{
395 "id": "test_no_mock",
396 "description": "No mock",
397 "input": {},
398 "assertions": []
399 }"#;
400 let fixture: Fixture = serde_json::from_str(json).unwrap();
401 assert!(!fixture.needs_mock_server());
402 assert!(!fixture.is_streaming_mock());
403 }
404}