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