1use crate::{
4 results::{HyperfineOutput, HyperfineResult},
5 symbolic::{Fixture, Overlay, Sample, Sidecar},
6};
7use eyre::{Result, WrapErr};
8use foundry_common::{sh_eprintln, sh_println};
9use foundry_compilers::project_util::TempProject;
10use foundry_test_utils::util::clone_remote;
11use once_cell::sync::Lazy;
12use std::{
13 collections::HashSet,
14 env, fs,
15 path::{Path, PathBuf},
16 process::Command,
17 str::FromStr,
18 time::Instant,
19};
20
21pub mod results;
22pub mod symbolic;
23
24pub const RUNS: u32 = 5;
26
27#[derive(Debug, Clone)]
29pub struct RepoConfig {
30 pub name: String,
31 pub org: String,
32 pub repo: String,
33 pub rev: String,
34 pub extra_args: Option<String>,
37}
38
39impl FromStr for RepoConfig {
40 type Err = eyre::Error;
41
42 fn from_str(spec: &str) -> Result<Self> {
47 let spec = spec.trim();
48 let (head, extra_args) = match spec.split_once(char::is_whitespace) {
50 Some((head, rest)) => (head, Some(rest.trim().to_string())),
51 None => (spec, None),
52 };
53
54 let (repo_path, custom_rev) = match head.split_once(':') {
55 Some((path, rev)) => (path, Some(rev)),
56 None => (head, None),
57 };
58
59 let (org, repo) = repo_path.split_once('/').ok_or_else(|| {
60 eyre::eyre!("Invalid repo format '{spec}'. Expected 'org/repo' or 'org/repo:rev'")
61 })?;
62
63 let mut config = BENCHMARK_REPOS
66 .iter()
67 .find(|r| r.org == org && r.repo == repo)
68 .cloned()
69 .unwrap_or_else(|| Self {
70 name: format!("{org}-{repo}"),
71 org: org.to_string(),
72 repo: repo.to_string(),
73 rev: "main".to_string(),
74 extra_args: None,
75 });
76
77 if let Some(rev) = custom_rev {
78 config.rev = rev.to_string();
79 }
80 config.extra_args = extra_args;
81
82 let _ = sh_println!("Parsed repo spec '{spec}' -> {config:?}");
83 Ok(config)
84 }
85}
86
87pub fn default_benchmark_repos() -> Vec<RepoConfig> {
89 vec![
90 RepoConfig {
91 name: "ithacaxyz-account".to_string(),
92 org: "ithacaxyz".to_string(),
93 repo: "account".to_string(),
94 rev: "main".to_string(),
95 extra_args: None,
96 },
97 RepoConfig {
98 name: "solady".to_string(),
99 org: "Vectorized".to_string(),
100 repo: "solady".to_string(),
101 rev: "main".to_string(),
102 extra_args: None,
103 },
104 ]
105}
106
107pub static BENCHMARK_REPOS: Lazy<Vec<RepoConfig>> = Lazy::new(default_benchmark_repos);
109
110pub static FOUNDRY_VERSIONS: &[&str] = &["stable", "nightly"];
126
127pub struct BenchmarkProject {
129 pub name: String,
130 pub temp_project: TempProject,
131 pub root_path: PathBuf,
132 pub extra_args: Option<String>,
134 pub org: String,
135 pub repo: String,
136 pub revision: String,
137}
138
139impl BenchmarkProject {
140 #[allow(unused_must_use)]
142 pub fn setup(config: &RepoConfig) -> Result<Self> {
143 let temp_project =
144 TempProject::dapptools().wrap_err("Failed to create temporary project")?;
145
146 let root_path = temp_project.root().to_path_buf();
148 let root = root_path.to_str().unwrap();
149
150 for entry in std::fs::read_dir(&root_path)? {
152 let entry = entry?;
153 let path = entry.path();
154 if path.is_dir() {
155 std::fs::remove_dir_all(&path).ok();
156 } else {
157 std::fs::remove_file(&path).ok();
158 }
159 }
160
161 let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo);
163 clone_remote(&repo_url, root, true);
164
165 if !config.rev.is_empty() && config.rev != "main" && config.rev != "master" {
167 let status = Command::new("git")
168 .current_dir(root)
169 .args(["checkout", &config.rev])
170 .status()
171 .wrap_err("Failed to checkout revision")?;
172
173 if !status.success() {
174 eyre::bail!("Git checkout failed for {}", config.name);
175 }
176 }
177
178 Self::install_npm_dependencies(&root_path)?;
181
182 sh_println!(" ✅ Project {} setup complete at {}", config.name, root);
183 let revision = String::from_utf8(
184 Command::new("git")
185 .current_dir(&root_path)
186 .args(["rev-parse", "HEAD"])
187 .output()?
188 .stdout,
189 )?
190 .trim()
191 .to_string();
192 Ok(Self {
193 name: config.name.clone(),
194 root_path,
195 temp_project,
196 extra_args: config.extra_args.clone(),
197 org: config.org.clone(),
198 repo: config.repo.clone(),
199 revision,
200 })
201 }
202
203 fn cmd(&self, base: &str) -> String {
205 match self.extra_args.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
206 Some(extra) => format!("{base} {extra}"),
207 None => base.to_string(),
208 }
209 }
210
211 #[allow(unused_must_use)]
213 fn install_npm_dependencies(root: &Path) -> Result<()> {
214 if root.join("package.json").exists() {
215 sh_println!(" 📦 Running npm install...");
216 let status = Command::new("npm")
217 .current_dir(root)
218 .args(["install"])
219 .stdout(std::process::Stdio::inherit())
220 .stderr(std::process::Stdio::inherit())
221 .status()
222 .wrap_err("Failed to run npm install")?;
223
224 if status.success() {
225 sh_println!(" ✅ npm install completed successfully");
226 } else {
227 sh_println!(
228 " ⚠️ Warning: npm install failed with exit code: {:?}",
229 status.code()
230 );
231 }
232 }
233 Ok(())
234 }
235
236 #[allow(clippy::too_many_arguments)]
257 fn hyperfine(
258 &self,
259 benchmark_name: &str,
260 version: &str,
261 command: &str,
262 runs: u32,
263 setup: Option<&str>,
264 prepare: Option<&str>,
265 conclude: Option<&str>,
266 verbose: bool,
267 ) -> Result<HyperfineResult> {
268 let temp_dir = std::env::temp_dir();
271 let json_dir =
272 temp_dir.join("foundry-bench").join(benchmark_name).join(version).join(&self.name);
273 std::fs::create_dir_all(&json_dir)?;
274
275 let json_path = json_dir.join(format!("{benchmark_name}.json"));
276
277 let mut hyperfine_cmd = Command::new("hyperfine");
279 hyperfine_cmd
280 .current_dir(&self.root_path)
281 .arg("--runs")
282 .arg(runs.to_string())
283 .arg("--export-json")
284 .arg(&json_path)
285 .arg("--shell=bash");
286
287 if let Some(setup_cmd) = setup {
289 hyperfine_cmd.arg("--setup").arg(setup_cmd);
290 }
291
292 if let Some(prepare_cmd) = prepare {
294 hyperfine_cmd.arg("--prepare").arg(prepare_cmd);
295 }
296
297 if let Some(conclude_cmd) = conclude {
299 hyperfine_cmd.arg("--conclude").arg(conclude_cmd);
300 }
301
302 if verbose {
303 hyperfine_cmd.arg("--show-output");
304 hyperfine_cmd.stderr(std::process::Stdio::inherit());
305 hyperfine_cmd.stdout(std::process::Stdio::inherit());
306 }
307
308 hyperfine_cmd.arg(command);
310
311 let status = hyperfine_cmd.status().wrap_err("Failed to run hyperfine")?;
312 if !status.success() {
313 eyre::bail!("Hyperfine failed for command: {}", command);
314 }
315
316 let json_content = std::fs::read_to_string(json_path)?;
318 let output: HyperfineOutput = serde_json::from_str(&json_content)?;
319
320 output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine"))
322 }
323
324 pub fn bench_forge_test(
326 &self,
327 version: &str,
328 runs: u32,
329 verbose: bool,
330 ) -> Result<HyperfineResult> {
331 self.hyperfine(
333 "forge_test",
334 version,
335 &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge test"),
336 runs,
337 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"),
338 None,
339 None,
340 verbose,
341 )
342 }
343
344 pub fn bench_forge_test_filtered(
349 &self,
350 version: &str,
351 runs: u32,
352 verbose: bool,
353 ) -> Result<HyperfineResult> {
354 let command = self.cmd("forge test");
355 let setup = format!("FOUNDRY_FORCE=true {command} && {command}");
358 self.hyperfine(
359 "forge_test_filtered",
360 version,
361 &command,
362 runs,
363 Some(&setup),
364 None,
365 None,
366 verbose,
367 )
368 }
369
370 pub fn bench_forge_build_with_cache(
372 &self,
373 version: &str,
374 runs: u32,
375 verbose: bool,
376 ) -> Result<HyperfineResult> {
377 self.hyperfine(
378 "forge_build_with_cache",
379 version,
380 &self.cmd(
381 "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build",
382 ),
383 runs,
384 None,
385 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"),
386 None,
387 verbose,
388 )
389 }
390
391 pub fn bench_forge_build_no_cache(
393 &self,
394 version: &str,
395 runs: u32,
396 verbose: bool,
397 ) -> Result<HyperfineResult> {
398 self.hyperfine(
400 "forge_build_no_cache",
401 version,
402 &self.cmd(
403 "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build",
404 ),
405 runs,
406 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"),
407 None,
408 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"),
409 verbose,
410 )
411 }
412
413 pub fn bench_forge_fuzz_test(
415 &self,
416 version: &str,
417 runs: u32,
418 verbose: bool,
419 ) -> Result<HyperfineResult> {
420 self.hyperfine(
422 "forge_fuzz_test",
423 version,
424 &self.cmd(
425 r#"FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge test --match-test "test[^(]*\([^)]+\)""#,
426 ),
427 runs,
428 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"),
429 None,
430 None,
431 verbose,
432 )
433 }
434
435 pub fn bench_forge_coverage(
437 &self,
438 version: &str,
439 runs: u32,
440 verbose: bool,
441 ) -> Result<HyperfineResult> {
442 self.hyperfine(
444 "forge_coverage",
445 version,
446 &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge coverage"),
447 runs,
448 None,
449 None,
450 None,
451 verbose,
452 )
453 }
454
455 pub fn bench_forge_isolate_test(
457 &self,
458 version: &str,
459 runs: u32,
460 verbose: bool,
461 ) -> Result<HyperfineResult> {
462 self.hyperfine(
464 "forge_isolate_test",
465 version,
466 &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge test"),
467 runs,
468 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge build"),
469 None,
470 None,
471 verbose,
472 )
473 }
474
475 pub fn bench_forge_symbolic_test(
477 &self,
478 _version: &str,
479 runs: u32,
480 verbose: bool,
481 ) -> Result<HyperfineResult> {
482 let fixture = Fixture::identify(&self.org, &self.repo);
483 let command = self.cmd(&fixture.test_command());
484 let build_command = fixture.build_command();
485 let overlay = Overlay::install(&self.root_path, fixture)?;
486 let benchmark = (|| -> Result<HyperfineResult> {
487 let status = Command::new("bash")
488 .current_dir(&self.root_path)
489 .args(["-lc", &build_command])
490 .status()
491 .wrap_err("Failed to build project before symbolic benchmark")?;
492 if !status.success() {
493 eyre::bail!(
494 "forge build failed before symbolic benchmark with command: {}",
495 build_command
496 );
497 }
498
499 let mut times = Vec::with_capacity(runs as usize);
500 let mut samples = Vec::with_capacity(runs as usize);
501 let mut exit_codes = Vec::with_capacity(runs as usize);
502
503 for _ in 0..runs {
504 let started = Instant::now();
505 let output = Command::new("bash")
506 .current_dir(&self.root_path)
507 .args(["-lc", &command])
508 .output()
509 .wrap_err("Failed to run forge symbolic benchmark")?;
510 let elapsed = started.elapsed().as_secs_f64();
511 let exit_code = output.status.code().unwrap_or(-1);
512 if !matches!(exit_code, 0 | 1) {
513 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
514 eyre::bail!(
515 "forge symbolic benchmark exited abnormally with code {exit_code}: {command}"
516 );
517 }
518 times.push(elapsed);
519 exit_codes.push(exit_code);
520
521 if verbose {
522 let _ = sh_println!("{}", String::from_utf8_lossy(&output.stderr));
523 }
524
525 let run = match symbolic::parse(&output.stdout) {
526 Ok(summary) => summary,
527 Err(err) => {
528 if !output.status.success() {
529 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
530 eyre::bail!(
531 "forge symbolic benchmark failed with command: {command}; {err}"
532 );
533 }
534 return Err(err);
535 }
536 };
537 if !output.status.success() && verbose {
538 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
539 }
540 samples.push(Sample { wall_time_seconds: elapsed, exit_code, run });
541 }
542
543 let symbolic = samples
544 .get(median_index(×))
545 .map(|sample| symbolic::compatibility(&sample.run))
546 .ok_or_else(|| eyre::eyre!("symbolic benchmark produced no runs"))?;
547 let sidecar = Sidecar::new(
548 fixture,
549 &format!("{}/{}", self.org, self.repo),
550 &self.revision,
551 &build_command,
552 &command,
553 samples,
554 );
555
556 Ok(HyperfineResult {
557 command,
558 mean: mean(×),
559 stddev: stddev(×),
560 median: median(×),
561 user: 0.0,
562 system: 0.0,
563 min: times.iter().copied().reduce(f64::min).unwrap_or_default(),
564 max: times.iter().copied().reduce(f64::max).unwrap_or_default(),
565 times,
566 exit_codes: Some(exit_codes),
567 parameters: None,
568 symbolic: Some(symbolic),
569 symbolic_sidecar: Some(sidecar),
570 })
571 })();
572 let cleanup = overlay.finish();
573 match (benchmark, cleanup) {
574 (Ok(result), Ok(())) => Ok(result),
575 (Err(err), Ok(())) => Err(err),
576 (Ok(_), Err(cleanup_err)) => Err(cleanup_err),
577 (Err(err), Err(cleanup_err)) => Err(eyre::eyre!(
578 "{err}; additionally, symbolic fixture cleanup failed: {cleanup_err}"
579 )),
580 }
581 }
582
583 pub fn root(&self) -> &Path {
585 &self.root_path
586 }
587
588 pub fn run(
590 &self,
591 benchmark: &str,
592 version: &str,
593 runs: u32,
594 verbose: bool,
595 ) -> Result<HyperfineResult> {
596 match benchmark {
597 "forge_test" => self.bench_forge_test(version, runs, verbose),
598 "forge_test_filtered" => self.bench_forge_test_filtered(version, runs, verbose),
599 "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose),
600 "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose),
601 "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose),
602 "forge_coverage" => self.bench_forge_coverage(version, runs, verbose),
603 "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose),
604 "forge_symbolic_test" => self.bench_forge_symbolic_test(version, runs, verbose),
605 _ => {
606 eyre::bail!("Unknown benchmark: {}", benchmark);
607 }
608 }
609 }
610}
611
612fn mean(values: &[f64]) -> f64 {
613 if values.is_empty() {
614 return 0.0;
615 }
616 values.iter().sum::<f64>() / values.len() as f64
617}
618
619fn median(values: &[f64]) -> f64 {
620 values.get(median_index(values)).copied().unwrap_or_default()
621}
622
623fn median_index(values: &[f64]) -> usize {
624 let mut indices = (0..values.len()).collect::<Vec<_>>();
625 indices.sort_by(|&left, &right| values[left].total_cmp(&values[right]));
626 indices.get(indices.len() / 2).copied().unwrap_or_default()
627}
628
629fn stddev(values: &[f64]) -> Option<f64> {
630 if values.len() < 2 {
631 return None;
632 }
633 let mean = mean(values);
634 let variance =
635 values.iter().map(|value| (value - mean).powi(2)).sum::<f64>() / values.len() as f64;
636 Some(variance.sqrt())
637}
638
639const WORKSPACE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
642const WORKSPACE_ROOT_ENV: &str = "FOUNDRY_BENCH_WORKSPACE_ROOT";
643const LOCAL_BUILD_PROFILE_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_PROFILE";
644const LOCAL_BUILD_BINS_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_BINS";
645const DEFAULT_LOCAL_BUILD_PROFILE: &str = "dist";
646const FOUNDRY_BINS: [&str; 4] = ["forge", "cast", "anvil", "chisel"];
647
648pub fn parse_version_specs(specs: &[String]) -> Result<Vec<(String, Option<PathBuf>)>> {
651 let mut labels = HashSet::new();
652 specs
653 .iter()
654 .map(|spec| {
655 let spec = spec.trim();
656 let (name, source) = match spec.split_once('=') {
657 Some((name, path)) if !name.is_empty() && !path.is_empty() => {
658 (name, Some(PathBuf::from(path)))
659 }
660 Some(_) => {
661 eyre::bail!("invalid source version '{spec}'; expected name=path");
662 }
663 None => (spec, None),
664 };
665 if name.is_empty()
666 || name == "."
667 || name == ".."
668 || !name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
669 {
670 eyre::bail!(
671 "invalid version label '{name}'; use letters, numbers, '.', '_', or '-'"
672 );
673 }
674 if !labels.insert(name.to_string()) {
675 eyre::bail!("duplicate version label '{name}'");
676 }
677 Ok((name.to_string(), source))
678 })
679 .collect()
680}
681
682#[allow(unused_must_use)]
686pub fn switch_foundry_version(version: &str) -> Result<()> {
687 if version == "local" {
688 return install_local_workspace(&workspace_root()?);
689 }
690
691 let output = Command::new("foundryup")
692 .args(["--use", version])
693 .output()
694 .wrap_err("Failed to run foundryup")?;
695
696 let stderr = String::from_utf8_lossy(&output.stderr);
698 if stderr.contains("command failed") && stderr.contains("forge --version") {
699 eyre::bail!(
700 "Foundry binaries maybe corrupted. Please reinstall by running `foundryup --install <version>`"
701 );
702 }
703
704 if !output.status.success() {
705 sh_eprintln!("foundryup stderr: {stderr}");
706 eyre::bail!("Failed to switch to foundry version: {}", version);
707 }
708
709 sh_println!(" Successfully switched to version: {version}");
710 Ok(())
711}
712
713#[allow(unused_must_use)]
717pub fn install_local_workspace(workspace: &Path) -> Result<()> {
718 let profile = local_build_profile();
719 let bins = local_build_bins()?;
720 sh_println!(
721 " Building local workspace at {} with {} profile for {}",
722 workspace.display(),
723 profile.to_string_lossy(),
724 bins.join(", ")
725 );
726
727 let mut cmd = Command::new("cargo");
728 cmd.current_dir(workspace).args(["build", "--locked", "--profile"]).arg(&profile);
729 for bin in &bins {
730 cmd.args(["--bin", bin]);
731 }
732
733 let status = cmd.status().wrap_err("Failed to build local Foundry workspace")?;
734
735 if !status.success() {
736 eyre::bail!("local Foundry build failed");
737 }
738
739 activate_local_binaries(workspace, &profile, &bins)?;
740 sh_println!(" Successfully activated local {} build", profile.to_string_lossy());
741 Ok(())
742}
743
744fn workspace_root() -> Result<PathBuf> {
745 let workspace = env::var_os(WORKSPACE_ROOT_ENV)
746 .map(PathBuf::from)
747 .unwrap_or_else(|| PathBuf::from(WORKSPACE_ROOT));
748 std::fs::canonicalize(&workspace)
749 .wrap_err_with(|| format!("Failed to resolve workspace root {}", workspace.display()))
750}
751
752fn local_build_profile() -> std::ffi::OsString {
753 env::var_os(LOCAL_BUILD_PROFILE_ENV)
754 .filter(|profile| !profile.is_empty())
755 .unwrap_or_else(|| DEFAULT_LOCAL_BUILD_PROFILE.into())
756}
757
758fn local_build_bins() -> Result<Vec<String>> {
759 let Some(raw_bins) = env::var_os(LOCAL_BUILD_BINS_ENV).filter(|bins| !bins.is_empty()) else {
760 return Ok(FOUNDRY_BINS.into_iter().map(String::from).collect());
761 };
762
763 let bins = raw_bins
764 .to_string_lossy()
765 .split(|c: char| c == ',' || c.is_ascii_whitespace())
766 .filter(|bin| !bin.is_empty())
767 .map(str::to_owned)
768 .collect::<Vec<_>>();
769
770 if bins.is_empty() {
771 eyre::bail!("{LOCAL_BUILD_BINS_ENV} did not contain any binary names");
772 }
773
774 Ok(bins)
775}
776
777fn activate_local_binaries(
778 workspace: &Path,
779 profile: &std::ffi::OsStr,
780 bins: &[String],
781) -> Result<()> {
782 let bin_dir = foundry_bin_dir()?;
783 fs::create_dir_all(&bin_dir).wrap_err_with(|| {
784 format!("Failed to create Foundry bin directory at {}", bin_dir.display())
785 })?;
786
787 let local_bin_dir = workspace.join("target").join(profile);
788 for bin in bins {
789 let bin_name = format!("{bin}{}", env::consts::EXE_SUFFIX);
790 let source = local_bin_dir.join(&bin_name);
791 let destination = bin_dir.join(&bin_name);
792
793 if !source.exists() {
794 eyre::bail!("local Foundry binary not found at {}", source.display());
795 }
796
797 if fs::symlink_metadata(&destination).is_ok() {
798 fs::remove_file(&destination).wrap_err_with(|| {
799 format!("Failed to remove existing binary at {}", destination.display())
800 })?;
801 }
802
803 fs::copy(&source, &destination).wrap_err_with(|| {
804 format!("Failed to activate local binary {}", destination.display())
805 })?;
806 }
807
808 Ok(())
809}
810
811fn foundry_bin_dir() -> Result<PathBuf> {
812 if let Some(foundry_dir) = env::var_os("FOUNDRY_DIR") {
813 return Ok(PathBuf::from(foundry_dir).join("bin"));
814 }
815
816 let base_dir = env::var_os("XDG_CONFIG_HOME")
817 .or_else(|| env::var_os("HOME"))
818 .map(PathBuf::from)
819 .ok_or_else(|| eyre::eyre!("Neither FOUNDRY_DIR, XDG_CONFIG_HOME, nor HOME is set"))?;
820
821 Ok(base_dir.join(".foundry").join("bin"))
822}
823
824pub fn get_forge_version() -> Result<String> {
826 let output = Command::new("forge")
827 .args(["--version"])
828 .output()
829 .wrap_err("Failed to get forge version")?;
830
831 if !output.status.success() {
832 eyre::bail!("forge --version failed");
833 }
834
835 let version =
836 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
837
838 Ok(version.lines().next().unwrap_or("unknown").to_string())
839}
840
841pub fn get_forge_commit() -> Result<String> {
843 let output = Command::new("forge")
844 .args(["--version"])
845 .output()
846 .wrap_err("Failed to get forge version")?;
847 if !output.status.success() {
848 eyre::bail!("forge --version failed");
849 }
850 let output =
851 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
852 parse_forge_commit(&output)
853 .map(str::to_owned)
854 .ok_or_else(|| eyre::eyre!("forge --version did not report a commit"))
855}
856
857fn parse_forge_commit(output: &str) -> Option<&str> {
858 output
859 .lines()
860 .find_map(|line| line.trim().strip_prefix("Commit SHA: "))
861 .filter(|commit| !commit.is_empty())
862 .or_else(|| {
863 output
864 .lines()
865 .next()?
866 .split_once('(')?
867 .1
868 .split_whitespace()
869 .next()
870 .filter(|commit| !commit.is_empty())
871 })
872}
873
874pub fn get_forge_version_details() -> Result<String> {
876 let output = Command::new("forge")
877 .args(["--version"])
878 .output()
879 .wrap_err("Failed to get forge version")?;
880
881 if !output.status.success() {
882 eyre::bail!("forge --version failed");
883 }
884
885 let full_output =
886 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
887
888 let lines: Vec<&str> = full_output.lines().collect();
890 if lines.len() >= 3 {
891 let version = lines[0].trim();
893 let commit = lines[1].trim().replace("Commit SHA: ", "");
894 let timestamp = lines[2].trim().replace("Build Timestamp: ", "");
895
896 let short_commit = &commit[..7]; let date = timestamp.split('T').next().unwrap_or(×tamp);
899
900 Ok(format!("{version} ({short_commit} {date})"))
901 } else {
902 Ok(lines.first().unwrap_or(&"unknown").to_string())
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910
911 #[test]
912 fn parses_and_validates_version_specs() {
913 let specs = vec!["stable".to_string(), "master=../foundry-baseline".to_string()];
914 assert_eq!(
915 parse_version_specs(&specs).unwrap(),
916 vec![
917 ("stable".to_string(), None),
918 ("master".to_string(), Some(PathBuf::from("../foundry-baseline")))
919 ]
920 );
921
922 assert!(
923 parse_version_specs(&["local".to_string(), "local=/tmp/foundry".to_string()]).is_err()
924 );
925 assert!(parse_version_specs(&["../master=/tmp/foundry".to_string()]).is_err());
926 assert!(parse_version_specs(&["master=".to_string()]).is_err());
927 }
928
929 #[test]
930 fn parses_modern_and_legacy_forge_commits() {
931 assert_eq!(
932 parse_forge_commit("forge Version: 1.3.1\nCommit SHA: abcdef123456\n"),
933 Some("abcdef123456")
934 );
935 assert_eq!(
936 parse_forge_commit("forge 0.2.0 (123456abcdef 2023-01-01T00:00:00Z)"),
937 Some("123456abcdef")
938 );
939 }
940}