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    /// Benchmark forge build with cache
345    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    /// Benchmark forge build without cache
366    pub fn bench_forge_build_no_cache(
367        &self,
368        version: &str,
369        runs: u32,
370        verbose: bool,
371    ) -> Result<HyperfineResult> {
372        // Clean before each timing run
373        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    /// Benchmark forge fuzz tests without isolation.
388    pub fn bench_forge_fuzz_test(
389        &self,
390        version: &str,
391        runs: u32,
392        verbose: bool,
393    ) -> Result<HyperfineResult> {
394        // Build before running fuzz tests
395        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    /// Benchmark forge coverage
410    pub fn bench_forge_coverage(
411        &self,
412        version: &str,
413        runs: u32,
414        verbose: bool,
415    ) -> Result<HyperfineResult> {
416        // No setup needed, forge coverage builds internally
417        // Use --ir-minimum to avoid "Stack too deep" errors
418        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    /// Benchmark forge test with isolate mode
433    pub fn bench_forge_isolate_test(
434        &self,
435        version: &str,
436        runs: u32,
437        verbose: bool,
438    ) -> Result<HyperfineResult> {
439        // Build before running tests
440        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    /// Benchmark focused symbolic checks and collect symbolic solver counters.
453    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(&times))
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(&times),
536                stddev: stddev(&times),
537                median: median(&times),
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    /// Get the root path of the project
561    pub fn root(&self) -> &Path {
562        &self.root_path
563    }
564
565    /// Run a specific benchmark by name
566    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
615/// The workspace root, embedded at compile time.
616/// `benches/` is one level below the workspace root.
617const 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
624/// Parse `--versions` entries into unique display names and optional source
625/// workspaces. `name=path` builds Foundry from `path` and labels it `name`.
626pub 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/// Switch to a specific foundry version.
659///
660/// The special keyword `local` builds and activates the current workspace.
661#[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    // Check if the error is about forge --version failing
673    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/// Build and activate the shipped Foundry binaries from an explicit workspace,
690/// without linking unused workspace binaries. Used to benchmark a baseline ref
691/// checked out into a separate worktree.
692#[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
800/// Get the current forge version
801pub 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
817/// Get the commit of the active Forge binary.
818pub 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
850/// Get the full forge version details including commit hash and date
851pub 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    // Extract relevant lines and format them
865    let lines: Vec<&str> = full_output.lines().collect();
866    if lines.len() >= 3 {
867        // Extract version, commit, and timestamp
868        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        // Format as: "forge 1.2.3-nightly (51650ea 2025-06-27)"
873        let short_commit = &commit[..7]; // First 7 chars of commit hash
874        let date = timestamp.split('T').next().unwrap_or(&timestamp);
875
876        Ok(format!("{version} ({short_commit} {date})"))
877    } else {
878        // Fallback to just the first line if format is unexpected
879        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}