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 FixtureEnv {
63 #[serde(default)]
65 pub api_key_var: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Fixture {
71 pub id: String,
73 #[serde(default)]
75 pub category: Option<String>,
76 pub description: String,
78 #[serde(default)]
80 pub tags: Vec<String>,
81 #[serde(default)]
83 pub skip: Option<SkipDirective>,
84 #[serde(default)]
86 pub env: Option<FixtureEnv>,
87 #[serde(default)]
90 pub call: Option<String>,
91 #[serde(default)]
93 pub input: serde_json::Value,
94 #[serde(default)]
96 pub mock_response: Option<MockResponse>,
97 #[serde(default)]
99 pub visitor: Option<VisitorSpec>,
100 #[serde(default)]
102 pub assertions: Vec<Assertion>,
103 #[serde(skip)]
105 pub source: String,
106 #[serde(default)]
109 pub http: Option<HttpFixture>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HttpFixture {
115 pub handler: HttpHandler,
117 pub request: HttpRequest,
119 pub expected_response: HttpExpectedResponse,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct HttpHandler {
126 pub route: String,
128 pub method: String,
130 #[serde(default)]
132 pub body_schema: Option<serde_json::Value>,
133 #[serde(default)]
135 pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
136 #[serde(default)]
138 pub middleware: Option<HttpMiddleware>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct HttpRequest {
144 pub method: String,
145 pub path: String,
146 #[serde(default)]
147 pub headers: HashMap<String, String>,
148 #[serde(default)]
149 pub query_params: HashMap<String, serde_json::Value>,
150 #[serde(default)]
151 pub cookies: HashMap<String, String>,
152 #[serde(default)]
153 pub body: Option<serde_json::Value>,
154 #[serde(default)]
155 pub content_type: Option<String>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct HttpExpectedResponse {
161 pub status_code: u16,
162 #[serde(default)]
164 pub body: Option<serde_json::Value>,
165 #[serde(default)]
167 pub body_partial: Option<serde_json::Value>,
168 #[serde(default)]
170 pub headers: HashMap<String, String>,
171 #[serde(default)]
173 pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ValidationErrorExpectation {
179 pub loc: Vec<String>,
180 pub msg: String,
181 #[serde(rename = "type")]
182 pub error_type: String,
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct CorsConfig {
188 #[serde(default)]
190 pub allow_origins: Vec<String>,
191 #[serde(default)]
193 pub allow_methods: Vec<String>,
194 #[serde(default)]
196 pub allow_headers: Vec<String>,
197 #[serde(default)]
199 pub expose_headers: Vec<String>,
200 #[serde(default)]
202 pub max_age: Option<u64>,
203 #[serde(default)]
205 pub allow_credentials: bool,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct StaticFile {
211 pub path: String,
213 pub content: String,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct StaticFilesConfig {
220 pub route_prefix: String,
222 #[serde(default)]
224 pub files: Vec<StaticFile>,
225 #[serde(default)]
227 pub index_file: bool,
228 #[serde(default)]
230 pub cache_control: Option<String>,
231}
232
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct HttpMiddleware {
236 #[serde(default)]
237 pub jwt_auth: Option<serde_json::Value>,
238 #[serde(default)]
239 pub api_key_auth: Option<serde_json::Value>,
240 #[serde(default)]
241 pub compression: Option<serde_json::Value>,
242 #[serde(default)]
243 pub rate_limit: Option<serde_json::Value>,
244 #[serde(default)]
245 pub request_timeout: Option<serde_json::Value>,
246 #[serde(default)]
247 pub request_id: Option<serde_json::Value>,
248 #[serde(default)]
250 pub cors: Option<CorsConfig>,
251 #[serde(default)]
253 pub static_files: Option<Vec<StaticFilesConfig>>,
254}
255
256fn is_host_root_path(path: &str) -> bool {
260 path.starts_with("/robots") || path.starts_with("/sitemap")
261}
262
263impl Fixture {
264 pub fn is_http_test(&self) -> bool {
266 self.http.is_some()
267 }
268
269 pub fn needs_mock_server(&self) -> bool {
274 if self.mock_response.is_some() || self.http.is_some() {
275 return true;
276 }
277 self.input
279 .get("mock_responses")
280 .and_then(|v| v.as_array())
281 .map(|arr| !arr.is_empty())
282 .unwrap_or(false)
283 }
284
285 pub fn as_mock_response(&self) -> Option<MockResponse> {
291 if let Some(mock) = &self.mock_response {
292 return Some(mock.clone());
293 }
294 if let Some(http) = &self.http {
295 return Some(MockResponse {
296 status: http.expected_response.status_code,
297 body: http.expected_response.body.clone(),
298 stream_chunks: None,
299 headers: http.expected_response.headers.clone(),
300 });
301 }
302 None
303 }
304
305 pub fn is_streaming_mock(&self) -> bool {
307 self.mock_response
308 .as_ref()
309 .and_then(|m| m.stream_chunks.as_ref())
310 .map(|c| !c.is_empty())
311 .unwrap_or(false)
312 }
313
314 pub fn has_host_root_route(&self) -> bool {
322 if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
324 if arr.iter().any(|entry| {
326 entry
327 .get("path")
328 .and_then(|v| v.as_str())
329 .map(is_host_root_path)
330 .unwrap_or(false)
331 }) {
332 return true;
333 }
334 return arr.iter().any(|entry| {
342 let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
343 let headers = entry.get("headers").and_then(|v| v.as_object());
344 let location_redirect = (300..400).contains(&status)
345 && headers
346 .map(|hdrs| {
347 hdrs.iter().any(|(name, value)| {
348 name.eq_ignore_ascii_case("location")
349 && value.as_str().is_some_and(|s| s.starts_with('/'))
350 })
351 })
352 .unwrap_or(false);
353 let refresh_redirect = headers
354 .map(|hdrs| {
355 hdrs.iter().any(|(name, value)| {
356 if !name.eq_ignore_ascii_case("refresh") {
357 return false;
358 }
359 value
360 .as_str()
361 .and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
362 .map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
363 .unwrap_or(false)
364 })
365 })
366 .unwrap_or(false);
367 let meta_refresh = entry
368 .get("body_inline")
369 .and_then(|v| v.as_str())
370 .map(|body| {
371 let lower = body.to_ascii_lowercase();
372 lower
373 .split("http-equiv=\"refresh\"")
374 .nth(1)
375 .and_then(|s| s.split("content=").nth(1))
376 .map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
377 .unwrap_or(false)
378 })
379 .unwrap_or(false);
380 location_redirect || refresh_redirect || meta_refresh
381 });
382 }
383 false
384 }
385
386 pub fn resolved_category(&self) -> String {
388 self.category.clone().unwrap_or_else(|| {
389 Path::new(&self.source)
390 .parent()
391 .and_then(|p| p.file_name())
392 .and_then(|n| n.to_str())
393 .unwrap_or("default")
394 .to_string()
395 })
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct SkipDirective {
402 #[serde(default)]
404 pub languages: Vec<String>,
405 #[serde(default)]
407 pub reason: Option<String>,
408}
409
410impl SkipDirective {
411 pub fn should_skip(&self, language: &str) -> bool {
413 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
414 }
415}
416
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
419pub struct Assertion {
420 #[serde(rename = "type")]
422 pub assertion_type: String,
423 #[serde(default)]
425 pub field: Option<String>,
426 #[serde(default)]
428 pub value: Option<serde_json::Value>,
429 #[serde(default)]
431 pub values: Option<Vec<serde_json::Value>>,
432 #[serde(default)]
434 pub method: Option<String>,
435 #[serde(default)]
437 pub check: Option<String>,
438 #[serde(default)]
440 pub args: Option<serde_json::Value>,
441 #[serde(default)]
450 pub return_type: Option<String>,
451}
452
453#[derive(Debug, Clone)]
455pub struct FixtureGroup {
456 pub category: String,
457 pub fixtures: Vec<Fixture>,
458}
459
460pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
462 let mut fixtures = Vec::new();
463 load_fixtures_recursive(dir, dir, &mut fixtures)?;
464
465 let mut seen: HashMap<String, String> = HashMap::new();
467 for f in &fixtures {
468 if let Some(prev_source) = seen.get(&f.id) {
469 bail!(
470 "duplicate fixture ID '{}': found in '{}' and '{}'",
471 f.id,
472 prev_source,
473 f.source
474 );
475 }
476 seen.insert(f.id.clone(), f.source.clone());
477 }
478
479 fixtures.sort_by(|a, b| {
481 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
482 cat_cmp.then_with(|| a.id.cmp(&b.id))
483 });
484
485 Ok(fixtures)
486}
487
488fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
489 let entries =
490 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
491
492 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
493 paths.sort();
494
495 for path in paths {
496 if path.is_dir() {
497 load_fixtures_recursive(base, &path, fixtures)?;
498 } else if path.extension().is_some_and(|ext| ext == "json") {
499 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
500 if filename == "schema.json" || filename.starts_with('_') {
502 continue;
503 }
504 let content = std::fs::read_to_string(&path)
505 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
506 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
507
508 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
510 serde_json::from_str(&content)
511 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
512 } else {
513 let single: Fixture = serde_json::from_str(&content)
514 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
515 vec![single]
516 };
517
518 for mut fixture in parsed {
519 fixture.source = relative.clone();
520 expand_json_templates(&mut fixture.input);
523 if let Some(ref mut http) = fixture.http {
524 for (_, v) in http.request.headers.iter_mut() {
525 *v = crate::escape::expand_fixture_templates(v);
526 }
527 if let Some(ref mut body) = http.request.body {
528 expand_json_templates(body);
529 }
530 }
531 fixtures.push(fixture);
532 }
533 }
534 }
535 Ok(())
536}
537
538pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
540 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
541 for f in fixtures {
542 groups.entry(f.resolved_category()).or_default().push(f.clone());
543 }
544 let mut result: Vec<FixtureGroup> = groups
545 .into_iter()
546 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
547 .collect();
548 result.sort_by(|a, b| a.category.cmp(&b.category));
549 result
550}
551
552fn expand_json_templates(value: &mut serde_json::Value) {
554 match value {
555 serde_json::Value::String(s) => {
556 let expanded = crate::escape::expand_fixture_templates(s);
557 if expanded != *s {
558 *s = expanded;
559 }
560 }
561 serde_json::Value::Array(arr) => {
562 for item in arr {
563 expand_json_templates(item);
564 }
565 }
566 serde_json::Value::Object(map) => {
567 for (_, v) in map.iter_mut() {
568 expand_json_templates(v);
569 }
570 }
571 _ => {}
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_fixture_with_mock_response() {
581 let json = r#"{
582 "id": "test_chat",
583 "description": "Test chat",
584 "call": "chat",
585 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
586 "mock_response": {
587 "status": 200,
588 "body": {"choices": [{"message": {"content": "hello"}}]}
589 },
590 "assertions": [{"type": "not_error"}]
591 }"#;
592 let fixture: Fixture = serde_json::from_str(json).unwrap();
593 assert!(fixture.needs_mock_server());
594 assert!(!fixture.is_streaming_mock());
595 assert_eq!(fixture.mock_response.unwrap().status, 200);
596 }
597
598 #[test]
599 fn test_fixture_with_streaming_mock_response() {
600 let json = r#"{
601 "id": "test_stream",
602 "description": "Test streaming",
603 "input": {},
604 "mock_response": {
605 "status": 200,
606 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
607 },
608 "assertions": []
609 }"#;
610 let fixture: Fixture = serde_json::from_str(json).unwrap();
611 assert!(fixture.needs_mock_server());
612 assert!(fixture.is_streaming_mock());
613 }
614
615 #[test]
616 fn test_fixture_without_mock_response() {
617 let json = r#"{
618 "id": "test_no_mock",
619 "description": "No mock",
620 "input": {},
621 "assertions": []
622 }"#;
623 let fixture: Fixture = serde_json::from_str(json).unwrap();
624 assert!(!fixture.needs_mock_server());
625 assert!(!fixture.is_streaming_mock());
626 }
627
628 #[test]
629 fn has_host_root_route_true_for_robots_path() {
630 let json = r#"{
631 "id": "robots_disallow_path",
632 "description": "Robots fixture",
633 "input": {
634 "mock_responses": [
635 {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
636 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
637 ]
638 },
639 "assertions": []
640 }"#;
641 let fixture: Fixture = serde_json::from_str(json).unwrap();
642 assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
643 }
644
645 #[test]
646 fn has_host_root_route_true_for_sitemap_path() {
647 let json = r#"{
648 "id": "sitemap_index",
649 "description": "Sitemap fixture",
650 "input": {
651 "mock_responses": [
652 {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
653 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
654 ]
655 },
656 "assertions": []
657 }"#;
658 let fixture: Fixture = serde_json::from_str(json).unwrap();
659 assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
660 }
661
662 #[test]
663 fn has_host_root_route_false_for_data_json_path() {
664 let json = r#"{
665 "id": "data_endpoint",
666 "description": "Non-host-root fixture",
667 "input": {
668 "mock_responses": [
669 {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
670 ]
671 },
672 "assertions": []
673 }"#;
674 let fixture: Fixture = serde_json::from_str(json).unwrap();
675 assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
676 }
677
678 #[test]
679 fn has_host_root_route_false_for_single_mock_response_schema() {
680 let json = r#"{
682 "id": "basic_chat",
683 "description": "Basic chat",
684 "mock_response": {"status": 200, "body": {}},
685 "input": {},
686 "assertions": []
687 }"#;
688 let fixture: Fixture = serde_json::from_str(json).unwrap();
689 assert!(
690 !fixture.has_host_root_route(),
691 "expected false for single mock_response schema"
692 );
693 }
694
695 #[test]
696 fn has_host_root_route_false_for_empty_mock_responses() {
697 let json = r#"{
698 "id": "empty_responses",
699 "description": "No mock_responses",
700 "input": {},
701 "assertions": []
702 }"#;
703 let fixture: Fixture = serde_json::from_str(json).unwrap();
704 assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
705 }
706}