1use crate::results::{HyperfineOutput, HyperfineResult};
4use eyre::{Result, WrapErr};
5use foundry_common::{sh_eprintln, sh_println};
6use foundry_compilers::project_util::TempProject;
7use foundry_test_utils::util::clone_remote;
8use once_cell::sync::Lazy;
9use std::{
10 path::{Path, PathBuf},
11 process::Command,
12 str::FromStr,
13};
14
15pub mod results;
16
17pub const RUNS: u32 = 5;
19
20#[derive(Debug, Clone)]
22pub struct RepoConfig {
23 pub name: String,
24 pub org: String,
25 pub repo: String,
26 pub rev: String,
27 pub extra_args: Option<String>,
30}
31
32impl FromStr for RepoConfig {
33 type Err = eyre::Error;
34
35 fn from_str(spec: &str) -> Result<Self> {
40 let spec = spec.trim();
41 let (head, extra_args) = match spec.split_once(char::is_whitespace) {
43 Some((head, rest)) => (head, Some(rest.trim().to_string())),
44 None => (spec, None),
45 };
46
47 let (repo_path, custom_rev) = match head.split_once(':') {
48 Some((path, rev)) => (path, Some(rev)),
49 None => (head, None),
50 };
51
52 let (org, repo) = repo_path.split_once('/').ok_or_else(|| {
53 eyre::eyre!("Invalid repo format '{spec}'. Expected 'org/repo' or 'org/repo:rev'")
54 })?;
55
56 let mut config = BENCHMARK_REPOS
59 .iter()
60 .find(|r| r.org == org && r.repo == repo)
61 .cloned()
62 .unwrap_or_else(|| Self {
63 name: format!("{org}-{repo}"),
64 org: org.to_string(),
65 repo: repo.to_string(),
66 rev: "main".to_string(),
67 extra_args: None,
68 });
69
70 if let Some(rev) = custom_rev {
71 config.rev = rev.to_string();
72 }
73 config.extra_args = extra_args;
74
75 let _ = sh_println!("Parsed repo spec '{spec}' -> {config:?}");
76 Ok(config)
77 }
78}
79
80pub fn default_benchmark_repos() -> Vec<RepoConfig> {
82 vec![
83 RepoConfig {
84 name: "ithacaxyz-account".to_string(),
85 org: "ithacaxyz".to_string(),
86 repo: "account".to_string(),
87 rev: "main".to_string(),
88 extra_args: None,
89 },
90 RepoConfig {
91 name: "solady".to_string(),
92 org: "Vectorized".to_string(),
93 repo: "solady".to_string(),
94 rev: "main".to_string(),
95 extra_args: None,
96 },
97 ]
98}
99
100pub static BENCHMARK_REPOS: Lazy<Vec<RepoConfig>> = Lazy::new(default_benchmark_repos);
102
103pub static FOUNDRY_VERSIONS: &[&str] = &["stable", "nightly"];
119
120pub struct BenchmarkProject {
122 pub name: String,
123 pub temp_project: TempProject,
124 pub root_path: PathBuf,
125 pub extra_args: Option<String>,
127}
128
129impl BenchmarkProject {
130 #[allow(unused_must_use)]
132 pub fn setup(config: &RepoConfig) -> Result<Self> {
133 let temp_project =
134 TempProject::dapptools().wrap_err("Failed to create temporary project")?;
135
136 let root_path = temp_project.root().to_path_buf();
138 let root = root_path.to_str().unwrap();
139
140 for entry in std::fs::read_dir(&root_path)? {
142 let entry = entry?;
143 let path = entry.path();
144 if path.is_dir() {
145 std::fs::remove_dir_all(&path).ok();
146 } else {
147 std::fs::remove_file(&path).ok();
148 }
149 }
150
151 let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo);
153 clone_remote(&repo_url, root, true);
154
155 if !config.rev.is_empty() && config.rev != "main" && config.rev != "master" {
157 let status = Command::new("git")
158 .current_dir(root)
159 .args(["checkout", &config.rev])
160 .status()
161 .wrap_err("Failed to checkout revision")?;
162
163 if !status.success() {
164 eyre::bail!("Git checkout failed for {}", config.name);
165 }
166 }
167
168 Self::install_npm_dependencies(&root_path)?;
171
172 sh_println!(" ✅ Project {} setup complete at {}", config.name, root);
173 Ok(Self {
174 name: config.name.clone(),
175 root_path,
176 temp_project,
177 extra_args: config.extra_args.clone(),
178 })
179 }
180
181 fn cmd(&self, base: &str) -> String {
183 match self.extra_args.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
184 Some(extra) => format!("{base} {extra}"),
185 None => base.to_string(),
186 }
187 }
188
189 #[allow(unused_must_use)]
191 fn install_npm_dependencies(root: &Path) -> Result<()> {
192 if root.join("package.json").exists() {
193 sh_println!(" 📦 Running npm install...");
194 let status = Command::new("npm")
195 .current_dir(root)
196 .args(["install"])
197 .stdout(std::process::Stdio::inherit())
198 .stderr(std::process::Stdio::inherit())
199 .status()
200 .wrap_err("Failed to run npm install")?;
201
202 if status.success() {
203 sh_println!(" ✅ npm install completed successfully");
204 } else {
205 sh_println!(
206 " ⚠️ Warning: npm install failed with exit code: {:?}",
207 status.code()
208 );
209 }
210 }
211 Ok(())
212 }
213
214 #[allow(clippy::too_many_arguments)]
235 fn hyperfine(
236 &self,
237 benchmark_name: &str,
238 version: &str,
239 command: &str,
240 runs: u32,
241 setup: Option<&str>,
242 prepare: Option<&str>,
243 conclude: Option<&str>,
244 verbose: bool,
245 ) -> Result<HyperfineResult> {
246 let temp_dir = std::env::temp_dir();
249 let json_dir =
250 temp_dir.join("foundry-bench").join(benchmark_name).join(version).join(&self.name);
251 std::fs::create_dir_all(&json_dir)?;
252
253 let json_path = json_dir.join(format!("{benchmark_name}.json"));
254
255 let mut hyperfine_cmd = Command::new("hyperfine");
257 hyperfine_cmd
258 .current_dir(&self.root_path)
259 .arg("--runs")
260 .arg(runs.to_string())
261 .arg("--export-json")
262 .arg(&json_path)
263 .arg("--shell=bash");
264
265 if let Some(setup_cmd) = setup {
267 hyperfine_cmd.arg("--setup").arg(setup_cmd);
268 }
269
270 if let Some(prepare_cmd) = prepare {
272 hyperfine_cmd.arg("--prepare").arg(prepare_cmd);
273 }
274
275 if let Some(conclude_cmd) = conclude {
277 hyperfine_cmd.arg("--conclude").arg(conclude_cmd);
278 }
279
280 if verbose {
281 hyperfine_cmd.arg("--show-output");
282 hyperfine_cmd.stderr(std::process::Stdio::inherit());
283 hyperfine_cmd.stdout(std::process::Stdio::inherit());
284 }
285
286 hyperfine_cmd.arg(command);
288
289 let status = hyperfine_cmd.status().wrap_err("Failed to run hyperfine")?;
290 if !status.success() {
291 eyre::bail!("Hyperfine failed for command: {}", command);
292 }
293
294 let json_content = std::fs::read_to_string(json_path)?;
296 let output: HyperfineOutput = serde_json::from_str(&json_content)?;
297
298 output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine"))
300 }
301
302 pub fn bench_forge_test(
304 &self,
305 version: &str,
306 runs: u32,
307 verbose: bool,
308 ) -> Result<HyperfineResult> {
309 self.hyperfine(
311 "forge_test",
312 version,
313 &self.cmd("forge test"),
314 runs,
315 Some("forge build"),
316 None,
317 None,
318 verbose,
319 )
320 }
321
322 pub fn bench_forge_build_with_cache(
324 &self,
325 version: &str,
326 runs: u32,
327 verbose: bool,
328 ) -> Result<HyperfineResult> {
329 self.hyperfine(
330 "forge_build_with_cache",
331 version,
332 &self.cmd("FOUNDRY_LINT_LINT_ON_BUILD=false forge build"),
333 runs,
334 None,
335 Some("forge build"),
336 None,
337 verbose,
338 )
339 }
340
341 pub fn bench_forge_build_no_cache(
343 &self,
344 version: &str,
345 runs: u32,
346 verbose: bool,
347 ) -> Result<HyperfineResult> {
348 self.hyperfine(
350 "forge_build_no_cache",
351 version,
352 &self.cmd("FOUNDRY_LINT_LINT_ON_BUILD=false forge build"),
353 runs,
354 Some("forge clean"),
355 None,
356 Some("forge clean"),
357 verbose,
358 )
359 }
360
361 pub fn bench_forge_fuzz_test(
363 &self,
364 version: &str,
365 runs: u32,
366 verbose: bool,
367 ) -> Result<HyperfineResult> {
368 self.hyperfine(
370 "forge_fuzz_test",
371 version,
372 &self.cmd(r#"forge test --match-test "test[^(]*\([^)]+\)""#),
373 runs,
374 Some("forge build"),
375 None,
376 None,
377 verbose,
378 )
379 }
380
381 pub fn bench_forge_coverage(
383 &self,
384 version: &str,
385 runs: u32,
386 verbose: bool,
387 ) -> Result<HyperfineResult> {
388 self.hyperfine(
391 "forge_coverage",
392 version,
393 &self.cmd("forge coverage --ir-minimum"),
394 runs,
395 None,
396 None,
397 None,
398 verbose,
399 )
400 }
401
402 pub fn bench_forge_isolate_test(
404 &self,
405 version: &str,
406 runs: u32,
407 verbose: bool,
408 ) -> Result<HyperfineResult> {
409 self.hyperfine(
411 "forge_isolate_test",
412 version,
413 &self.cmd("forge test --isolate"),
414 runs,
415 Some("forge build"),
416 None,
417 None,
418 verbose,
419 )
420 }
421
422 pub fn root(&self) -> &Path {
424 &self.root_path
425 }
426
427 pub fn run(
429 &self,
430 benchmark: &str,
431 version: &str,
432 runs: u32,
433 verbose: bool,
434 ) -> Result<HyperfineResult> {
435 match benchmark {
436 "forge_test" => self.bench_forge_test(version, runs, verbose),
437 "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose),
438 "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose),
439 "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose),
440 "forge_coverage" => self.bench_forge_coverage(version, runs, verbose),
441 "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose),
442 _ => eyre::bail!("Unknown benchmark: {}", benchmark),
443 }
444 }
445}
446
447const WORKSPACE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
450
451#[allow(unused_must_use)]
456pub fn switch_foundry_version(version: &str) -> Result<()> {
457 if version == "local" {
458 return install_local_version();
459 }
460
461 let output = Command::new("foundryup")
462 .args(["--use", version])
463 .output()
464 .wrap_err("Failed to run foundryup")?;
465
466 let stderr = String::from_utf8_lossy(&output.stderr);
468 if stderr.contains("command failed") && stderr.contains("forge --version") {
469 eyre::bail!(
470 "Foundry binaries maybe corrupted. Please reinstall by running `foundryup --install <version>`"
471 );
472 }
473
474 if !output.status.success() {
475 sh_eprintln!("foundryup stderr: {stderr}");
476 eyre::bail!("Failed to switch to foundry version: {}", version);
477 }
478
479 sh_println!(" Successfully switched to version: {version}");
480 Ok(())
481}
482
483#[allow(unused_must_use)]
486pub fn install_local_version() -> Result<()> {
487 let workspace =
488 std::fs::canonicalize(WORKSPACE_ROOT).wrap_err("Failed to resolve workspace root")?;
489 sh_println!(" Building local workspace at {}", workspace.display());
490
491 let status = Command::new("foundryup")
492 .args(["--path", workspace.to_str().unwrap()])
493 .status()
494 .wrap_err("Failed to run foundryup --path")?;
495
496 if !status.success() {
497 eyre::bail!("foundryup --path failed for local workspace");
498 }
499
500 sh_println!(" Successfully activated local build");
501 Ok(())
502}
503
504pub fn get_forge_version() -> Result<String> {
506 let output = Command::new("forge")
507 .args(["--version"])
508 .output()
509 .wrap_err("Failed to get forge version")?;
510
511 if !output.status.success() {
512 eyre::bail!("forge --version failed");
513 }
514
515 let version =
516 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
517
518 Ok(version.lines().next().unwrap_or("unknown").to_string())
519}
520
521pub fn get_forge_version_details() -> Result<String> {
523 let output = Command::new("forge")
524 .args(["--version"])
525 .output()
526 .wrap_err("Failed to get forge version")?;
527
528 if !output.status.success() {
529 eyre::bail!("forge --version failed");
530 }
531
532 let full_output =
533 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
534
535 let lines: Vec<&str> = full_output.lines().collect();
537 if lines.len() >= 3 {
538 let version = lines[0].trim();
540 let commit = lines[1].trim().replace("Commit SHA: ", "");
541 let timestamp = lines[2].trim().replace("Build Timestamp: ", "");
542
543 let short_commit = &commit[..7]; let date = timestamp.split('T').next().unwrap_or(×tamp);
546
547 Ok(format!("{version} ({short_commit} {date})"))
548 } else {
549 Ok(lines.first().unwrap_or(&"unknown").to_string())
551 }
552}