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}
92
93impl Fixture {
94 pub fn needs_mock_server(&self) -> bool {
96 self.mock_response.is_some()
97 }
98
99 pub fn is_streaming_mock(&self) -> bool {
101 self.mock_response
102 .as_ref()
103 .and_then(|m| m.stream_chunks.as_ref())
104 .map(|c| !c.is_empty())
105 .unwrap_or(false)
106 }
107
108 pub fn resolved_category(&self) -> String {
110 self.category.clone().unwrap_or_else(|| {
111 Path::new(&self.source)
112 .parent()
113 .and_then(|p| p.file_name())
114 .and_then(|n| n.to_str())
115 .unwrap_or("default")
116 .to_string()
117 })
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SkipDirective {
124 #[serde(default)]
126 pub languages: Vec<String>,
127 #[serde(default)]
129 pub reason: Option<String>,
130}
131
132impl SkipDirective {
133 pub fn should_skip(&self, language: &str) -> bool {
135 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Assertion {
142 #[serde(rename = "type")]
144 pub assertion_type: String,
145 #[serde(default)]
147 pub field: Option<String>,
148 #[serde(default)]
150 pub value: Option<serde_json::Value>,
151 #[serde(default)]
153 pub values: Option<Vec<serde_json::Value>>,
154 #[serde(default)]
156 pub method: Option<String>,
157 #[serde(default)]
159 pub check: Option<String>,
160 #[serde(default)]
162 pub args: Option<serde_json::Value>,
163}
164
165#[derive(Debug, Clone)]
167pub struct FixtureGroup {
168 pub category: String,
169 pub fixtures: Vec<Fixture>,
170}
171
172pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
174 let mut fixtures = Vec::new();
175 load_fixtures_recursive(dir, dir, &mut fixtures)?;
176
177 let mut seen: HashMap<String, String> = HashMap::new();
179 for f in &fixtures {
180 if let Some(prev_source) = seen.get(&f.id) {
181 bail!(
182 "duplicate fixture ID '{}': found in '{}' and '{}'",
183 f.id,
184 prev_source,
185 f.source
186 );
187 }
188 seen.insert(f.id.clone(), f.source.clone());
189 }
190
191 fixtures.sort_by(|a, b| {
193 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
194 cat_cmp.then_with(|| a.id.cmp(&b.id))
195 });
196
197 Ok(fixtures)
198}
199
200fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
201 let entries =
202 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
203
204 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
205 paths.sort();
206
207 for path in paths {
208 if path.is_dir() {
209 load_fixtures_recursive(base, &path, fixtures)?;
210 } else if path.extension().is_some_and(|ext| ext == "json") {
211 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
212 if filename == "schema.json" || filename.starts_with('_') {
214 continue;
215 }
216 let content = std::fs::read_to_string(&path)
217 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
218 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
219
220 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
222 serde_json::from_str(&content)
223 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
224 } else {
225 let single: Fixture = serde_json::from_str(&content)
226 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
227 vec![single]
228 };
229
230 for mut fixture in parsed {
231 fixture.source = relative.clone();
232 fixtures.push(fixture);
233 }
234 }
235 }
236 Ok(())
237}
238
239pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
241 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
242 for f in fixtures {
243 groups.entry(f.resolved_category()).or_default().push(f.clone());
244 }
245 let mut result: Vec<FixtureGroup> = groups
246 .into_iter()
247 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
248 .collect();
249 result.sort_by(|a, b| a.category.cmp(&b.category));
250 result
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_fixture_with_mock_response() {
259 let json = r#"{
260 "id": "test_chat",
261 "description": "Test chat",
262 "call": "chat",
263 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
264 "mock_response": {
265 "status": 200,
266 "body": {"choices": [{"message": {"content": "hello"}}]}
267 },
268 "assertions": [{"type": "not_error"}]
269 }"#;
270 let fixture: Fixture = serde_json::from_str(json).unwrap();
271 assert!(fixture.needs_mock_server());
272 assert!(!fixture.is_streaming_mock());
273 assert_eq!(fixture.mock_response.unwrap().status, 200);
274 }
275
276 #[test]
277 fn test_fixture_with_streaming_mock_response() {
278 let json = r#"{
279 "id": "test_stream",
280 "description": "Test streaming",
281 "input": {},
282 "mock_response": {
283 "status": 200,
284 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
285 },
286 "assertions": []
287 }"#;
288 let fixture: Fixture = serde_json::from_str(json).unwrap();
289 assert!(fixture.needs_mock_server());
290 assert!(fixture.is_streaming_mock());
291 }
292
293 #[test]
294 fn test_fixture_without_mock_response() {
295 let json = r#"{
296 "id": "test_no_mock",
297 "description": "No mock",
298 "input": {},
299 "assertions": []
300 }"#;
301 let fixture: Fixture = serde_json::from_str(json).unwrap();
302 assert!(!fixture.needs_mock_server());
303 assert!(!fixture.is_streaming_mock());
304 }
305}