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