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 #[serde(default)]
63 return_form: TemplateReturnForm,
64 },
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum TemplateReturnForm {
71 #[default]
74 Dict,
75 BareString,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FixtureEnv {
82 #[serde(default)]
84 pub api_key_var: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Fixture {
90 pub id: String,
92 #[serde(default)]
94 pub category: Option<String>,
95 pub description: String,
97 #[serde(default)]
99 pub tags: Vec<String>,
100 #[serde(default)]
102 pub skip: Option<SkipDirective>,
103 #[serde(default)]
105 pub env: Option<FixtureEnv>,
106 #[serde(default)]
109 pub call: Option<String>,
110 #[serde(default)]
112 pub input: serde_json::Value,
113 #[serde(default)]
115 pub mock_response: Option<MockResponse>,
116 #[serde(default)]
118 pub visitor: Option<VisitorSpec>,
119 #[serde(default)]
121 pub assertions: Vec<Assertion>,
122 #[serde(skip)]
124 pub source: String,
125 #[serde(default)]
128 pub http: Option<HttpFixture>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct HttpFixture {
134 pub handler: HttpHandler,
136 pub request: HttpRequest,
138 pub expected_response: HttpExpectedResponse,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct HttpHandler {
145 pub route: String,
147 pub method: String,
149 #[serde(default)]
151 pub body_schema: Option<serde_json::Value>,
152 #[serde(default)]
154 pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
155 #[serde(default)]
157 pub middleware: Option<HttpMiddleware>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct HttpRequest {
163 pub method: String,
164 pub path: String,
165 #[serde(default)]
166 pub headers: HashMap<String, String>,
167 #[serde(default)]
168 pub query_params: HashMap<String, serde_json::Value>,
169 #[serde(default)]
170 pub cookies: HashMap<String, String>,
171 #[serde(default)]
172 pub body: Option<serde_json::Value>,
173 #[serde(default)]
174 pub content_type: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HttpExpectedResponse {
180 pub status_code: u16,
181 #[serde(default)]
183 pub body: Option<serde_json::Value>,
184 #[serde(default)]
186 pub body_partial: Option<serde_json::Value>,
187 #[serde(default)]
189 pub headers: HashMap<String, String>,
190 #[serde(default)]
192 pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ValidationErrorExpectation {
198 pub loc: Vec<String>,
199 pub msg: String,
200 #[serde(rename = "type")]
201 pub error_type: String,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct CorsConfig {
207 #[serde(default)]
209 pub allow_origins: Vec<String>,
210 #[serde(default)]
212 pub allow_methods: Vec<String>,
213 #[serde(default)]
215 pub allow_headers: Vec<String>,
216 #[serde(default)]
218 pub expose_headers: Vec<String>,
219 #[serde(default)]
221 pub max_age: Option<u64>,
222 #[serde(default)]
224 pub allow_credentials: bool,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct StaticFile {
230 pub path: String,
232 pub content: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct StaticFilesConfig {
239 pub route_prefix: String,
241 #[serde(default)]
243 pub files: Vec<StaticFile>,
244 #[serde(default)]
246 pub index_file: bool,
247 #[serde(default)]
249 pub cache_control: Option<String>,
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct HttpMiddleware {
255 #[serde(default)]
256 pub jwt_auth: Option<serde_json::Value>,
257 #[serde(default)]
258 pub api_key_auth: Option<serde_json::Value>,
259 #[serde(default)]
260 pub compression: Option<serde_json::Value>,
261 #[serde(default)]
262 pub rate_limit: Option<serde_json::Value>,
263 #[serde(default)]
264 pub request_timeout: Option<serde_json::Value>,
265 #[serde(default)]
266 pub request_id: Option<serde_json::Value>,
267 #[serde(default)]
269 pub cors: Option<CorsConfig>,
270 #[serde(default)]
272 pub static_files: Option<Vec<StaticFilesConfig>>,
273}
274
275fn is_host_root_path(path: &str) -> bool {
279 path.starts_with("/robots") || path.starts_with("/sitemap")
280}
281
282impl Fixture {
283 pub fn is_http_test(&self) -> bool {
285 self.http.is_some()
286 }
287
288 pub fn needs_mock_server(&self) -> bool {
293 if self.mock_response.is_some() || self.http.is_some() {
294 return true;
295 }
296 self.input
298 .get("mock_responses")
299 .and_then(|v| v.as_array())
300 .map(|arr| !arr.is_empty())
301 .unwrap_or(false)
302 }
303
304 pub fn as_mock_response(&self) -> Option<MockResponse> {
310 if let Some(mock) = &self.mock_response {
311 return Some(mock.clone());
312 }
313 if let Some(http) = &self.http {
314 return Some(MockResponse {
315 status: http.expected_response.status_code,
316 body: http.expected_response.body.clone(),
317 stream_chunks: None,
318 headers: http.expected_response.headers.clone(),
319 });
320 }
321 None
322 }
323
324 pub fn is_streaming_mock(&self) -> bool {
326 self.mock_response
327 .as_ref()
328 .and_then(|m| m.stream_chunks.as_ref())
329 .map(|c| !c.is_empty())
330 .unwrap_or(false)
331 }
332
333 pub fn has_host_root_route(&self) -> bool {
341 if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
343 if arr.iter().any(|entry| {
345 entry
346 .get("path")
347 .and_then(|v| v.as_str())
348 .map(is_host_root_path)
349 .unwrap_or(false)
350 }) {
351 return true;
352 }
353 return arr.iter().any(|entry| {
361 let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
362 let headers = entry.get("headers").and_then(|v| v.as_object());
363 let location_redirect = (300..400).contains(&status)
364 && headers
365 .map(|hdrs| {
366 hdrs.iter().any(|(name, value)| {
367 name.eq_ignore_ascii_case("location")
368 && value.as_str().is_some_and(|s| s.starts_with('/'))
369 })
370 })
371 .unwrap_or(false);
372 let refresh_redirect = headers
373 .map(|hdrs| {
374 hdrs.iter().any(|(name, value)| {
375 if !name.eq_ignore_ascii_case("refresh") {
376 return false;
377 }
378 value
379 .as_str()
380 .and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
381 .map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
382 .unwrap_or(false)
383 })
384 })
385 .unwrap_or(false);
386 let meta_refresh = entry
387 .get("body_inline")
388 .and_then(|v| v.as_str())
389 .map(|body| {
390 let lower = body.to_ascii_lowercase();
391 lower
392 .split("http-equiv=\"refresh\"")
393 .nth(1)
394 .and_then(|s| s.split("content=").nth(1))
395 .map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
396 .unwrap_or(false)
397 })
398 .unwrap_or(false);
399 location_redirect || refresh_redirect || meta_refresh
400 });
401 }
402 false
403 }
404
405 pub fn resolved_category(&self) -> String {
407 self.category.clone().unwrap_or_else(|| {
408 Path::new(&self.source)
409 .parent()
410 .and_then(|p| p.file_name())
411 .and_then(|n| n.to_str())
412 .unwrap_or("default")
413 .to_string()
414 })
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct SkipDirective {
421 #[serde(default)]
423 pub languages: Vec<String>,
424 #[serde(default)]
426 pub reason: Option<String>,
427}
428
429impl SkipDirective {
430 pub fn should_skip(&self, language: &str) -> bool {
432 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
433 }
434}
435
436#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct Assertion {
439 #[serde(rename = "type")]
441 pub assertion_type: String,
442 #[serde(default)]
444 pub field: Option<String>,
445 #[serde(default)]
447 pub value: Option<serde_json::Value>,
448 #[serde(default)]
450 pub values: Option<Vec<serde_json::Value>>,
451 #[serde(default)]
453 pub method: Option<String>,
454 #[serde(default)]
456 pub check: Option<String>,
457 #[serde(default)]
459 pub args: Option<serde_json::Value>,
460 #[serde(default)]
469 pub return_type: Option<String>,
470}
471
472#[derive(Debug, Clone)]
474pub struct FixtureGroup {
475 pub category: String,
476 pub fixtures: Vec<Fixture>,
477}
478
479pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
481 let mut fixtures = Vec::new();
482 load_fixtures_recursive(dir, dir, &mut fixtures)?;
483
484 let mut seen: HashMap<String, String> = HashMap::new();
486 for f in &fixtures {
487 if let Some(prev_source) = seen.get(&f.id) {
488 bail!(
489 "duplicate fixture ID '{}': found in '{}' and '{}'",
490 f.id,
491 prev_source,
492 f.source
493 );
494 }
495 seen.insert(f.id.clone(), f.source.clone());
496 }
497
498 fixtures.sort_by(|a, b| {
500 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
501 cat_cmp.then_with(|| a.id.cmp(&b.id))
502 });
503
504 Ok(fixtures)
505}
506
507fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
508 let entries =
509 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
510
511 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
512 paths.sort();
513
514 for path in paths {
515 if path.is_dir() {
516 load_fixtures_recursive(base, &path, fixtures)?;
517 } else if path.extension().is_some_and(|ext| ext == "json") {
518 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
519 if filename == "schema.json" || filename.starts_with('_') {
521 continue;
522 }
523 let content = std::fs::read_to_string(&path)
524 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
525 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
526
527 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
529 serde_json::from_str(&content)
530 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
531 } else {
532 let single: Fixture = serde_json::from_str(&content)
533 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
534 vec![single]
535 };
536
537 for mut fixture in parsed {
538 fixture.source = relative.clone();
539 expand_json_templates(&mut fixture.input);
542 if let Some(ref mut http) = fixture.http {
543 for (_, v) in http.request.headers.iter_mut() {
544 *v = crate::escape::expand_fixture_templates(v);
545 }
546 if let Some(ref mut body) = http.request.body {
547 expand_json_templates(body);
548 }
549 }
550 fixtures.push(fixture);
551 }
552 }
553 }
554 Ok(())
555}
556
557pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
559 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
560 for f in fixtures {
561 groups.entry(f.resolved_category()).or_default().push(f.clone());
562 }
563 let mut result: Vec<FixtureGroup> = groups
564 .into_iter()
565 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
566 .collect();
567 result.sort_by(|a, b| a.category.cmp(&b.category));
568 result
569}
570
571fn expand_json_templates(value: &mut serde_json::Value) {
573 match value {
574 serde_json::Value::String(s) => {
575 let expanded = crate::escape::expand_fixture_templates(s);
576 if expanded != *s {
577 *s = expanded;
578 }
579 }
580 serde_json::Value::Array(arr) => {
581 for item in arr {
582 expand_json_templates(item);
583 }
584 }
585 serde_json::Value::Object(map) => {
586 for (_, v) in map.iter_mut() {
587 expand_json_templates(v);
588 }
589 }
590 _ => {}
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn test_fixture_with_mock_response() {
600 let json = r#"{
601 "id": "test_chat",
602 "description": "Test chat",
603 "call": "chat",
604 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
605 "mock_response": {
606 "status": 200,
607 "body": {"choices": [{"message": {"content": "hello"}}]}
608 },
609 "assertions": [{"type": "not_error"}]
610 }"#;
611 let fixture: Fixture = serde_json::from_str(json).unwrap();
612 assert!(fixture.needs_mock_server());
613 assert!(!fixture.is_streaming_mock());
614 assert_eq!(fixture.mock_response.unwrap().status, 200);
615 }
616
617 #[test]
618 fn test_fixture_with_streaming_mock_response() {
619 let json = r#"{
620 "id": "test_stream",
621 "description": "Test streaming",
622 "input": {},
623 "mock_response": {
624 "status": 200,
625 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
626 },
627 "assertions": []
628 }"#;
629 let fixture: Fixture = serde_json::from_str(json).unwrap();
630 assert!(fixture.needs_mock_server());
631 assert!(fixture.is_streaming_mock());
632 }
633
634 #[test]
635 fn test_fixture_without_mock_response() {
636 let json = r#"{
637 "id": "test_no_mock",
638 "description": "No mock",
639 "input": {},
640 "assertions": []
641 }"#;
642 let fixture: Fixture = serde_json::from_str(json).unwrap();
643 assert!(!fixture.needs_mock_server());
644 assert!(!fixture.is_streaming_mock());
645 }
646
647 #[test]
648 fn has_host_root_route_true_for_robots_path() {
649 let json = r#"{
650 "id": "robots_disallow_path",
651 "description": "Robots fixture",
652 "input": {
653 "mock_responses": [
654 {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
655 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
656 ]
657 },
658 "assertions": []
659 }"#;
660 let fixture: Fixture = serde_json::from_str(json).unwrap();
661 assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
662 }
663
664 #[test]
665 fn has_host_root_route_true_for_sitemap_path() {
666 let json = r#"{
667 "id": "sitemap_index",
668 "description": "Sitemap fixture",
669 "input": {
670 "mock_responses": [
671 {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
672 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
673 ]
674 },
675 "assertions": []
676 }"#;
677 let fixture: Fixture = serde_json::from_str(json).unwrap();
678 assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
679 }
680
681 #[test]
682 fn has_host_root_route_false_for_data_json_path() {
683 let json = r#"{
684 "id": "data_endpoint",
685 "description": "Non-host-root fixture",
686 "input": {
687 "mock_responses": [
688 {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
689 ]
690 },
691 "assertions": []
692 }"#;
693 let fixture: Fixture = serde_json::from_str(json).unwrap();
694 assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
695 }
696
697 #[test]
698 fn has_host_root_route_false_for_single_mock_response_schema() {
699 let json = r#"{
701 "id": "basic_chat",
702 "description": "Basic chat",
703 "mock_response": {"status": 200, "body": {}},
704 "input": {},
705 "assertions": []
706 }"#;
707 let fixture: Fixture = serde_json::from_str(json).unwrap();
708 assert!(
709 !fixture.has_host_root_route(),
710 "expected false for single mock_response schema"
711 );
712 }
713
714 #[test]
715 fn has_host_root_route_false_for_empty_mock_responses() {
716 let json = r#"{
717 "id": "empty_responses",
718 "description": "No mock_responses",
719 "input": {},
720 "assertions": []
721 }"#;
722 let fixture: Fixture = serde_json::from_str(json).unwrap();
723 assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
724 }
725}