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 Fixture {
11 pub id: String,
13 #[serde(default)]
15 pub category: Option<String>,
16 pub description: String,
18 #[serde(default)]
20 pub tags: Vec<String>,
21 #[serde(default)]
23 pub skip: Option<SkipDirective>,
24 #[serde(default)]
27 pub call: Option<String>,
28 #[serde(default)]
30 pub input: serde_json::Value,
31 #[serde(default)]
33 pub assertions: Vec<Assertion>,
34 #[serde(skip)]
36 pub source: String,
37}
38
39impl Fixture {
40 pub fn resolved_category(&self) -> String {
42 self.category.clone().unwrap_or_else(|| {
43 Path::new(&self.source)
44 .parent()
45 .and_then(|p| p.file_name())
46 .and_then(|n| n.to_str())
47 .unwrap_or("default")
48 .to_string()
49 })
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SkipDirective {
56 #[serde(default)]
58 pub languages: Vec<String>,
59 #[serde(default)]
61 pub reason: Option<String>,
62}
63
64impl SkipDirective {
65 pub fn should_skip(&self, language: &str) -> bool {
67 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Assertion {
74 #[serde(rename = "type")]
76 pub assertion_type: String,
77 #[serde(default)]
79 pub field: Option<String>,
80 #[serde(default)]
82 pub value: Option<serde_json::Value>,
83 #[serde(default)]
85 pub values: Option<Vec<serde_json::Value>>,
86}
87
88#[derive(Debug, Clone)]
90pub struct FixtureGroup {
91 pub category: String,
92 pub fixtures: Vec<Fixture>,
93}
94
95pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
97 let mut fixtures = Vec::new();
98 load_fixtures_recursive(dir, dir, &mut fixtures)?;
99
100 let mut seen: HashMap<String, String> = HashMap::new();
102 for f in &fixtures {
103 if let Some(prev_source) = seen.get(&f.id) {
104 bail!(
105 "duplicate fixture ID '{}': found in '{}' and '{}'",
106 f.id,
107 prev_source,
108 f.source
109 );
110 }
111 seen.insert(f.id.clone(), f.source.clone());
112 }
113
114 fixtures.sort_by(|a, b| {
116 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
117 cat_cmp.then_with(|| a.id.cmp(&b.id))
118 });
119
120 Ok(fixtures)
121}
122
123fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
124 let entries =
125 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
126
127 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
128 paths.sort();
129
130 for path in paths {
131 if path.is_dir() {
132 load_fixtures_recursive(base, &path, fixtures)?;
133 } else if path.extension().is_some_and(|ext| ext == "json") {
134 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
135 if filename == "schema.json" || filename.starts_with('_') {
137 continue;
138 }
139 let content = std::fs::read_to_string(&path)
140 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
141 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
142
143 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
145 serde_json::from_str(&content)
146 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
147 } else {
148 let single: Fixture = serde_json::from_str(&content)
149 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
150 vec![single]
151 };
152
153 for mut fixture in parsed {
154 fixture.source = relative.clone();
155 fixtures.push(fixture);
156 }
157 }
158 }
159 Ok(())
160}
161
162pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
164 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
165 for f in fixtures {
166 groups.entry(f.resolved_category()).or_default().push(f.clone());
167 }
168 let mut result: Vec<FixtureGroup> = groups
169 .into_iter()
170 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
171 .collect();
172 result.sort_by(|a, b| a.category.cmp(&b.category));
173 result
174}