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_build_with_cache(
346 &self,
347 version: &str,
348 runs: u32,
349 verbose: bool,
350 ) -> Result<HyperfineResult> {
351 self.hyperfine(
352 "forge_build_with_cache",
353 version,
354 &self.cmd(
355 "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build",
356 ),
357 runs,
358 None,
359 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"),
360 None,
361 verbose,
362 )
363 }
364
365 pub fn bench_forge_build_no_cache(
367 &self,
368 version: &str,
369 runs: u32,
370 verbose: bool,
371 ) -> Result<HyperfineResult> {
372 self.hyperfine(
374 "forge_build_no_cache",
375 version,
376 &self.cmd(
377 "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build",
378 ),
379 runs,
380 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"),
381 None,
382 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"),
383 verbose,
384 )
385 }
386
387 pub fn bench_forge_fuzz_test(
389 &self,
390 version: &str,
391 runs: u32,
392 verbose: bool,
393 ) -> Result<HyperfineResult> {
394 self.hyperfine(
396 "forge_fuzz_test",
397 version,
398 &self.cmd(
399 r#"FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge test --match-test "test[^(]*\([^)]+\)""#,
400 ),
401 runs,
402 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"),
403 None,
404 None,
405 verbose,
406 )
407 }
408
409 pub fn bench_forge_coverage(
411 &self,
412 version: &str,
413 runs: u32,
414 verbose: bool,
415 ) -> Result<HyperfineResult> {
416 self.hyperfine(
419 "forge_coverage",
420 version,
421 &self.cmd(
422 "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge coverage --ir-minimum",
423 ),
424 runs,
425 None,
426 None,
427 None,
428 verbose,
429 )
430 }
431
432 pub fn bench_forge_isolate_test(
434 &self,
435 version: &str,
436 runs: u32,
437 verbose: bool,
438 ) -> Result<HyperfineResult> {
439 self.hyperfine(
441 "forge_isolate_test",
442 version,
443 &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge test"),
444 runs,
445 Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge build"),
446 None,
447 None,
448 verbose,
449 )
450 }
451
452 pub fn bench_forge_symbolic_test(
454 &self,
455 _version: &str,
456 runs: u32,
457 verbose: bool,
458 ) -> Result<HyperfineResult> {
459 let fixture = Fixture::identify(&self.org, &self.repo);
460 let command = self.cmd(&fixture.test_command());
461 let build_command = fixture.build_command();
462 let overlay = Overlay::install(&self.root_path, fixture)?;
463 let benchmark = (|| -> Result<HyperfineResult> {
464 let status = Command::new("bash")
465 .current_dir(&self.root_path)
466 .args(["-lc", &build_command])
467 .status()
468 .wrap_err("Failed to build project before symbolic benchmark")?;
469 if !status.success() {
470 eyre::bail!(
471 "forge build failed before symbolic benchmark with command: {}",
472 build_command
473 );
474 }
475
476 let mut times = Vec::with_capacity(runs as usize);
477 let mut samples = Vec::with_capacity(runs as usize);
478 let mut exit_codes = Vec::with_capacity(runs as usize);
479
480 for _ in 0..runs {
481 let started = Instant::now();
482 let output = Command::new("bash")
483 .current_dir(&self.root_path)
484 .args(["-lc", &command])
485 .output()
486 .wrap_err("Failed to run forge symbolic benchmark")?;
487 let elapsed = started.elapsed().as_secs_f64();
488 let exit_code = output.status.code().unwrap_or(-1);
489 if !matches!(exit_code, 0 | 1) {
490 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
491 eyre::bail!(
492 "forge symbolic benchmark exited abnormally with code {exit_code}: {command}"
493 );
494 }
495 times.push(elapsed);
496 exit_codes.push(exit_code);
497
498 if verbose {
499 let _ = sh_println!("{}", String::from_utf8_lossy(&output.stderr));
500 }
501
502 let run = match symbolic::parse(&output.stdout) {
503 Ok(summary) => summary,
504 Err(err) => {
505 if !output.status.success() {
506 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
507 eyre::bail!(
508 "forge symbolic benchmark failed with command: {command}; {err}"
509 );
510 }
511 return Err(err);
512 }
513 };
514 if !output.status.success() && verbose {
515 let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr));
516 }
517 samples.push(Sample { wall_time_seconds: elapsed, exit_code, run });
518 }
519
520 let symbolic = samples
521 .get(median_index(×))
522 .map(|sample| symbolic::compatibility(&sample.run))
523 .ok_or_else(|| eyre::eyre!("symbolic benchmark produced no runs"))?;
524 let sidecar = Sidecar::new(
525 fixture,
526 &format!("{}/{}", self.org, self.repo),
527 &self.revision,
528 &build_command,
529 &command,
530 samples,
531 );
532
533 Ok(HyperfineResult {
534 command,
535 mean: mean(×),
536 stddev: stddev(×),
537 median: median(×),
538 user: 0.0,
539 system: 0.0,
540 min: times.iter().copied().reduce(f64::min).unwrap_or_default(),
541 max: times.iter().copied().reduce(f64::max).unwrap_or_default(),
542 times,
543 exit_codes: Some(exit_codes),
544 parameters: None,
545 symbolic: Some(symbolic),
546 symbolic_sidecar: Some(sidecar),
547 })
548 })();
549 let cleanup = overlay.finish();
550 match (benchmark, cleanup) {
551 (Ok(result), Ok(())) => Ok(result),
552 (Err(err), Ok(())) => Err(err),
553 (Ok(_), Err(cleanup_err)) => Err(cleanup_err),
554 (Err(err), Err(cleanup_err)) => Err(eyre::eyre!(
555 "{err}; additionally, symbolic fixture cleanup failed: {cleanup_err}"
556 )),
557 }
558 }
559
560 pub fn root(&self) -> &Path {
562 &self.root_path
563 }
564
565 pub fn run(
567 &self,
568 benchmark: &str,
569 version: &str,
570 runs: u32,
571 verbose: bool,
572 ) -> Result<HyperfineResult> {
573 match benchmark {
574 "forge_test" => self.bench_forge_test(version, runs, verbose),
575 "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose),
576 "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose),
577 "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose),
578 "forge_coverage" => self.bench_forge_coverage(version, runs, verbose),
579 "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose),
580 "forge_symbolic_test" => self.bench_forge_symbolic_test(version, runs, verbose),
581 _ => {
582 eyre::bail!("Unknown benchmark: {}", benchmark);
583 }
584 }
585 }
586}
587
588fn mean(values: &[f64]) -> f64 {
589 if values.is_empty() {
590 return 0.0;
591 }
592 values.iter().sum::<f64>() / values.len() as f64
593}
594
595fn median(values: &[f64]) -> f64 {
596 values.get(median_index(values)).copied().unwrap_or_default()
597}
598
599fn median_index(values: &[f64]) -> usize {
600 let mut indices = (0..values.len()).collect::<Vec<_>>();
601 indices.sort_by(|&left, &right| values[left].total_cmp(&values[right]));
602 indices.get(indices.len() / 2).copied().unwrap_or_default()
603}
604
605fn stddev(values: &[f64]) -> Option<f64> {
606 if values.len() < 2 {
607 return None;
608 }
609 let mean = mean(values);
610 let variance =
611 values.iter().map(|value| (value - mean).powi(2)).sum::<f64>() / values.len() as f64;
612 Some(variance.sqrt())
613}
614
615const WORKSPACE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
618const WORKSPACE_ROOT_ENV: &str = "FOUNDRY_BENCH_WORKSPACE_ROOT";
619const LOCAL_BUILD_PROFILE_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_PROFILE";
620const LOCAL_BUILD_BINS_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_BINS";
621const DEFAULT_LOCAL_BUILD_PROFILE: &str = "dist";
622const FOUNDRY_BINS: [&str; 4] = ["forge", "cast", "anvil", "chisel"];
623
624pub fn parse_version_specs(specs: &[String]) -> Result<Vec<(String, Option<PathBuf>)>> {
627 let mut labels = HashSet::new();
628 specs
629 .iter()
630 .map(|spec| {
631 let spec = spec.trim();
632 let (name, source) = match spec.split_once('=') {
633 Some((name, path)) if !name.is_empty() && !path.is_empty() => {
634 (name, Some(PathBuf::from(path)))
635 }
636 Some(_) => {
637 eyre::bail!("invalid source version '{spec}'; expected name=path");
638 }
639 None => (spec, None),
640 };
641 if name.is_empty()
642 || name == "."
643 || name == ".."
644 || !name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
645 {
646 eyre::bail!(
647 "invalid version label '{name}'; use letters, numbers, '.', '_', or '-'"
648 );
649 }
650 if !labels.insert(name.to_string()) {
651 eyre::bail!("duplicate version label '{name}'");
652 }
653 Ok((name.to_string(), source))
654 })
655 .collect()
656}
657
658#[allow(unused_must_use)]
662pub fn switch_foundry_version(version: &str) -> Result<()> {
663 if version == "local" {
664 return install_local_workspace(&workspace_root()?);
665 }
666
667 let output = Command::new("foundryup")
668 .args(["--use", version])
669 .output()
670 .wrap_err("Failed to run foundryup")?;
671
672 let stderr = String::from_utf8_lossy(&output.stderr);
674 if stderr.contains("command failed") && stderr.contains("forge --version") {
675 eyre::bail!(
676 "Foundry binaries maybe corrupted. Please reinstall by running `foundryup --install <version>`"
677 );
678 }
679
680 if !output.status.success() {
681 sh_eprintln!("foundryup stderr: {stderr}");
682 eyre::bail!("Failed to switch to foundry version: {}", version);
683 }
684
685 sh_println!(" Successfully switched to version: {version}");
686 Ok(())
687}
688
689#[allow(unused_must_use)]
693pub fn install_local_workspace(workspace: &Path) -> Result<()> {
694 let profile = local_build_profile();
695 let bins = local_build_bins()?;
696 sh_println!(
697 " Building local workspace at {} with {} profile for {}",
698 workspace.display(),
699 profile.to_string_lossy(),
700 bins.join(", ")
701 );
702
703 let mut cmd = Command::new("cargo");
704 cmd.current_dir(workspace).args(["build", "--locked", "--profile"]).arg(&profile);
705 for bin in &bins {
706 cmd.args(["--bin", bin]);
707 }
708
709 let status = cmd.status().wrap_err("Failed to build local Foundry workspace")?;
710
711 if !status.success() {
712 eyre::bail!("local Foundry build failed");
713 }
714
715 activate_local_binaries(workspace, &profile, &bins)?;
716 sh_println!(" Successfully activated local {} build", profile.to_string_lossy());
717 Ok(())
718}
719
720fn workspace_root() -> Result<PathBuf> {
721 let workspace = env::var_os(WORKSPACE_ROOT_ENV)
722 .map(PathBuf::from)
723 .unwrap_or_else(|| PathBuf::from(WORKSPACE_ROOT));
724 std::fs::canonicalize(&workspace)
725 .wrap_err_with(|| format!("Failed to resolve workspace root {}", workspace.display()))
726}
727
728fn local_build_profile() -> std::ffi::OsString {
729 env::var_os(LOCAL_BUILD_PROFILE_ENV)
730 .filter(|profile| !profile.is_empty())
731 .unwrap_or_else(|| DEFAULT_LOCAL_BUILD_PROFILE.into())
732}
733
734fn local_build_bins() -> Result<Vec<String>> {
735 let Some(raw_bins) = env::var_os(LOCAL_BUILD_BINS_ENV).filter(|bins| !bins.is_empty()) else {
736 return Ok(FOUNDRY_BINS.into_iter().map(String::from).collect());
737 };
738
739 let bins = raw_bins
740 .to_string_lossy()
741 .split(|c: char| c == ',' || c.is_ascii_whitespace())
742 .filter(|bin| !bin.is_empty())
743 .map(str::to_owned)
744 .collect::<Vec<_>>();
745
746 if bins.is_empty() {
747 eyre::bail!("{LOCAL_BUILD_BINS_ENV} did not contain any binary names");
748 }
749
750 Ok(bins)
751}
752
753fn activate_local_binaries(
754 workspace: &Path,
755 profile: &std::ffi::OsStr,
756 bins: &[String],
757) -> Result<()> {
758 let bin_dir = foundry_bin_dir()?;
759 fs::create_dir_all(&bin_dir).wrap_err_with(|| {
760 format!("Failed to create Foundry bin directory at {}", bin_dir.display())
761 })?;
762
763 let local_bin_dir = workspace.join("target").join(profile);
764 for bin in bins {
765 let bin_name = format!("{bin}{}", env::consts::EXE_SUFFIX);
766 let source = local_bin_dir.join(&bin_name);
767 let destination = bin_dir.join(&bin_name);
768
769 if !source.exists() {
770 eyre::bail!("local Foundry binary not found at {}", source.display());
771 }
772
773 if fs::symlink_metadata(&destination).is_ok() {
774 fs::remove_file(&destination).wrap_err_with(|| {
775 format!("Failed to remove existing binary at {}", destination.display())
776 })?;
777 }
778
779 fs::copy(&source, &destination).wrap_err_with(|| {
780 format!("Failed to activate local binary {}", destination.display())
781 })?;
782 }
783
784 Ok(())
785}
786
787fn foundry_bin_dir() -> Result<PathBuf> {
788 if let Some(foundry_dir) = env::var_os("FOUNDRY_DIR") {
789 return Ok(PathBuf::from(foundry_dir).join("bin"));
790 }
791
792 let base_dir = env::var_os("XDG_CONFIG_HOME")
793 .or_else(|| env::var_os("HOME"))
794 .map(PathBuf::from)
795 .ok_or_else(|| eyre::eyre!("Neither FOUNDRY_DIR, XDG_CONFIG_HOME, nor HOME is set"))?;
796
797 Ok(base_dir.join(".foundry").join("bin"))
798}
799
800pub fn get_forge_version() -> Result<String> {
802 let output = Command::new("forge")
803 .args(["--version"])
804 .output()
805 .wrap_err("Failed to get forge version")?;
806
807 if !output.status.success() {
808 eyre::bail!("forge --version failed");
809 }
810
811 let version =
812 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
813
814 Ok(version.lines().next().unwrap_or("unknown").to_string())
815}
816
817pub fn get_forge_commit() -> Result<String> {
819 let output = Command::new("forge")
820 .args(["--version"])
821 .output()
822 .wrap_err("Failed to get forge version")?;
823 if !output.status.success() {
824 eyre::bail!("forge --version failed");
825 }
826 let output =
827 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
828 parse_forge_commit(&output)
829 .map(str::to_owned)
830 .ok_or_else(|| eyre::eyre!("forge --version did not report a commit"))
831}
832
833fn parse_forge_commit(output: &str) -> Option<&str> {
834 output
835 .lines()
836 .find_map(|line| line.trim().strip_prefix("Commit SHA: "))
837 .filter(|commit| !commit.is_empty())
838 .or_else(|| {
839 output
840 .lines()
841 .next()?
842 .split_once('(')?
843 .1
844 .split_whitespace()
845 .next()
846 .filter(|commit| !commit.is_empty())
847 })
848}
849
850pub fn get_forge_version_details() -> Result<String> {
852 let output = Command::new("forge")
853 .args(["--version"])
854 .output()
855 .wrap_err("Failed to get forge version")?;
856
857 if !output.status.success() {
858 eyre::bail!("forge --version failed");
859 }
860
861 let full_output =
862 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
863
864 let lines: Vec<&str> = full_output.lines().collect();
866 if lines.len() >= 3 {
867 let version = lines[0].trim();
869 let commit = lines[1].trim().replace("Commit SHA: ", "");
870 let timestamp = lines[2].trim().replace("Build Timestamp: ", "");
871
872 let short_commit = &commit[..7]; let date = timestamp.split('T').next().unwrap_or(×tamp);
875
876 Ok(format!("{version} ({short_commit} {date})"))
877 } else {
878 Ok(lines.first().unwrap_or(&"unknown").to_string())
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn parses_and_validates_version_specs() {
889 let specs = vec!["stable".to_string(), "master=../foundry-baseline".to_string()];
890 assert_eq!(
891 parse_version_specs(&specs).unwrap(),
892 vec![
893 ("stable".to_string(), None),
894 ("master".to_string(), Some(PathBuf::from("../foundry-baseline")))
895 ]
896 );
897
898 assert!(
899 parse_version_specs(&["local".to_string(), "local=/tmp/foundry".to_string()]).is_err()
900 );
901 assert!(parse_version_specs(&["../master=/tmp/foundry".to_string()]).is_err());
902 assert!(parse_version_specs(&["master=".to_string()]).is_err());
903 }
904
905 #[test]
906 fn parses_modern_and_legacy_forge_commits() {
907 assert_eq!(
908 parse_forge_commit("forge Version: 1.3.1\nCommit SHA: abcdef123456\n"),
909 Some("abcdef123456")
910 );
911 assert_eq!(
912 parse_forge_commit("forge 0.2.0 (123456abcdef 2023-01-01T00:00:00Z)"),
913 Some("123456abcdef")
914 );
915 }
916}