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