1use std::path::{Path, PathBuf};
24
25#[derive(Debug)]
26pub enum ManifestError {
27 Read(String),
28 Parse(String),
29 Validate(String),
30}
31
32impl std::fmt::Display for ManifestError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::Read(m) | Self::Parse(m) | Self::Validate(m) => f.write_str(m),
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct Manifest {
42 pub name: String,
43 pub entry: PathBuf,
45 pub iterations: usize,
46 pub warmup: usize,
47 pub args: Vec<String>,
48 pub expected: ExpectedShape,
49 pub tolerance: Tolerance,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BenchTarget {
55 Vm,
57 Rust,
60 WasmGc,
68 WasmGcV8,
78}
79
80impl BenchTarget {
81 pub fn parse(s: &str) -> Result<Self, String> {
82 match s {
83 "vm" => Ok(Self::Vm),
84 "wasm-gc" => Ok(Self::WasmGc),
85 "wasm-gc-v8" => Ok(Self::WasmGcV8),
86 "rust" => Ok(Self::Rust),
87 other => Err(format!(
88 "unknown bench target '{}'; expected one of: vm, wasm-gc, wasm-gc-v8, rust",
89 other
90 )),
91 }
92 }
93
94 pub const fn name(self) -> &'static str {
95 match self {
96 Self::Vm => "vm",
97 Self::WasmGc => "wasm-gc",
98 Self::WasmGcV8 => "wasm-gc-v8",
99 Self::Rust => "rust",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy)]
109pub struct Tolerance {
110 pub wall_time_p50_pct: f64,
113 pub wall_time_p95_pct: f64,
116}
117
118impl Default for Tolerance {
119 fn default() -> Self {
120 Self {
121 wall_time_p50_pct: 20.0,
122 wall_time_p95_pct: 30.0,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct ExpectedShape {
129 pub response_bytes: Option<usize>,
130 pub response_bytes_min: Option<usize>,
131 pub response_bytes_max: Option<usize>,
132}
133
134impl Manifest {
135 pub fn load(path: &Path) -> Result<Self, ManifestError> {
136 let raw = std::fs::read_to_string(path)
137 .map_err(|e| ManifestError::Read(format!("cannot read '{}': {}", path.display(), e)))?;
138 let table: toml::Value = raw.parse().map_err(|e: toml::de::Error| {
139 ManifestError::Parse(format!("{}: {}", path.display(), e))
140 })?;
141 let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
142 Self::from_toml(&table, path, manifest_dir)
143 }
144
145 fn from_toml(
146 table: &toml::Value,
147 manifest_path: &Path,
148 manifest_dir: &Path,
149 ) -> Result<Self, ManifestError> {
150 let entry_raw = table.get("entry").and_then(|v| v.as_str()).ok_or_else(|| {
151 ManifestError::Validate(format!(
152 "{}: missing required `entry = \"...\"`",
153 manifest_path.display()
154 ))
155 })?;
156 let entry_path = Path::new(entry_raw);
157 let entry = if entry_path.is_absolute() {
158 entry_path.to_path_buf()
159 } else {
160 manifest_dir.join(entry_path)
161 };
162 if !entry.exists() {
163 return Err(ManifestError::Validate(format!(
164 "{}: entry '{}' does not exist (resolved to '{}')",
165 manifest_path.display(),
166 entry_raw,
167 entry.display()
168 )));
169 }
170
171 let name = table
172 .get("name")
173 .and_then(|v| v.as_str())
174 .map(|s| s.to_string())
175 .unwrap_or_else(|| {
176 manifest_path
177 .file_stem()
178 .and_then(|s| s.to_str())
179 .unwrap_or("scenario")
180 .to_string()
181 });
182
183 let iterations = table
184 .get("iterations")
185 .and_then(|v| v.as_integer())
186 .unwrap_or(10) as usize;
187 let warmup = table
188 .get("warmup")
189 .and_then(|v| v.as_integer())
190 .unwrap_or(1) as usize;
191
192 if iterations == 0 {
193 return Err(ManifestError::Validate(format!(
194 "{}: `iterations` must be > 0",
195 manifest_path.display()
196 )));
197 }
198
199 let args = table
200 .get("args")
201 .and_then(|v| v.as_array())
202 .map(|arr| {
203 arr.iter()
204 .filter_map(|v| v.as_str().map(|s| s.to_string()))
205 .collect()
206 })
207 .unwrap_or_default();
208
209 let expected = match table.get("expected") {
210 Some(toml::Value::Table(t)) => ExpectedShape {
211 response_bytes: t
212 .get("response_bytes")
213 .and_then(|v| v.as_integer())
214 .map(|n| n as usize),
215 response_bytes_min: t
216 .get("response_bytes_min")
217 .and_then(|v| v.as_integer())
218 .map(|n| n as usize),
219 response_bytes_max: t
220 .get("response_bytes_max")
221 .and_then(|v| v.as_integer())
222 .map(|n| n as usize),
223 },
224 _ => ExpectedShape::default(),
225 };
226
227 let tolerance = match table.get("tolerance") {
228 Some(toml::Value::Table(t)) => {
229 let mut tol = Tolerance::default();
230 if let Some(v) = t.get("wall_time_p50_pct").and_then(|v| v.as_float()) {
231 tol.wall_time_p50_pct = v;
232 }
233 if let Some(v) = t.get("wall_time_p95_pct").and_then(|v| v.as_float()) {
234 tol.wall_time_p95_pct = v;
235 }
236 tol
237 }
238 _ => Tolerance::default(),
239 };
240
241 Ok(Manifest {
242 name,
243 entry,
244 iterations,
245 warmup,
246 args,
247 expected,
248 tolerance,
249 })
250 }
251}