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