Skip to main content

foundry_cli/utils/
mod.rs

1use alloy_json_abi::JsonAbi;
2use alloy_primitives::{Address, U256, map::HashMap};
3use alloy_provider::{Network, Provider, RootProvider, network::AnyNetwork};
4use eyre::{ContextCompat, Result};
5use foundry_common::{provider::ProviderBuilder, shell};
6use foundry_config::{Chain, Config};
7use itertools::Itertools;
8use path_slash::PathExt;
9use regex::Regex;
10use serde::de::DeserializeOwned;
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    ffi::{OsStr, OsString},
14    path::{Path, PathBuf},
15    process::{Command, Output, Stdio},
16    str::FromStr,
17    sync::{LazyLock, OnceLock},
18    time::{Duration, SystemTime, UNIX_EPOCH},
19};
20use tracing_subscriber::{EnvFilter, prelude::*, reload};
21
22mod cmd;
23pub use cmd::*;
24
25mod suggestions;
26pub use suggestions::*;
27
28mod abi;
29pub use abi::*;
30
31mod allocator;
32pub use allocator::*;
33
34mod tempo;
35pub use tempo::*;
36
37// reexport all `foundry_config::utils`
38#[doc(hidden)]
39pub use foundry_config::utils::*;
40
41/// Deterministic fuzzer seed used for gas snapshots and coverage reports.
42///
43/// The keccak256 hash of "foundry rulez"
44pub const STATIC_FUZZ_SEED: [u8; 32] = [
45    0x01, 0x00, 0xfa, 0x69, 0xa5, 0xf1, 0x71, 0x0a, 0x95, 0xcd, 0xef, 0x94, 0x88, 0x9b, 0x02, 0x84,
46    0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6,
47];
48
49/// Applies an optional percentage multiplier to a gas estimate.
50pub fn apply_gas_estimate_multiplier(estimate: u64, multiplier: Option<u64>) -> Result<u64> {
51    let Some(multiplier) = multiplier else { return Ok(estimate) };
52    let adjusted = u128::from(estimate) * u128::from(multiplier) / 100;
53    adjusted.try_into().map_err(|_| eyre::eyre!("multiplied gas estimate exceeds u64"))
54}
55
56/// Regex used to parse `.gitmodules` file and capture the submodule path and branch.
57pub static SUBMODULE_BRANCH_REGEX: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap());
59/// Regex used to parse `git submodule status` output.
60pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
61    LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
62
63/// Handle to reload the tracing `EnvFilter` at runtime.
64static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
65    OnceLock::new();
66
67/// Useful extensions to [`std::path::Path`].
68pub trait FoundryPathExt {
69    /// Returns true if the [`Path`] ends with `.t.sol`
70    fn is_sol_test(&self) -> bool;
71
72    /// Returns true if the  [`Path`] has a `sol` extension
73    fn is_sol(&self) -> bool;
74
75    /// Returns true if the  [`Path`] has a `yul` extension
76    fn is_yul(&self) -> bool;
77}
78
79impl<T: AsRef<Path>> FoundryPathExt for T {
80    fn is_sol_test(&self) -> bool {
81        self.as_ref()
82            .file_name()
83            .and_then(|s| s.to_str())
84            .map(|s| s.ends_with(".t.sol"))
85            .unwrap_or_default()
86    }
87
88    fn is_sol(&self) -> bool {
89        self.as_ref().extension() == Some(std::ffi::OsStr::new("sol"))
90    }
91
92    fn is_yul(&self) -> bool {
93        self.as_ref().extension() == Some(std::ffi::OsStr::new("yul"))
94    }
95}
96
97/// Initializes a tracing Subscriber for logging.
98///
99/// The `EnvFilter` is wrapped in a [`reload::Layer`] so it can be reconfigured at runtime via
100/// [`update_tracing_filter`].
101pub fn subscriber() {
102    let (filter_layer, reload_handle) = reload::Layer::new(env_filter());
103    let registry = tracing_subscriber::Registry::default().with(filter_layer);
104    #[cfg(feature = "tracy")]
105    let registry = registry.with(tracing_tracy::TracyLayer::default());
106    registry.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)).init();
107    let _ = FILTER_RELOAD_HANDLE.set(reload_handle);
108}
109
110/// Replaces the active tracing `EnvFilter` at runtime.
111///
112/// `directives` is parsed as an [`EnvFilter`] (e.g. `"info"`, `"debug,hyper=off"`).
113/// This is a no-op if [`subscriber`] has not been called yet.
114pub fn update_tracing_filter(directives: &str) {
115    let Some(handle) = FILTER_RELOAD_HANDLE.get() else {
116        return;
117    };
118    let Ok(new_filter) = directives.parse::<EnvFilter>() else {
119        return;
120    };
121    let _ = handle.reload(new_filter);
122}
123
124fn env_filter() -> EnvFilter {
125    const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt");
126    let mut filter = EnvFilter::from_default_env();
127    for &directive in DEFAULT_DIRECTIVES {
128        filter = filter.add_directive(directive.parse().unwrap());
129    }
130    filter
131}
132
133/// Returns a [`RootProvider`] instantiated using [Config]'s RPC settings.
134pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
135    get_provider_builder(config)?.build()
136}
137
138/// Returns a [ProviderBuilder] instantiated using [Config] values.
139///
140/// Defaults to `http://localhost:8545` and `Mainnet`.
141pub fn get_provider_builder(config: &Config) -> Result<ProviderBuilder> {
142    ProviderBuilder::from_config(config)
143}
144
145pub async fn get_chain<N, P>(chain: Option<Chain>, provider: P) -> Result<Chain>
146where
147    N: Network,
148    P: Provider<N>,
149{
150    match chain {
151        Some(chain) => Ok(chain),
152        None => Ok(Chain::from_id(provider.get_chain_id().await?)),
153    }
154}
155
156/// Parses an ether value from a string.
157///
158/// The amount can be tagged with a unit, e.g. "1ether".
159///
160/// If the string represents an untagged amount (e.g. "100") then
161/// it is interpreted as wei.
162pub fn parse_ether_value(value: &str) -> Result<U256> {
163    Ok(if value.starts_with("0x") || value.starts_with("0X") {
164        U256::from_str(value)?
165    } else {
166        alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)?
167            .as_uint()
168            .wrap_err("Could not parse ether value from string")?
169            .0
170    })
171}
172
173/// Parses a `T` from a string using [`serde_json::from_str`].
174pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
175    serde_json::from_str(value)
176}
177
178/// Parses a `Duration` from a &str
179pub fn parse_delay(delay: &str) -> Result<Duration> {
180    let delay = if delay.ends_with("ms") {
181        let d: u64 = delay.trim_end_matches("ms").parse()?;
182        Duration::from_millis(d)
183    } else {
184        let d: f64 = delay.parse()?;
185        let delay = (d * 1000.0).round();
186        if delay.is_infinite() || delay.is_nan() || delay.is_sign_negative() {
187            eyre::bail!("delay must be finite and non-negative");
188        }
189
190        Duration::from_millis(delay as u64)
191    };
192    Ok(delay)
193}
194
195/// Returns the current time as a [`Duration`] since the Unix epoch.
196pub fn now() -> Duration {
197    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
198}
199
200/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber).
201pub fn common_setup() {
202    install_crypto_provider();
203    crate::handler::install();
204    load_dotenv();
205    enable_paint();
206}
207
208/// Loads dotenv files from the cwd and project root, ignoring parse failures.
209pub fn load_dotenv() {
210    // we only want the .env file of the cwd and project root
211    // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or
212    // both `Err`
213    let mut paths = Vec::new();
214    if let (Ok(cwd), Ok(project_root)) = (std::env::current_dir(), find_project_root(None)) {
215        let project_env = project_root.join(".env");
216        if project_env.is_file() {
217            paths.push(project_env);
218        }
219        if cwd != project_root {
220            // prj root and cwd can be identical
221            let cwd_env = cwd.join(".env");
222            if cwd_env.is_file() {
223                paths.push(cwd_env);
224            }
225        }
226    }
227
228    for path in paths {
229        dotenvy::from_path(path).ok();
230    }
231}
232
233/// Sets the default [`yansi`] color output condition.
234pub fn enable_paint() {
235    let enable = yansi::Condition::os_support() && yansi::Condition::tty_and_color_live();
236    yansi::whenever(yansi::Condition::cached(enable));
237}
238
239/// This force installs the default crypto provider.
240///
241/// This is necessary in case there are more than one available backends enabled in rustls (ring,
242/// aws-lc-rs).
243///
244/// This should be called high in the main fn.
245///
246/// See also:
247///   <https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455100010>
248///   <https://github.com/awslabs/aws-sdk-rust/discussions/1257>
249pub fn install_crypto_provider() {
250    // https://github.com/snapview/tokio-tungstenite/issues/353
251    rustls::crypto::ring::default_provider()
252        .install_default()
253        .expect("Failed to install default rustls crypto provider");
254}
255
256/// Fetches the ABI of a contract from Etherscan.
257pub async fn fetch_abi_from_etherscan(
258    address: Address,
259    config: &foundry_config::Config,
260) -> Result<Vec<(JsonAbi, String)>> {
261    let chain = config.chain.unwrap_or_default();
262    let client = config
263        .get_etherscan_config_with_chain(Some(chain))?
264        .ok_or_else(|| eyre::eyre!("No Etherscan API key configured for chain {chain}"))?
265        .into_client_with_no_proxy(config.eth_rpc_no_proxy)?;
266    let source = client.contract_source_code(address).await?;
267    source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect()
268}
269
270/// Useful extensions to [`std::process::Command`].
271pub trait CommandUtils {
272    /// Returns the command's output if execution is successful, otherwise, throws an error.
273    fn exec(&mut self) -> Result<Output>;
274
275    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
276    fn get_stdout_lossy(&mut self) -> Result<String>;
277}
278
279impl CommandUtils for Command {
280    #[track_caller]
281    fn exec(&mut self) -> Result<Output> {
282        trace!(command=?self, "executing");
283
284        let output = self.output()?;
285
286        trace!(code=?output.status.code(), ?output);
287
288        if output.status.success() {
289            Ok(output)
290        } else {
291            let stdout = String::from_utf8_lossy(&output.stdout);
292            let stdout = stdout.trim();
293            let stderr = String::from_utf8_lossy(&output.stderr);
294            let stderr = stderr.trim();
295            let msg = if stdout.is_empty() {
296                stderr.to_string()
297            } else if stderr.is_empty() {
298                stdout.to_string()
299            } else {
300                format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
301            };
302
303            let mut name = self.get_program().to_string_lossy();
304            if let Some(arg) = self.get_args().next() {
305                let arg = arg.to_string_lossy();
306                if !arg.starts_with('-') {
307                    let name = name.to_mut();
308                    name.push(' ');
309                    name.push_str(&arg);
310                }
311            }
312
313            let mut err = match output.status.code() {
314                Some(code) => format!("{name} exited with code {code}"),
315                None => format!("{name} terminated by a signal"),
316            };
317            if !msg.is_empty() {
318                err.push(':');
319                err.push(if msg.lines().count() == 1 { ' ' } else { '\n' });
320                err.push_str(&msg);
321            }
322            Err(eyre::eyre!(err))
323        }
324    }
325
326    #[track_caller]
327    fn get_stdout_lossy(&mut self) -> Result<String> {
328        let output = self.exec()?;
329        let stdout = String::from_utf8_lossy(&output.stdout);
330        Ok(stdout.trim().into())
331    }
332}
333
334#[derive(Clone, Copy, Debug)]
335pub struct Git<'a> {
336    pub root: &'a Path,
337    pub quiet: bool,
338    pub shallow: bool,
339}
340
341impl<'a> Git<'a> {
342    pub fn new(root: &'a Path) -> Self {
343        Self { root, quiet: shell::is_quiet(), shallow: false }
344    }
345
346    pub fn from_config(config: &'a Config) -> Self {
347        Self::new(config.root.as_path())
348    }
349
350    pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
351        let output = Self::cmd_no_root()
352            .current_dir(relative_to)
353            .args(["rev-parse", "--show-toplevel"])
354            .get_stdout_lossy()?;
355        Ok(PathBuf::from(output))
356    }
357
358    pub fn clone_with_branch(
359        shallow: bool,
360        from: impl AsRef<OsStr>,
361        branch: impl AsRef<OsStr>,
362        to: Option<impl AsRef<OsStr>>,
363    ) -> Result<()> {
364        Self::cmd_no_root()
365            .stderr(Stdio::inherit())
366            .args(["clone", "--recurse-submodules"])
367            .args(shallow.then_some("--depth=1"))
368            .args(shallow.then_some("--shallow-submodules"))
369            .arg("-b")
370            .arg(branch)
371            .arg(from)
372            .args(to)
373            .exec()
374            .map(drop)
375    }
376
377    pub fn clone(
378        shallow: bool,
379        from: impl AsRef<OsStr>,
380        to: Option<impl AsRef<OsStr>>,
381    ) -> Result<()> {
382        Self::cmd_no_root()
383            .stderr(Stdio::inherit())
384            .args(["clone", "--recurse-submodules"])
385            .args(shallow.then_some("--depth=1"))
386            .args(shallow.then_some("--shallow-submodules"))
387            .arg(from)
388            .args(to)
389            .exec()
390            .map(drop)
391    }
392
393    pub fn fetch(
394        self,
395        shallow: bool,
396        remote: impl AsRef<OsStr>,
397        branch: Option<impl AsRef<OsStr>>,
398    ) -> Result<()> {
399        self.cmd()
400            .stderr(Stdio::inherit())
401            .arg("fetch")
402            .args(shallow.then_some("--no-tags"))
403            .args(shallow.then_some("--depth=1"))
404            .arg(remote)
405            .args(branch)
406            .exec()
407            .map(drop)
408    }
409
410    pub const fn root(self, root: &Path) -> Git<'_> {
411        Git { root, ..self }
412    }
413
414    pub const fn quiet(self, quiet: bool) -> Self {
415        Self { quiet, ..self }
416    }
417
418    /// True to perform shallow clones
419    pub const fn shallow(self, shallow: bool) -> Self {
420        Self { shallow, ..self }
421    }
422
423    pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
424        self.cmd()
425            .arg("checkout")
426            .args(recursive.then_some("--recurse-submodules"))
427            .arg(tag)
428            .exec()
429            .map(drop)
430    }
431
432    /// Returns the current HEAD commit hash of the current branch.
433    pub fn head(self) -> Result<String> {
434        self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
435    }
436
437    pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
438        self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop)
439    }
440
441    pub fn init(self) -> Result<()> {
442        self.cmd().arg("init").exec().map(drop)
443    }
444
445    pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
446        let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
447        let branch =
448            self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
449        Ok((rev, branch))
450    }
451
452    #[expect(clippy::should_implement_trait)] // this is not std::ops::Add clippy
453    pub fn add<I, S>(self, paths: I) -> Result<()>
454    where
455        I: IntoIterator<Item = S>,
456        S: AsRef<OsStr>,
457    {
458        self.cmd().arg("add").args(paths).exec().map(drop)
459    }
460
461    pub fn add_literal(self, path: &Path) -> Result<()> {
462        self.cmd().args(["--literal-pathspecs", "add", "--"]).arg(path).exec().map(drop)
463    }
464
465    pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
466        self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
467    }
468
469    pub fn commit_tree(
470        self,
471        tree: impl AsRef<OsStr>,
472        msg: Option<impl AsRef<OsStr>>,
473    ) -> Result<String> {
474        self.cmd()
475            .arg("commit-tree")
476            .arg(tree)
477            .args(msg.as_ref().is_some().then_some("-m"))
478            .args(msg)
479            .get_stdout_lossy()
480    }
481
482    pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
483    where
484        I: IntoIterator<Item = S>,
485        S: AsRef<OsStr>,
486    {
487        self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
488    }
489
490    pub fn remove_index_path(self, path: &Path) -> Result<()> {
491        self.cmd()
492            .args(["--literal-pathspecs", "rm", "--cached", "--force", "--"])
493            .arg(path)
494            .exec()
495            .map(drop)
496    }
497
498    pub fn commit(self, msg: &str) -> Result<()> {
499        let output = self
500            .cmd()
501            .args(["commit", "-m", msg])
502            .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
503            .output()?;
504        if !output.status.success() {
505            let stdout = String::from_utf8_lossy(&output.stdout);
506            let stderr = String::from_utf8_lossy(&output.stderr);
507            // ignore "nothing to commit" error
508            let msg = "nothing to commit, working tree clean";
509            if !(stdout.contains(msg) || stderr.contains(msg)) {
510                return Err(eyre::eyre!(
511                    "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
512                    output.status.code(),
513                    stdout.trim(),
514                    stderr.trim()
515                ));
516            }
517        }
518        Ok(())
519    }
520
521    pub fn is_in_repo(self) -> std::io::Result<bool> {
522        self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
523    }
524
525    pub fn is_repo_root(self) -> Result<bool> {
526        self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty())
527    }
528
529    pub fn is_clean(self) -> Result<bool> {
530        self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
531    }
532
533    pub fn is_path_clean(self, path: &Path) -> Result<bool> {
534        self.cmd()
535            .args(["--literal-pathspecs", "status", "--porcelain", "--"])
536            .arg(path)
537            .exec()
538            .map(|out| out.stdout.is_empty())
539    }
540
541    pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
542        self.cmd_at(at)
543            .args(["branch", "--list", "--no-color"])
544            .arg(branch)
545            .get_stdout_lossy()
546            .map(|stdout| !stdout.is_empty())
547    }
548
549    pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
550        self.cmd_at(at)
551            .args(["tag", "--list"])
552            .arg(tag)
553            .get_stdout_lossy()
554            .map(|stdout| !stdout.is_empty())
555    }
556
557    pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
558        self.cmd_at(at)
559            .args(["cat-file", "-t"])
560            .arg(rev)
561            .get_stdout_lossy()
562            .map(|stdout| &stdout == "commit")
563    }
564
565    pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
566        self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
567    }
568
569    pub fn ensure_clean(self) -> Result<()> {
570        if self.is_clean()? {
571            Ok(())
572        } else {
573            Err(eyre::eyre!(
574                "\
575The target directory is a part of or on its own an already initialized git repository,
576and it requires clean working and staging areas, including no untracked files.
577
578Check the current git repository's status with `git status`.
579Then, you can track files with `git add ...` and then commit them with `git commit`,
580ignore them in the `.gitignore` file."
581            ))
582        }
583    }
584
585    pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
586        self.cmd()
587            .arg("rev-parse")
588            .args(short.then_some("--short"))
589            .arg(revision)
590            .get_stdout_lossy()
591    }
592
593    pub fn tag(self) -> Result<String> {
594        self.cmd().arg("tag").get_stdout_lossy()
595    }
596
597    /// Returns the tag the commit first appeared in.
598    ///
599    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
600    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
601    /// in `v0.2.0`.
602    ///
603    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
604    pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
605        self.cmd_at(at)
606            .args(["tag", "--contains"])
607            .arg(rev)
608            .get_stdout_lossy()
609            .map(|stdout| stdout.lines().next().map(str::to_string))
610    }
611
612    /// Returns a list of tuples of submodule paths and their respective branches.
613    ///
614    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
615    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
616    ///
617    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
618    /// `lib` is name of the directory where the submodules are located.
619    pub fn read_submodules_with_branch(
620        self,
621        at: &Path,
622        lib: &OsStr,
623    ) -> Result<HashMap<PathBuf, String>> {
624        // Read the .gitmodules file
625        let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
626
627        let paths = SUBMODULE_BRANCH_REGEX
628            .captures_iter(&gitmodules)
629            .map(|cap| {
630                let path_str = cap.get(1).unwrap().as_str();
631                let path = PathBuf::from_str(path_str).unwrap();
632                trace!(path = %path.display(), "unstripped path");
633
634                // Keep only the components that come after the lib directory.
635                // This needs to be done because the lockfile uses paths relative foundry project
636                // root whereas .gitmodules use paths relative to the git root which may not be the
637                // project root. e.g monorepo.
638                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
639                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
640                let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
641                let path = path
642                    .components()
643                    .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
644                    .collect::<PathBuf>();
645
646                let branch = cap.get(2).unwrap().as_str().to_string();
647                (path, branch)
648            })
649            .collect::<HashMap<_, _>>();
650
651        Ok(paths)
652    }
653
654    pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
655    where
656        I: IntoIterator<Item = S>,
657        S: AsRef<OsStr>,
658    {
659        let paths = paths.into_iter().map(|path| path.as_ref().to_owned()).collect::<Vec<_>>();
660        if self.submodules_initialized(&paths).unwrap_or(false) {
661            return Ok(false);
662        }
663
664        self.cmd()
665            .args(["submodule", "status"])
666            .args(&paths)
667            .get_stdout_lossy()
668            .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
669    }
670
671    /// Returns true if all submodules matching `paths` have initialized worktrees.
672    fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
673        let Some(root) = self.root.ancestors().find(|root| root.join(".git").exists()) else {
674            return Ok(false);
675        };
676        if paths.iter().any(|path| {
677            Path::new(path)
678                .components()
679                .any(|component| matches!(component, std::path::Component::ParentDir))
680        }) {
681            return Ok(false);
682        }
683        let relative_root = self.root.strip_prefix(root).unwrap_or_else(|_| Path::new(""));
684        let gitmodules = root.join(".gitmodules");
685        if !gitmodules.is_file() {
686            return Ok(false);
687        }
688
689        let output = Command::new("git")
690            .args(["config", "--null", "--file"])
691            .arg(gitmodules)
692            .args(["--get-regexp", r"^submodule\..*\.path$"])
693            .get_stdout_lossy()?;
694
695        for entry in output.split_terminator('\0') {
696            let (_, path) = entry
697                .split_once('\n')
698                .ok_or_else(|| eyre::eyre!("invalid submodule path config"))?;
699            let path = Path::new(path);
700            let matches = paths.is_empty()
701                || paths.iter().any(|prefix| {
702                    let prefix = Path::new(prefix);
703                    if prefix.is_absolute() {
704                        prefix.strip_prefix(root).is_ok_and(|prefix| path.starts_with(prefix))
705                    } else {
706                        path.starts_with(relative_root.join(prefix))
707                    }
708                });
709            if matches && !root.join(path).join(".git").exists() {
710                return Ok(false);
711            }
712        }
713        Ok(true)
714    }
715
716    /// Returns true if the given path has submodules by checking `git submodule status`
717    pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
718    where
719        I: IntoIterator<Item = S>,
720        S: AsRef<OsStr>,
721    {
722        self.cmd()
723            .args(["submodule", "status"])
724            .args(paths)
725            .get_stdout_lossy()
726            .map(|stdout| stdout.trim().lines().next().is_some())
727    }
728
729    pub fn submodule_add(
730        self,
731        force: bool,
732        url: impl AsRef<OsStr>,
733        path: impl AsRef<OsStr>,
734    ) -> Result<()> {
735        self.cmd()
736            .arg("--literal-pathspecs")
737            .stderr(self.stderr())
738            .args(["submodule", "add"])
739            .args(self.shallow.then_some("--depth=1"))
740            .args(force.then_some("--force"))
741            .arg(url)
742            .arg(path)
743            .exec()
744            .map(drop)
745    }
746
747    pub fn submodule_update<I, S>(
748        self,
749        force: bool,
750        remote: bool,
751        no_fetch: bool,
752        recursive: bool,
753        paths: I,
754    ) -> Result<()>
755    where
756        I: IntoIterator<Item = S>,
757        S: AsRef<OsStr>,
758    {
759        self.cmd()
760            .stderr(self.stderr())
761            .args(["submodule", "update", "--progress", "--init"])
762            .args(self.shallow.then_some("--depth=1"))
763            .args(force.then_some("--force"))
764            .args(remote.then_some("--remote"))
765            .args(no_fetch.then_some("--no-fetch"))
766            .args(recursive.then_some("--recursive"))
767            .args(paths)
768            .exec()
769            .map(drop)
770    }
771
772    pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
773        self.cmd()
774            .stderr(self.stderr())
775            .args(["submodule", "foreach"])
776            .args(recursive.then_some("--recursive"))
777            .arg(cmd)
778            .exec()
779            .map(drop)
780    }
781
782    /// If the status is prefix with `-`, the submodule is not initialized.
783    ///
784    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
785    pub fn submodules_uninitialized(self) -> Result<bool> {
786        // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the
787        // "submodule status has '-' prefix" logic.
788        self.has_missing_dependencies(std::iter::empty::<&OsStr>())
789    }
790
791    /// Initializes the git submodules.
792    pub fn submodule_init(self) -> Result<()> {
793        self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
794    }
795
796    pub fn submodules(&self) -> Result<Submodules> {
797        self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
798    }
799
800    /// Returns submodules at or below `path`, with paths relative to this Git root.
801    pub fn submodules_in(&self, path: &Path) -> Result<Vec<SubmoduleCheckout>> {
802        self.submodules_in_worktree(path, self.root, Path::new(""))
803    }
804
805    /// Returns submodules at or below `path`, with paths relative to this Git root, using mappings
806    /// from the enclosing worktree.
807    pub fn submodules_in_worktree(
808        &self,
809        path: &Path,
810        worktree_root: &Path,
811        worktree_prefix: &Path,
812    ) -> Result<Vec<SubmoduleCheckout>> {
813        let pathspec = if path.as_os_str().is_empty() { Path::new(".") } else { path };
814        let output = self
815            .cmd()
816            .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
817            .arg(pathspec)
818            .exec()?;
819        let (_, mappings) = self.submodule_mappings_at(worktree_root)?;
820        let mut gitlinks = BTreeMap::new();
821        for entry in output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) {
822            let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
823                return Err(eyre::eyre!("invalid index entry"));
824            };
825            let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
826            let Some(mode) = fields.next() else {
827                return Err(eyre::eyre!("invalid index entry"));
828            };
829            let Some(rev) = fields.next() else {
830                return Err(eyre::eyre!("invalid index entry"));
831            };
832            let Some(stage) = fields.next() else {
833                return Err(eyre::eyre!("invalid index entry"));
834            };
835            if fields.next().is_some() {
836                return Err(eyre::eyre!("invalid index entry"));
837            }
838            if mode != "160000" {
839                continue;
840            }
841
842            let submodule_path = PathBuf::from(std::str::from_utf8(&entry[separator + 1..])?);
843            let worktree_path =
844                foundry_common::fs::normalize_path(&worktree_prefix.join(&submodule_path));
845            if !mappings.contains(&worktree_path) {
846                gitlinks.insert(
847                    submodule_path.clone(),
848                    SubmoduleCheckout {
849                        status: SubmoduleCheckoutStatus::MissingMapping,
850                        rev: rev.to_string(),
851                        path: submodule_path,
852                    },
853                );
854                continue;
855            }
856            if stage != "0" {
857                gitlinks.insert(
858                    submodule_path.clone(),
859                    SubmoduleCheckout {
860                        status: SubmoduleCheckoutStatus::Conflicted,
861                        rev: rev.to_string(),
862                        path: submodule_path,
863                    },
864                );
865                continue;
866            }
867
868            let status = self
869                .cmd()
870                .args(["--literal-pathspecs", "submodule", "status", "--"])
871                .arg(&submodule_path)
872                .get_stdout_lossy()?;
873            let (status, rev) = match status.as_bytes().first() {
874                Some(b'-') => (SubmoduleCheckoutStatus::Uninitialized, &status[1..]),
875                Some(b'+') => (SubmoduleCheckoutStatus::Modified, &status[1..]),
876                Some(b'U') => (SubmoduleCheckoutStatus::Conflicted, &status[1..]),
877                Some(_) => (SubmoduleCheckoutStatus::Current, status.as_str()),
878                None => return Err(eyre::eyre!("missing submodule status")),
879            };
880            let rev = rev
881                .split_ascii_whitespace()
882                .next()
883                .ok_or_else(|| eyre::eyre!("invalid submodule status"))?;
884            gitlinks.insert(
885                submodule_path.clone(),
886                SubmoduleCheckout { status, rev: rev.to_string(), path: submodule_path },
887            );
888        }
889        Ok(gitlinks.into_values().collect())
890    }
891
892    pub fn submodule_sync(self) -> Result<()> {
893        self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
894    }
895
896    /// Get the URL of a submodule from git config
897    pub fn submodule_url(self, path: &Path) -> Result<Option<String>> {
898        self.cmd()
899            .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())])
900            .get_stdout_lossy()
901            .map(|url| Some(url.trim().to_string()))
902    }
903
904    /// Returns whether `.gitmodules` contains the default section name or an exact path mapping.
905    pub fn has_submodule_mapping(self, path: &Path) -> Result<bool> {
906        let (names, paths) = self.submodule_mappings()?;
907        Ok(names.contains(path) || paths.contains(path))
908    }
909
910    fn submodule_mappings(self) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
911        self.submodule_mappings_at(self.root)
912    }
913
914    fn submodule_mappings_at(self, root: &Path) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
915        let gitmodules = root.join(".gitmodules");
916        if !gitmodules.exists() {
917            return Ok(Default::default());
918        }
919
920        let output = self
921            .cmd()
922            .args(["config", "--null", "--file"])
923            .arg(gitmodules)
924            .args(["--get-regexp", r"^submodule\..*"])
925            .output()?;
926        match output.status.code() {
927            Some(0) => {
928                let mut names = BTreeSet::new();
929                let mut paths = BTreeSet::new();
930                for entry in
931                    output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty())
932                {
933                    let Some(separator) = entry.iter().position(|byte| *byte == b'\n') else {
934                        return Err(eyre::eyre!("invalid submodule mapping entry"));
935                    };
936                    let key = std::str::from_utf8(&entry[..separator])?;
937                    let value = std::str::from_utf8(&entry[separator + 1..])?;
938                    let Some(key) = key.strip_prefix("submodule.") else { continue };
939                    let Some((name, field)) = key.rsplit_once('.') else { continue };
940                    names.insert(PathBuf::from(name));
941                    if field == "path" {
942                        paths.insert(PathBuf::from(value));
943                    }
944                }
945                Ok((names, paths))
946            }
947            Some(1) => Ok(Default::default()),
948            _ => Err(eyre::eyre!(
949                "failed to inspect .gitmodules: {}",
950                String::from_utf8_lossy(&output.stderr).trim()
951            )),
952        }
953    }
954
955    /// Returns whether the index contains a submodule at the given path.
956    pub fn is_gitlink(self, path: &Path) -> Result<bool> {
957        self.cmd().args(["ls-files", "--stage", "-z", "--"]).arg(path).exec().map(|output| {
958            let expected_path = path.to_slash_lossy();
959            output.stdout.split(|byte| *byte == 0).any(|entry| {
960                entry.starts_with(b"160000 ")
961                    && entry
962                        .iter()
963                        .position(|byte| *byte == b'\t')
964                        .and_then(|separator| entry.get(separator + 1..))
965                        == Some(expected_path.as_bytes())
966            })
967        })
968    }
969
970    /// Returns whether the index contains any entry at or below the given path.
971    pub fn has_index_entries(self, path: &Path) -> Result<bool> {
972        self.cmd()
973            .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
974            .arg(path)
975            .exec()
976            .map(|output| !output.stdout.is_empty())
977    }
978
979    /// Returns whether a regular stage-0 index entry has no flags and matches the worktree.
980    pub fn is_normal_tracked_file(self, path: &Path) -> Result<bool> {
981        let output = self
982            .cmd()
983            .args(["--literal-pathspecs", "ls-files", "--stage", "-v", "-z", "--"])
984            .arg(path)
985            .exec()?;
986        let mut entries = output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty());
987        let Some(entry) = entries.next() else { return Ok(false) };
988        if entries.next().is_some() {
989            return Ok(false);
990        }
991        let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
992            return Err(eyre::eyre!("invalid index entry"));
993        };
994        if entry.get(separator + 1..) != Some(path.to_slash_lossy().as_bytes()) {
995            return Ok(false);
996        }
997        let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
998        if fields.next() != Some("H") || !matches!(fields.next(), Some("100644" | "100755")) {
999            return Ok(false);
1000        }
1001        let Some(index_hash) = fields.next() else { return Ok(false) };
1002        if fields.next() != Some("0") || fields.next().is_some() {
1003            return Ok(false);
1004        }
1005        let worktree_hash = self.cmd().args(["hash-object", "--"]).arg(path).get_stdout_lossy()?;
1006        Ok(worktree_hash.trim() == index_hash)
1007    }
1008
1009    /// Returns whether local config contains values for the given submodule.
1010    pub fn has_submodule_config(self, path: &Path) -> Result<bool> {
1011        let pattern = format!(r"^submodule\.{}\.", regex::escape(&path.to_slash_lossy()));
1012        let output = self.cmd().args(["config", "--local", "--get-regexp", &pattern]).output()?;
1013        match output.status.code() {
1014            Some(0) => Ok(true),
1015            Some(1) => Ok(false),
1016            _ => Err(eyre::eyre!(
1017                "failed to inspect submodule config: {}",
1018                String::from_utf8_lossy(&output.stderr).trim()
1019            )),
1020        }
1021    }
1022
1023    /// Removes all local config values for the given submodule.
1024    pub fn remove_submodule_config(self, path: &Path) -> Result<()> {
1025        if self.has_submodule_config(path)? {
1026            let section = format!("submodule.{}", path.to_slash_lossy());
1027            self.cmd().args(["config", "--local", "--remove-section", &section]).exec()?;
1028        }
1029        Ok(())
1030    }
1031
1032    /// Returns the absolute path to the repository's Git directory.
1033    pub fn absolute_git_dir(self) -> Result<PathBuf> {
1034        self.cmd().args(["rev-parse", "--absolute-git-dir"]).get_stdout_lossy().map(PathBuf::from)
1035    }
1036
1037    /// Returns the fetch URL of the given remote, or `None` if it doesn't exist.
1038    pub fn remote_url(self, name: &str) -> Option<String> {
1039        self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
1040    }
1041
1042    /// Sets the branch for a submodule.
1043    pub fn set_submodule_branch(self, rel_path: &Path, branch: &str) -> Result<()> {
1044        self.cmd().args(["submodule", "set-branch", "-b", branch]).arg(rel_path).exec().map(drop)
1045    }
1046
1047    /// Returns remote branch names as a newline-separated string.
1048    pub fn remote_branches(self) -> Result<String> {
1049        self.cmd().args(["branch", "-r"]).get_stdout_lossy()
1050    }
1051
1052    /// Fetches a branch from origin and checks out a local tracking branch at the given path.
1053    pub fn fetch_and_checkout_branch(self, at: &Path, branch: &str) -> Result<()> {
1054        self.cmd_at(at).args(["fetch", "origin", branch]).exec().map_err(|e| {
1055            eyre::eyre!(
1056                "Could not fetch latest changes for branch {branch} in submodule at {}: {e}",
1057                at.display()
1058            )
1059        })?;
1060        self.cmd_at(at)
1061            .args(["checkout", "-B", branch, &format!("origin/{branch}")])
1062            .exec()
1063            .map_err(|e| {
1064                eyre::eyre!(
1065                    "Could not checkout and track origin/{branch} for submodule at {}: {e}",
1066                    at.display()
1067                )
1068            })?;
1069        Ok(())
1070    }
1071
1072    fn cmd(self) -> Command {
1073        let mut cmd = Self::cmd_no_root();
1074        cmd.current_dir(self.root);
1075        cmd
1076    }
1077
1078    fn cmd_at(self, path: &Path) -> Command {
1079        let mut cmd = Self::cmd_no_root();
1080        cmd.current_dir(path);
1081        cmd
1082    }
1083
1084    fn cmd_no_root() -> Command {
1085        let mut cmd = Command::new("git");
1086        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1087        cmd
1088    }
1089
1090    // don't set this in cmd() because it's not wanted for all commands
1091    fn stderr(self) -> Stdio {
1092        if self.quiet { Stdio::piped() } else { Stdio::inherit() }
1093    }
1094}
1095
1096/// Deserialized `git submodule status lib/dep` output.
1097#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1098pub struct Submodule {
1099    /// Current commit hash the submodule is checked out at.
1100    rev: String,
1101    /// Relative path to the submodule.
1102    path: PathBuf,
1103}
1104
1105impl Submodule {
1106    pub const fn new(rev: String, path: PathBuf) -> Self {
1107        Self { rev, path }
1108    }
1109
1110    pub fn rev(&self) -> &str {
1111        &self.rev
1112    }
1113
1114    pub const fn path(&self) -> &PathBuf {
1115        &self.path
1116    }
1117}
1118
1119/// A submodule checkout inspected without changing its worktree or index.
1120#[derive(Debug, Clone, PartialEq, Eq)]
1121pub struct SubmoduleCheckout {
1122    status: SubmoduleCheckoutStatus,
1123    rev: String,
1124    path: PathBuf,
1125}
1126
1127impl SubmoduleCheckout {
1128    pub const fn status(&self) -> SubmoduleCheckoutStatus {
1129        self.status
1130    }
1131
1132    pub fn rev(&self) -> &str {
1133        &self.rev
1134    }
1135
1136    pub const fn path(&self) -> &PathBuf {
1137        &self.path
1138    }
1139}
1140
1141/// State of an inspected submodule checkout.
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub enum SubmoduleCheckoutStatus {
1144    /// The checkout matches the commit recorded by the superproject.
1145    Current,
1146    /// The submodule has not been initialized.
1147    Uninitialized,
1148    /// The checkout differs from the commit recorded by the superproject.
1149    Modified,
1150    /// The submodule has merge conflicts.
1151    Conflicted,
1152    /// The index contains a gitlink without a corresponding `.gitmodules` mapping.
1153    MissingMapping,
1154}
1155
1156impl FromStr for Submodule {
1157    type Err = eyre::Report;
1158
1159    fn from_str(s: &str) -> Result<Self> {
1160        let caps = SUBMODULE_STATUS_REGEX
1161            .captures(s)
1162            .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
1163
1164        Ok(Self {
1165            rev: caps.get(1).unwrap().as_str().to_string(),
1166            path: PathBuf::from(caps.get(2).unwrap().as_str()),
1167        })
1168    }
1169}
1170
1171/// Deserialized `git submodule status` output.
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173pub struct Submodules(pub Vec<Submodule>);
1174
1175impl Submodules {
1176    pub const fn len(&self) -> usize {
1177        self.0.len()
1178    }
1179
1180    pub const fn is_empty(&self) -> bool {
1181        self.0.is_empty()
1182    }
1183}
1184
1185impl FromStr for Submodules {
1186    type Err = eyre::Report;
1187
1188    fn from_str(s: &str) -> Result<Self> {
1189        let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
1190        Ok(Self(subs))
1191    }
1192}
1193
1194impl<'a> IntoIterator for &'a Submodules {
1195    type Item = &'a Submodule;
1196    type IntoIter = std::slice::Iter<'a, Submodule>;
1197
1198    fn into_iter(self) -> Self::IntoIter {
1199        self.0.iter()
1200    }
1201}
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205    use foundry_common::fs;
1206    use std::{env, fs::File, io::Write};
1207    use tempfile::tempdir;
1208
1209    #[test]
1210    fn applies_gas_estimate_multiplier() {
1211        assert_eq!(apply_gas_estimate_multiplier(21_000, None).unwrap(), 21_000);
1212        assert_eq!(apply_gas_estimate_multiplier(21_000, Some(150)).unwrap(), 31_500);
1213        assert_eq!(
1214            apply_gas_estimate_multiplier(21_000, Some(1_000_000_000_000_000)).unwrap(),
1215            210_000_000_000_000_000
1216        );
1217        assert!(apply_gas_estimate_multiplier(u64::MAX, Some(101)).is_err());
1218    }
1219
1220    #[test]
1221    fn parse_submodule_status() {
1222        let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
1223        let sub = Submodule::from_str(s).unwrap();
1224        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1225        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1226
1227        let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1228        let sub = Submodule::from_str(s).unwrap();
1229        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1230        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1231
1232        let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1233        let sub = Submodule::from_str(s).unwrap();
1234        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1235        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1236    }
1237
1238    #[test]
1239    fn parse_multiline_submodule_status() {
1240        let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
1241+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
1242"#;
1243        let subs = Submodules::from_str(s).unwrap().0;
1244        assert_eq!(subs.len(), 2);
1245        assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
1246        assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
1247        assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1248        assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
1249    }
1250
1251    #[test]
1252    fn deserialize_submodule() {
1253        let submodule: Submodule = serde_json::from_str(
1254            r#"{"rev":"8829465a08cac423dcf59852f21e448449c1a1a8","path":"lib/dep"}"#,
1255        )
1256        .unwrap();
1257        assert_eq!(submodule.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1258        assert_eq!(submodule.path(), Path::new("lib/dep"));
1259        assert_eq!(
1260            serde_json::to_value(submodule).unwrap(),
1261            serde_json::json!({
1262                "rev": "8829465a08cac423dcf59852f21e448449c1a1a8",
1263                "path": "lib/dep",
1264            })
1265        );
1266    }
1267
1268    #[test]
1269    fn skips_submodule_status_if_dependencies_are_initialized() {
1270        let tmp = tempdir().unwrap();
1271        let root = tmp.path();
1272        std::fs::create_dir(root.join(".git")).unwrap();
1273        std::fs::write(
1274            root.join(".gitmodules"),
1275            r#"[submodule "lib/forge-std"]
1276	path = lib/forge-std
1277	url = https://github.com/foundry-rs/forge-std
1278"#,
1279        )
1280        .unwrap();
1281        std::fs::create_dir_all(root.join("lib/forge-std/.git")).unwrap();
1282
1283        let git = Git::new(root);
1284        assert!(git.submodules_initialized(&["lib".into()]).unwrap());
1285        assert!(git.submodules_initialized(&[root.join("lib").into()]).unwrap());
1286
1287        let nested = root.join("packages/contracts");
1288        std::fs::create_dir_all(&nested).unwrap();
1289        assert!(!Git::new(&nested).submodules_initialized(&["../../lib".into()]).unwrap());
1290
1291        // The fast path succeeds even though this is not a real Git repository.
1292        assert!(!git.has_missing_dependencies(["lib"]).unwrap());
1293
1294        std::fs::remove_dir(root.join("lib/forge-std/.git")).unwrap();
1295        assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1296    }
1297
1298    #[test]
1299    fn foundry_path_ext_works() {
1300        let p = Path::new("contracts/MyTest.t.sol");
1301        assert!(p.is_sol_test());
1302        assert!(p.is_sol());
1303        let p = Path::new("contracts/Greeter.sol");
1304        assert!(!p.is_sol_test());
1305    }
1306
1307    #[test]
1308    fn parse_ether_value_accepts_hex_prefixed_wei() {
1309        assert_eq!(parse_ether_value("0x10").unwrap(), U256::from(16));
1310        assert_eq!(parse_ether_value("0X10").unwrap(), U256::from(16));
1311        assert_eq!(parse_ether_value("0x12").unwrap(), U256::from(0x12));
1312        assert_eq!(parse_ether_value("0xff").unwrap(), U256::from(0xff));
1313        assert_eq!(parse_ether_value("100").unwrap(), U256::from(100));
1314        assert_eq!(parse_ether_value("1ether").unwrap(), U256::from(1000000000000000000u128));
1315    }
1316
1317    // loads .env from cwd and project dir, See [`find_project_root()`]
1318    #[test]
1319    fn can_load_dotenv() {
1320        let temp = tempdir().unwrap();
1321        Git::new(temp.path()).init().unwrap();
1322        let cwd_env = temp.path().join(".env");
1323        fs::create_file(temp.path().join("foundry.toml")).unwrap();
1324        let nested = temp.path().join("nested");
1325        fs::create_dir(&nested).unwrap();
1326
1327        let mut cwd_file = File::create(cwd_env).unwrap();
1328        let mut prj_file = File::create(nested.join(".env")).unwrap();
1329
1330        cwd_file.write_all(b"TESTCWDKEY=cwd_val\nBROWSER=./project-browser").unwrap();
1331        cwd_file.sync_all().unwrap();
1332
1333        prj_file.write_all(b"TESTPRJKEY=prj_val\nBROWSER=./nested-browser").unwrap();
1334        prj_file.sync_all().unwrap();
1335
1336        let browser = env::var_os("BROWSER");
1337        unsafe { env::remove_var("BROWSER") };
1338        let cwd = env::current_dir().unwrap();
1339        env::set_current_dir(&nested).unwrap();
1340        load_dotenv();
1341        env::set_current_dir(cwd).unwrap();
1342
1343        let loaded_browser = env::var_os("BROWSER");
1344        unsafe {
1345            if let Some(browser) = browser {
1346                env::set_var("BROWSER", browser);
1347            } else {
1348                env::remove_var("BROWSER");
1349            }
1350        }
1351
1352        assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
1353        assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
1354        assert_eq!(loaded_browser.as_deref(), Some(OsStr::new("./project-browser")));
1355    }
1356
1357    #[test]
1358    fn test_read_gitmodules_regex() {
1359        let gitmodules = r#"
1360        [submodule "lib/solady"]
1361        path = lib/solady
1362        url = ""
1363        branch = v0.1.0
1364        [submodule "lib/openzeppelin-contracts"]
1365        path = lib/openzeppelin-contracts
1366        url = ""
1367        branch = v4.8.0-791-g8829465a
1368        [submodule "lib/forge-std"]
1369        path = lib/forge-std
1370        url = ""
1371"#;
1372
1373        let paths = SUBMODULE_BRANCH_REGEX
1374            .captures_iter(gitmodules)
1375            .map(|cap| {
1376                (
1377                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1378                    String::from(cap.get(2).unwrap().as_str()),
1379                )
1380            })
1381            .collect::<HashMap<_, _>>();
1382
1383        assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
1384        assert_eq!(
1385            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1386            "v4.8.0-791-g8829465a"
1387        );
1388
1389        let no_branch_gitmodules = r#"
1390        [submodule "lib/solady"]
1391        path = lib/solady
1392        url = ""
1393        [submodule "lib/openzeppelin-contracts"]
1394        path = lib/openzeppelin-contracts
1395        url = ""
1396        [submodule "lib/forge-std"]
1397        path = lib/forge-std
1398        url = ""
1399"#;
1400        let paths = SUBMODULE_BRANCH_REGEX
1401            .captures_iter(no_branch_gitmodules)
1402            .map(|cap| {
1403                (
1404                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1405                    String::from(cap.get(2).unwrap().as_str()),
1406                )
1407            })
1408            .collect::<HashMap<_, _>>();
1409
1410        assert!(paths.is_empty());
1411
1412        let branch_in_between = r#"
1413        [submodule "lib/solady"]
1414        path = lib/solady
1415        url = ""
1416        [submodule "lib/openzeppelin-contracts"]
1417        path = lib/openzeppelin-contracts
1418        url = ""
1419        branch = v4.8.0-791-g8829465a
1420        [submodule "lib/forge-std"]
1421        path = lib/forge-std
1422        url = ""
1423        "#;
1424
1425        let paths = SUBMODULE_BRANCH_REGEX
1426            .captures_iter(branch_in_between)
1427            .map(|cap| {
1428                (
1429                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1430                    String::from(cap.get(2).unwrap().as_str()),
1431                )
1432            })
1433            .collect::<HashMap<_, _>>();
1434
1435        assert_eq!(paths.len(), 1);
1436        assert_eq!(
1437            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1438            "v4.8.0-791-g8829465a"
1439        );
1440    }
1441}