use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockResponse {
pub status: u16,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub stream_chunks: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub headers: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisitorSpec {
pub callbacks: HashMap<String, CallbackAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum CallbackAction {
#[serde(rename = "skip")]
Skip,
#[serde(rename = "continue")]
Continue,
#[serde(rename = "preserve_html")]
PreserveHtml,
#[serde(rename = "custom")]
Custom {
output: String,
},
#[serde(rename = "custom_template")]
CustomTemplate {
template: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureEnv {
#[serde(default)]
pub api_key_var: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fixture {
pub id: String,
#[serde(default)]
pub category: Option<String>,
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub skip: Option<SkipDirective>,
#[serde(default)]
pub env: Option<FixtureEnv>,
#[serde(default)]
pub call: Option<String>,
#[serde(default)]
pub input: serde_json::Value,
#[serde(default)]
pub mock_response: Option<MockResponse>,
#[serde(default)]
pub visitor: Option<VisitorSpec>,
#[serde(default)]
pub assertions: Vec<Assertion>,
#[serde(skip)]
pub source: String,
#[serde(default)]
pub http: Option<HttpFixture>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpFixture {
pub handler: HttpHandler,
pub request: HttpRequest,
pub expected_response: HttpExpectedResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpHandler {
pub route: String,
pub method: String,
#[serde(default)]
pub body_schema: Option<serde_json::Value>,
#[serde(default)]
pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
#[serde(default)]
pub middleware: Option<HttpMiddleware>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequest {
pub method: String,
pub path: String,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub query_params: HashMap<String, serde_json::Value>,
#[serde(default)]
pub cookies: HashMap<String, String>,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub content_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpExpectedResponse {
pub status_code: u16,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub body_partial: Option<serde_json::Value>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationErrorExpectation {
pub loc: Vec<String>,
pub msg: String,
#[serde(rename = "type")]
pub error_type: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CorsConfig {
#[serde(default)]
pub allow_origins: Vec<String>,
#[serde(default)]
pub allow_methods: Vec<String>,
#[serde(default)]
pub allow_headers: Vec<String>,
#[serde(default)]
pub expose_headers: Vec<String>,
#[serde(default)]
pub max_age: Option<u64>,
#[serde(default)]
pub allow_credentials: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFilesConfig {
pub route_prefix: String,
#[serde(default)]
pub files: Vec<StaticFile>,
#[serde(default)]
pub index_file: bool,
#[serde(default)]
pub cache_control: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HttpMiddleware {
#[serde(default)]
pub jwt_auth: Option<serde_json::Value>,
#[serde(default)]
pub api_key_auth: Option<serde_json::Value>,
#[serde(default)]
pub compression: Option<serde_json::Value>,
#[serde(default)]
pub rate_limit: Option<serde_json::Value>,
#[serde(default)]
pub request_timeout: Option<serde_json::Value>,
#[serde(default)]
pub request_id: Option<serde_json::Value>,
#[serde(default)]
pub cors: Option<CorsConfig>,
#[serde(default)]
pub static_files: Option<Vec<StaticFilesConfig>>,
}
impl Fixture {
pub fn is_http_test(&self) -> bool {
self.http.is_some()
}
pub fn needs_mock_server(&self) -> bool {
self.mock_response.is_some() || self.http.is_some()
}
pub fn as_mock_response(&self) -> Option<MockResponse> {
if let Some(mock) = &self.mock_response {
return Some(mock.clone());
}
if let Some(http) = &self.http {
return Some(MockResponse {
status: http.expected_response.status_code,
body: http.expected_response.body.clone(),
stream_chunks: None,
headers: http.expected_response.headers.clone(),
});
}
None
}
pub fn is_streaming_mock(&self) -> bool {
self.mock_response
.as_ref()
.and_then(|m| m.stream_chunks.as_ref())
.map(|c| !c.is_empty())
.unwrap_or(false)
}
pub fn resolved_category(&self) -> String {
self.category.clone().unwrap_or_else(|| {
Path::new(&self.source)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("default")
.to_string()
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkipDirective {
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub reason: Option<String>,
}
impl SkipDirective {
pub fn should_skip(&self, language: &str) -> bool {
self.languages.is_empty() || self.languages.iter().any(|l| l == language)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Assertion {
#[serde(rename = "type")]
pub assertion_type: String,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub value: Option<serde_json::Value>,
#[serde(default)]
pub values: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub check: Option<String>,
#[serde(default)]
pub args: Option<serde_json::Value>,
#[serde(default)]
pub return_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FixtureGroup {
pub category: String,
pub fixtures: Vec<Fixture>,
}
pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
let mut fixtures = Vec::new();
load_fixtures_recursive(dir, dir, &mut fixtures)?;
let mut seen: HashMap<String, String> = HashMap::new();
for f in &fixtures {
if let Some(prev_source) = seen.get(&f.id) {
bail!(
"duplicate fixture ID '{}': found in '{}' and '{}'",
f.id,
prev_source,
f.source
);
}
seen.insert(f.id.clone(), f.source.clone());
}
fixtures.sort_by(|a, b| {
let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
cat_cmp.then_with(|| a.id.cmp(&b.id))
});
Ok(fixtures)
}
fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
load_fixtures_recursive(base, &path, fixtures)?;
} else if path.extension().is_some_and(|ext| ext == "json") {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if filename == "schema.json" || filename.starts_with('_') {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read fixture: {}", path.display()))?;
let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
serde_json::from_str(&content)
.with_context(|| format!("failed to parse fixture array: {}", path.display()))?
} else {
let single: Fixture = serde_json::from_str(&content)
.with_context(|| format!("failed to parse fixture: {}", path.display()))?;
vec![single]
};
for mut fixture in parsed {
fixture.source = relative.clone();
expand_json_templates(&mut fixture.input);
if let Some(ref mut http) = fixture.http {
for (_, v) in http.request.headers.iter_mut() {
*v = crate::escape::expand_fixture_templates(v);
}
if let Some(ref mut body) = http.request.body {
expand_json_templates(body);
}
}
fixtures.push(fixture);
}
}
}
Ok(())
}
pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
for f in fixtures {
groups.entry(f.resolved_category()).or_default().push(f.clone());
}
let mut result: Vec<FixtureGroup> = groups
.into_iter()
.map(|(category, fixtures)| FixtureGroup { category, fixtures })
.collect();
result.sort_by(|a, b| a.category.cmp(&b.category));
result
}
fn expand_json_templates(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
let expanded = crate::escape::expand_fixture_templates(s);
if expanded != *s {
*s = expanded;
}
}
serde_json::Value::Array(arr) => {
for item in arr {
expand_json_templates(item);
}
}
serde_json::Value::Object(map) => {
for (_, v) in map.iter_mut() {
expand_json_templates(v);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixture_with_mock_response() {
let json = r#"{
"id": "test_chat",
"description": "Test chat",
"call": "chat",
"input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
"mock_response": {
"status": 200,
"body": {"choices": [{"message": {"content": "hello"}}]}
},
"assertions": [{"type": "not_error"}]
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.needs_mock_server());
assert!(!fixture.is_streaming_mock());
assert_eq!(fixture.mock_response.unwrap().status, 200);
}
#[test]
fn test_fixture_with_streaming_mock_response() {
let json = r#"{
"id": "test_stream",
"description": "Test streaming",
"input": {},
"mock_response": {
"status": 200,
"stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.needs_mock_server());
assert!(fixture.is_streaming_mock());
}
#[test]
fn test_fixture_without_mock_response() {
let json = r#"{
"id": "test_no_mock",
"description": "No mock",
"input": {},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(!fixture.needs_mock_server());
assert!(!fixture.is_streaming_mock());
}
}