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 normalize_assertions(&mut fixture);
551 fixtures.push(fixture);
552 }
553 }
554 }
555 Ok(())
556}
557
558pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
560 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
561 for f in fixtures {
562 groups.entry(f.resolved_category()).or_default().push(f.clone());
563 }
564 let mut result: Vec<FixtureGroup> = groups
565 .into_iter()
566 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
567 .collect();
568 result.sort_by(|a, b| a.category.cmp(&b.category));
569 result
570}
571
572fn normalize_assertions(fixture: &mut Fixture) {
590 for assertion in fixture.assertions.iter_mut() {
591 let Some(field) = assertion.field.as_deref() else {
592 continue;
593 };
594 let bare = field.strip_prefix("crawl.").unwrap_or(field);
597 match bare {
598 "pages_crawled" => {
599 assertion.field = Some("pages".to_string());
600 match assertion.assertion_type.as_str() {
601 "equals" => assertion.assertion_type = "count_equals".to_string(),
602 "greater_than_or_equal" => assertion.assertion_type = "count_min".to_string(),
603 "less_than_or_equal" => assertion.assertion_type = "count_max".to_string(),
604 _ => {}
605 }
606 }
607 "min_pages" => {
608 assertion.field = Some("pages".to_string());
609 if assertion.assertion_type == "greater_than_or_equal" {
610 assertion.assertion_type = "count_min".to_string();
611 }
612 }
613 _ => {}
614 }
615 }
616}
617
618fn expand_json_templates(value: &mut serde_json::Value) {
620 match value {
621 serde_json::Value::String(s) => {
622 let expanded = crate::escape::expand_fixture_templates(s);
623 if expanded != *s {
624 *s = expanded;
625 }
626 }
627 serde_json::Value::Array(arr) => {
628 for item in arr {
629 expand_json_templates(item);
630 }
631 }
632 serde_json::Value::Object(map) => {
633 for (_, v) in map.iter_mut() {
634 expand_json_templates(v);
635 }
636 }
637 _ => {}
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_fixture_with_mock_response() {
647 let json = r#"{
648 "id": "test_chat",
649 "description": "Test chat",
650 "call": "chat",
651 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
652 "mock_response": {
653 "status": 200,
654 "body": {"choices": [{"message": {"content": "hello"}}]}
655 },
656 "assertions": [{"type": "not_error"}]
657 }"#;
658 let fixture: Fixture = serde_json::from_str(json).unwrap();
659 assert!(fixture.needs_mock_server());
660 assert!(!fixture.is_streaming_mock());
661 assert_eq!(fixture.mock_response.unwrap().status, 200);
662 }
663
664 #[test]
665 fn test_fixture_with_streaming_mock_response() {
666 let json = r#"{
667 "id": "test_stream",
668 "description": "Test streaming",
669 "input": {},
670 "mock_response": {
671 "status": 200,
672 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
673 },
674 "assertions": []
675 }"#;
676 let fixture: Fixture = serde_json::from_str(json).unwrap();
677 assert!(fixture.needs_mock_server());
678 assert!(fixture.is_streaming_mock());
679 }
680
681 fn make_fixture_with_assertion(assertion_json: &str) -> Fixture {
682 let json = format!(
683 r#"{{
684 "id": "x",
685 "description": "x",
686 "input": {{}},
687 "assertions": [{assertion_json}]
688 }}"#,
689 );
690 serde_json::from_str(&json).unwrap()
691 }
692
693 #[test]
694 fn normalize_assertions_rewrites_pages_crawled_to_count_equals_on_pages() {
695 let mut fixture =
696 make_fixture_with_assertion(r#"{"type": "equals", "field": "crawl.pages_crawled", "value": 3}"#);
697 normalize_assertions(&mut fixture);
698 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
699 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
700 }
701
702 #[test]
703 fn normalize_assertions_rewrites_bare_pages_crawled() {
704 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "pages_crawled", "value": 5}"#);
705 normalize_assertions(&mut fixture);
706 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
707 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
708 }
709
710 #[test]
711 fn normalize_assertions_rewrites_pages_crawled_gte_to_count_min() {
712 let mut fixture = make_fixture_with_assertion(
713 r#"{"type": "greater_than_or_equal", "field": "crawl.pages_crawled", "value": 3}"#,
714 );
715 normalize_assertions(&mut fixture);
716 assert_eq!(fixture.assertions[0].assertion_type, "count_min");
717 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
718 }
719
720 #[test]
721 fn normalize_assertions_rewrites_pages_crawled_lte_to_count_max() {
722 let mut fixture = make_fixture_with_assertion(
723 r#"{"type": "less_than_or_equal", "field": "crawl.pages_crawled", "value": 7}"#,
724 );
725 normalize_assertions(&mut fixture);
726 assert_eq!(fixture.assertions[0].assertion_type, "count_max");
727 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
728 }
729
730 #[test]
731 fn normalize_assertions_rewrites_min_pages_to_count_min_on_pages() {
732 let mut fixture =
733 make_fixture_with_assertion(r#"{"type": "greater_than_or_equal", "field": "crawl.min_pages", "value": 2}"#);
734 normalize_assertions(&mut fixture);
735 assert_eq!(fixture.assertions[0].assertion_type, "count_min");
736 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
737 }
738
739 #[test]
740 fn normalize_assertions_leaves_unrelated_fields_unchanged() {
741 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "content", "value": "hi"}"#);
742 normalize_assertions(&mut fixture);
743 assert_eq!(fixture.assertions[0].assertion_type, "equals");
744 assert_eq!(fixture.assertions[0].field.as_deref(), Some("content"));
745 }
746
747 #[test]
748 fn test_fixture_without_mock_response() {
749 let json = r#"{
750 "id": "test_no_mock",
751 "description": "No mock",
752 "input": {},
753 "assertions": []
754 }"#;
755 let fixture: Fixture = serde_json::from_str(json).unwrap();
756 assert!(!fixture.needs_mock_server());
757 assert!(!fixture.is_streaming_mock());
758 }
759
760 #[test]
761 fn has_host_root_route_true_for_robots_path() {
762 let json = r#"{
763 "id": "robots_disallow_path",
764 "description": "Robots fixture",
765 "input": {
766 "mock_responses": [
767 {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
768 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
769 ]
770 },
771 "assertions": []
772 }"#;
773 let fixture: Fixture = serde_json::from_str(json).unwrap();
774 assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
775 }
776
777 #[test]
778 fn has_host_root_route_true_for_sitemap_path() {
779 let json = r#"{
780 "id": "sitemap_index",
781 "description": "Sitemap fixture",
782 "input": {
783 "mock_responses": [
784 {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
785 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
786 ]
787 },
788 "assertions": []
789 }"#;
790 let fixture: Fixture = serde_json::from_str(json).unwrap();
791 assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
792 }
793
794 #[test]
795 fn has_host_root_route_false_for_data_json_path() {
796 let json = r#"{
797 "id": "data_endpoint",
798 "description": "Non-host-root fixture",
799 "input": {
800 "mock_responses": [
801 {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
802 ]
803 },
804 "assertions": []
805 }"#;
806 let fixture: Fixture = serde_json::from_str(json).unwrap();
807 assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
808 }
809
810 #[test]
811 fn has_host_root_route_false_for_single_mock_response_schema() {
812 let json = r#"{
814 "id": "basic_chat",
815 "description": "Basic chat",
816 "mock_response": {"status": 200, "body": {}},
817 "input": {},
818 "assertions": []
819 }"#;
820 let fixture: Fixture = serde_json::from_str(json).unwrap();
821 assert!(
822 !fixture.has_host_root_route(),
823 "expected false for single mock_response schema"
824 );
825 }
826
827 #[test]
828 fn has_host_root_route_false_for_empty_mock_responses() {
829 let json = r#"{
830 "id": "empty_responses",
831 "description": "No mock_responses",
832 "input": {},
833 "assertions": []
834 }"#;
835 let fixture: Fixture = serde_json::from_str(json).unwrap();
836 assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
837 }
838}