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 Fixture {
25 pub id: String,
27 #[serde(default)]
29 pub category: Option<String>,
30 pub description: String,
32 #[serde(default)]
34 pub tags: Vec<String>,
35 #[serde(default)]
37 pub skip: Option<SkipDirective>,
38 #[serde(default)]
41 pub call: Option<String>,
42 #[serde(default)]
44 pub input: serde_json::Value,
45 #[serde(default)]
47 pub mock_response: Option<MockResponse>,
48 #[serde(default)]
50 pub assertions: Vec<Assertion>,
51 #[serde(skip)]
53 pub source: String,
54}
55
56impl Fixture {
57 pub fn needs_mock_server(&self) -> bool {
59 self.mock_response.is_some()
60 }
61
62 pub fn is_streaming_mock(&self) -> bool {
64 self.mock_response
65 .as_ref()
66 .and_then(|m| m.stream_chunks.as_ref())
67 .map(|c| !c.is_empty())
68 .unwrap_or(false)
69 }
70
71 pub fn resolved_category(&self) -> String {
73 self.category.clone().unwrap_or_else(|| {
74 Path::new(&self.source)
75 .parent()
76 .and_then(|p| p.file_name())
77 .and_then(|n| n.to_str())
78 .unwrap_or("default")
79 .to_string()
80 })
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct SkipDirective {
87 #[serde(default)]
89 pub languages: Vec<String>,
90 #[serde(default)]
92 pub reason: Option<String>,
93}
94
95impl SkipDirective {
96 pub fn should_skip(&self, language: &str) -> bool {
98 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Assertion {
105 #[serde(rename = "type")]
107 pub assertion_type: String,
108 #[serde(default)]
110 pub field: Option<String>,
111 #[serde(default)]
113 pub value: Option<serde_json::Value>,
114 #[serde(default)]
116 pub values: Option<Vec<serde_json::Value>>,
117}
118
119#[derive(Debug, Clone)]
121pub struct FixtureGroup {
122 pub category: String,
123 pub fixtures: Vec<Fixture>,
124}
125
126pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
128 let mut fixtures = Vec::new();
129 load_fixtures_recursive(dir, dir, &mut fixtures)?;
130
131 let mut seen: HashMap<String, String> = HashMap::new();
133 for f in &fixtures {
134 if let Some(prev_source) = seen.get(&f.id) {
135 bail!(
136 "duplicate fixture ID '{}': found in '{}' and '{}'",
137 f.id,
138 prev_source,
139 f.source
140 );
141 }
142 seen.insert(f.id.clone(), f.source.clone());
143 }
144
145 fixtures.sort_by(|a, b| {
147 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
148 cat_cmp.then_with(|| a.id.cmp(&b.id))
149 });
150
151 Ok(fixtures)
152}
153
154fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
155 let entries =
156 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
157
158 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
159 paths.sort();
160
161 for path in paths {
162 if path.is_dir() {
163 load_fixtures_recursive(base, &path, fixtures)?;
164 } else if path.extension().is_some_and(|ext| ext == "json") {
165 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
166 if filename == "schema.json" || filename.starts_with('_') {
168 continue;
169 }
170 let content = std::fs::read_to_string(&path)
171 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
172 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
173
174 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
176 serde_json::from_str(&content)
177 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
178 } else {
179 let single: Fixture = serde_json::from_str(&content)
180 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
181 vec![single]
182 };
183
184 for mut fixture in parsed {
185 fixture.source = relative.clone();
186 fixtures.push(fixture);
187 }
188 }
189 }
190 Ok(())
191}
192
193pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
195 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
196 for f in fixtures {
197 groups.entry(f.resolved_category()).or_default().push(f.clone());
198 }
199 let mut result: Vec<FixtureGroup> = groups
200 .into_iter()
201 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
202 .collect();
203 result.sort_by(|a, b| a.category.cmp(&b.category));
204 result
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_fixture_with_mock_response() {
213 let json = r#"{
214 "id": "test_chat",
215 "description": "Test chat",
216 "call": "chat",
217 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
218 "mock_response": {
219 "status": 200,
220 "body": {"choices": [{"message": {"content": "hello"}}]}
221 },
222 "assertions": [{"type": "not_error"}]
223 }"#;
224 let fixture: Fixture = serde_json::from_str(json).unwrap();
225 assert!(fixture.needs_mock_server());
226 assert!(!fixture.is_streaming_mock());
227 assert_eq!(fixture.mock_response.unwrap().status, 200);
228 }
229
230 #[test]
231 fn test_fixture_with_streaming_mock_response() {
232 let json = r#"{
233 "id": "test_stream",
234 "description": "Test streaming",
235 "input": {},
236 "mock_response": {
237 "status": 200,
238 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
239 },
240 "assertions": []
241 }"#;
242 let fixture: Fixture = serde_json::from_str(json).unwrap();
243 assert!(fixture.needs_mock_server());
244 assert!(fixture.is_streaming_mock());
245 }
246
247 #[test]
248 fn test_fixture_without_mock_response() {
249 let json = r#"{
250 "id": "test_no_mock",
251 "description": "No mock",
252 "input": {},
253 "assertions": []
254 }"#;
255 let fixture: Fixture = serde_json::from_str(json).unwrap();
256 assert!(!fixture.needs_mock_server());
257 assert!(!fixture.is_streaming_mock());
258 }
259}