Skip to main content

foundry_bench/
lib.rs

1//! Foundry benchmark runner.
2
3use 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
24/// Default number of runs for benchmarks
25pub const RUNS: u32 = 5;
26
27/// Configuration for repositories to benchmark
28#[derive(Debug, Clone)]
29pub struct RepoConfig {
30    pub name: String,
31    pub org: String,
32    pub repo: String,
33    pub rev: String,
34    /// Optional extra arguments appended to every benchmark command for this
35    /// repo (e.g. `--nmc BrokenTest` to skip a broken test contract).
36    pub extra_args: Option<String>,
37}
38
39impl FromStr for RepoConfig {
40    type Err = eyre::Error;
41
42    /// Parse a repo spec of the form `org/repo[:rev][ <extra args...>]`.
43    ///
44    /// Anything after the first whitespace is treated as extra arguments
45    /// appended to every benchmark command for this repo.
46    fn from_str(spec: &str) -> Result<Self> {
47        let spec = spec.trim();
48        // Anything after the first whitespace is per-repo extra args.
49        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        // Inherit defaults from BENCHMARK_REPOS when available, otherwise build
64        // a fresh config. Custom rev / extra args always override.
65        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
87/// Available repositories for benchmarking
88pub 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
107// Keep a lazy static for compatibility
108pub static BENCHMARK_REPOS: Lazy<Vec<RepoConfig>> = Lazy::new(default_benchmark_repos);
109
110/// Foundry versions to benchmark
111///
112/// To add more versions for comparison, install them first:
113/// ```bash
114/// foundryup --install stable
115/// foundryup --install nightly
116/// foundryup --install v0.2.0  # Example specific version
117/// ```
118///
119/// Then add the version strings to this array. Supported formats:
120/// - "stable" - Latest stable release
121/// - "nightly" - Latest nightly build
122/// - "v0.2.0" - Specific version tag
123/// - "commit-hash" - Specific commit hash
124/// - "nightly-rev" - Nightly build with specific revision
125pub static FOUNDRY_VERSIONS: &[&str] = &["stable", "nightly"];
126
127/// A benchmark project that represents a cloned repository ready for testing
128pub struct BenchmarkProject {
129    pub name: String,
130    pub temp_project: TempProject,
131    pub root_path: PathBuf,
132    /// Optional extra arguments appended to every benchmark command.
133    pub extra_args: Option<String>,
134    pub org: String,
135    pub repo: String,
136    pub revision: String,
137}
138
139impl BenchmarkProject {
140    /// Set up a benchmark project by cloning the repository
141    #[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        // Get root path before clearing
147        let root_path = temp_project.root().to_path_buf();
148        let root = root_path.to_str().unwrap();
149
150        // Remove all files in the directory
151        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        // Clone the repository
162        let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo);
163        clone_remote(&repo_url, root, true);
164
165        // Checkout specific revision if provided
166        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        // Git submodules are already cloned via --recursive flag
179        // But npm dependencies still need to be installed
180        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    /// Append `self.extra_args` to a benchmark shell command, if any.
204    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    /// Install npm dependencies if package.json exists
212    #[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    /// Run a command with hyperfine and return the results
237    ///
238    /// # Arguments
239    /// * `benchmark_name` - Name of the benchmark for organizing output
240    /// * `version` - Foundry version being benchmarked
241    /// * `command` - The command to benchmark
242    /// * `runs` - Number of runs to perform
243    /// * `setup` - Optional setup command to run before the benchmark series (e.g., "forge build")
244    /// * `prepare` - Optional prepare command to run before each timing run (e.g., "forge clean")
245    /// * `conclude` - Optional conclude command to run after each timing run (e.g., cleanup)
246    /// * `verbose` - Whether to show command output
247    ///
248    /// # Hyperfine flags used:
249    /// * `--runs` - Number of timing runs
250    /// * `--setup` - Execute before the benchmark series (not before each run)
251    /// * `--prepare` - Execute before each timing run
252    /// * `--conclude` - Execute after each timing run
253    /// * `--export-json` - Export results to JSON for parsing
254    /// * `--shell=bash` - Use bash for shell command execution
255    /// * `--show-output` - Show command output (when verbose)
256    #[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        // Create structured temp directory for JSON output
269        // Format: <temp_dir>/<benchmark_name>/<version>/<repo_name>/<benchmark_name>.json
270        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        // Build hyperfine command
278        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        // Add optional setup command
288        if let Some(setup_cmd) = setup {
289            hyperfine_cmd.arg("--setup").arg(setup_cmd);
290        }
291
292        // Add optional prepare command
293        if let Some(prepare_cmd) = prepare {
294            hyperfine_cmd.arg("--prepare").arg(prepare_cmd);
295        }
296
297        // Add optional conclude command
298        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        // Add the benchmark command last
309        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        // Read and parse the JSON output
317        let json_content = std::fs::read_to_string(json_path)?;
318        let output: HyperfineOutput = serde_json::from_str(&json_content)?;
319
320        // Extract the first result (we only run one command at a time)
321        output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine"))
322    }
323
324    /// Benchmark forge test without isolation.
325    pub fn bench_forge_test(
326        &self,
327        version: &str,
328        runs: u32,
329        verbose: bool,
330    ) -> Result<HyperfineResult> {
331        // Build before running tests
332        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    /// Benchmarks tests after cleaning once and warming the selected workload.
345    ///
346    /// Keeps the project's dynamic linking configuration. Effective CLI or configuration
347    /// filters are needed to exercise partial-cache discovery.
348    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        // Force cleanup in the first warmup using the same root/configuration as the tests.
356        // The second invocation warms discovery against the resulting normal artifacts.
357        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    /// Benchmark forge build with cache
371    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    /// Benchmark forge build without cache
392    pub fn bench_forge_build_no_cache(
393        &self,
394        version: &str,
395        runs: u32,
396        verbose: bool,
397    ) -> Result<HyperfineResult> {
398        // Clean before each timing run
399        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    /// Benchmark forge fuzz tests without isolation.
414    pub fn bench_forge_fuzz_test(
415        &self,
416        version: &str,
417        runs: u32,
418        verbose: bool,
419    ) -> Result<HyperfineResult> {
420        // Build before running fuzz tests
421        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    /// Benchmark forge coverage
436    pub fn bench_forge_coverage(
437        &self,
438        version: &str,
439        runs: u32,
440        verbose: bool,
441    ) -> Result<HyperfineResult> {
442        // No setup needed, forge coverage builds internally.
443        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    /// Benchmark forge test with isolate mode
456    pub fn bench_forge_isolate_test(
457        &self,
458        version: &str,
459        runs: u32,
460        verbose: bool,
461    ) -> Result<HyperfineResult> {
462        // Build before running tests
463        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    /// Benchmark focused symbolic checks and collect symbolic solver counters.
476    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(&times))
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(&times),
559                stddev: stddev(&times),
560                median: median(&times),
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    /// Get the root path of the project
584    pub fn root(&self) -> &Path {
585        &self.root_path
586    }
587
588    /// Run a specific benchmark by name
589    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
639/// The workspace root, embedded at compile time.
640/// `benches/` is one level below the workspace root.
641const 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
648/// Parse `--versions` entries into unique display names and optional source
649/// workspaces. `name=path` builds Foundry from `path` and labels it `name`.
650pub 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/// Switch to a specific foundry version.
683///
684/// The special keyword `local` builds and activates the current workspace.
685#[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    // Check if the error is about forge --version failing
697    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/// Build and activate the shipped Foundry binaries from an explicit workspace,
714/// without linking unused workspace binaries. Used to benchmark a baseline ref
715/// checked out into a separate worktree.
716#[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
824/// Get the current forge version
825pub 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
841/// Get the commit of the active Forge binary.
842pub 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
874/// Get the full forge version details including commit hash and date
875pub 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    // Extract relevant lines and format them
889    let lines: Vec<&str> = full_output.lines().collect();
890    if lines.len() >= 3 {
891        // Extract version, commit, and timestamp
892        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        // Format as: "forge 1.2.3-nightly (51650ea 2025-06-27)"
897        let short_commit = &commit[..7]; // First 7 chars of commit hash
898        let date = timestamp.split('T').next().unwrap_or(&timestamp);
899
900        Ok(format!("{version} ({short_commit} {date})"))
901    } else {
902        // Fallback to just the first line if format is unexpected
903        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}