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