foundry_cli/utils/
mod.rs

1use alloy_json_abi::JsonAbi;
2use alloy_primitives::{U256, map::HashMap};
3use alloy_provider::{Provider, network::AnyNetwork};
4use eyre::{ContextCompat, Result};
5use foundry_common::{
6    provider::{ProviderBuilder, RetryProvider},
7    shell,
8};
9use foundry_config::{Chain, Config};
10use itertools::Itertools;
11use regex::Regex;
12use serde::de::DeserializeOwned;
13use std::{
14    ffi::OsStr,
15    path::{Path, PathBuf},
16    process::{Command, Output, Stdio},
17    str::FromStr,
18    sync::LazyLock,
19    time::{Duration, SystemTime, UNIX_EPOCH},
20};
21use tracing_subscriber::prelude::*;
22
23mod cmd;
24pub use cmd::*;
25
26mod suggestions;
27pub use suggestions::*;
28
29mod abi;
30pub use abi::*;
31
32mod allocator;
33pub use allocator::*;
34
35// reexport all `foundry_config::utils`
36#[doc(hidden)]
37pub use foundry_config::utils::*;
38
39/// Deterministic fuzzer seed used for gas snapshots and coverage reports.
40///
41/// The keccak256 hash of "foundry rulez"
42pub const STATIC_FUZZ_SEED: [u8; 32] = [
43    0x01, 0x00, 0xfa, 0x69, 0xa5, 0xf1, 0x71, 0x0a, 0x95, 0xcd, 0xef, 0x94, 0x88, 0x9b, 0x02, 0x84,
44    0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6,
45];
46
47/// Regex used to parse `.gitmodules` file and capture the submodule path and branch.
48pub static SUBMODULE_BRANCH_REGEX: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap());
50/// Regex used to parse `git submodule status` output.
51pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
52    LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
53
54/// Useful extensions to [`std::path::Path`].
55pub trait FoundryPathExt {
56    /// Returns true if the [`Path`] ends with `.t.sol`
57    fn is_sol_test(&self) -> bool;
58
59    /// Returns true if the  [`Path`] has a `sol` extension
60    fn is_sol(&self) -> bool;
61
62    /// Returns true if the  [`Path`] has a `yul` extension
63    fn is_yul(&self) -> bool;
64}
65
66impl<T: AsRef<Path>> FoundryPathExt for T {
67    fn is_sol_test(&self) -> bool {
68        self.as_ref()
69            .file_name()
70            .and_then(|s| s.to_str())
71            .map(|s| s.ends_with(".t.sol"))
72            .unwrap_or_default()
73    }
74
75    fn is_sol(&self) -> bool {
76        self.as_ref().extension() == Some(std::ffi::OsStr::new("sol"))
77    }
78
79    fn is_yul(&self) -> bool {
80        self.as_ref().extension() == Some(std::ffi::OsStr::new("yul"))
81    }
82}
83
84/// Initializes a tracing Subscriber for logging
85pub fn subscriber() {
86    let registry = tracing_subscriber::Registry::default().with(env_filter());
87    #[cfg(feature = "tracy")]
88    let registry = registry.with(tracing_tracy::TracyLayer::default());
89    registry.with(tracing_subscriber::fmt::layer()).init()
90}
91
92fn env_filter() -> tracing_subscriber::EnvFilter {
93    const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt");
94    let mut filter = tracing_subscriber::EnvFilter::from_default_env();
95    for &directive in DEFAULT_DIRECTIVES {
96        filter = filter.add_directive(directive.parse().unwrap());
97    }
98    filter
99}
100
101pub fn abi_to_solidity(abi: &JsonAbi, name: &str) -> Result<String> {
102    let s = abi.to_sol(name, None);
103    let s = forge_fmt::format(&s)?;
104    Ok(s)
105}
106
107/// Returns a [RetryProvider] instantiated using [Config]'s
108/// RPC
109pub fn get_provider(config: &Config) -> Result<RetryProvider> {
110    get_provider_builder(config)?.build()
111}
112
113/// Returns a [ProviderBuilder] instantiated using [Config] values.
114///
115/// Defaults to `http://localhost:8545` and `Mainnet`.
116pub fn get_provider_builder(config: &Config) -> Result<ProviderBuilder> {
117    let url = config.get_rpc_url_or_localhost_http()?;
118    let mut builder = ProviderBuilder::new(url.as_ref());
119
120    builder = builder.accept_invalid_certs(config.eth_rpc_accept_invalid_certs);
121
122    if let Ok(chain) = config.chain.unwrap_or_default().try_into() {
123        builder = builder.chain(chain);
124    }
125
126    if let Some(jwt) = config.get_rpc_jwt_secret()? {
127        builder = builder.jwt(jwt.as_ref());
128    }
129
130    if let Some(rpc_timeout) = config.eth_rpc_timeout {
131        builder = builder.timeout(Duration::from_secs(rpc_timeout));
132    }
133
134    if let Some(rpc_headers) = config.eth_rpc_headers.clone() {
135        builder = builder.headers(rpc_headers);
136    }
137
138    Ok(builder)
139}
140
141pub async fn get_chain<P>(chain: Option<Chain>, provider: P) -> Result<Chain>
142where
143    P: Provider<AnyNetwork>,
144{
145    match chain {
146        Some(chain) => Ok(chain),
147        None => Ok(Chain::from_id(provider.get_chain_id().await?)),
148    }
149}
150
151/// Parses an ether value from a string.
152///
153/// The amount can be tagged with a unit, e.g. "1ether".
154///
155/// If the string represents an untagged amount (e.g. "100") then
156/// it is interpreted as wei.
157pub fn parse_ether_value(value: &str) -> Result<U256> {
158    Ok(if value.starts_with("0x") {
159        U256::from_str_radix(value, 16)?
160    } else {
161        alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)?
162            .as_uint()
163            .wrap_err("Could not parse ether value from string")?
164            .0
165    })
166}
167
168/// Parses a `T` from a string using [`serde_json::from_str`].
169pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
170    serde_json::from_str(value)
171}
172
173/// Parses a `Duration` from a &str
174pub fn parse_delay(delay: &str) -> Result<Duration> {
175    let delay = if delay.ends_with("ms") {
176        let d: u64 = delay.trim_end_matches("ms").parse()?;
177        Duration::from_millis(d)
178    } else {
179        let d: f64 = delay.parse()?;
180        let delay = (d * 1000.0).round();
181        if delay.is_infinite() || delay.is_nan() || delay.is_sign_negative() {
182            eyre::bail!("delay must be finite and non-negative");
183        }
184
185        Duration::from_millis(delay as u64)
186    };
187    Ok(delay)
188}
189
190/// Returns the current time as a [`Duration`] since the Unix epoch.
191pub fn now() -> Duration {
192    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
193}
194
195/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber).
196pub fn common_setup() {
197    install_crypto_provider();
198    crate::handler::install();
199    load_dotenv();
200    enable_paint();
201}
202
203/// Loads a dotenv file, from the cwd and the project root, ignoring potential failure.
204///
205/// We could use `warn!` here, but that would imply that the dotenv file can't configure
206/// the logging behavior of Foundry.
207///
208/// Similarly, we could just use `eprintln!`, but colors are off limits otherwise dotenv is implied
209/// to not be able to configure the colors. It would also mess up the JSON output.
210pub fn load_dotenv() {
211    let load = |p: &Path| {
212        dotenvy::from_path(p.join(".env")).ok();
213    };
214
215    // we only want the .env file of the cwd and project root
216    // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or
217    // both `Err`
218    if let (Ok(cwd), Ok(prj_root)) = (std::env::current_dir(), find_project_root(None)) {
219        load(&prj_root);
220        if cwd != prj_root {
221            // prj root and cwd can be identical
222            load(&cwd);
223        }
224    };
225}
226
227/// Sets the default [`yansi`] color output condition.
228pub fn enable_paint() {
229    let enable = yansi::Condition::os_support() && yansi::Condition::tty_and_color_live();
230    yansi::whenever(yansi::Condition::cached(enable));
231}
232
233/// This force installs the default crypto provider.
234///
235/// This is necessary in case there are more than one available backends enabled in rustls (ring,
236/// aws-lc-rs).
237///
238/// This should be called high in the main fn.
239///
240/// See also:
241///   <https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455100010>
242///   <https://github.com/awslabs/aws-sdk-rust/discussions/1257>
243pub fn install_crypto_provider() {
244    // https://github.com/snapview/tokio-tungstenite/issues/353
245    rustls::crypto::ring::default_provider()
246        .install_default()
247        .expect("Failed to install default rustls crypto provider");
248}
249
250/// Useful extensions to [`std::process::Command`].
251pub trait CommandUtils {
252    /// Returns the command's output if execution is successful, otherwise, throws an error.
253    fn exec(&mut self) -> Result<Output>;
254
255    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
256    fn get_stdout_lossy(&mut self) -> Result<String>;
257}
258
259impl CommandUtils for Command {
260    #[track_caller]
261    fn exec(&mut self) -> Result<Output> {
262        trace!(command=?self, "executing");
263
264        let output = self.output()?;
265
266        trace!(code=?output.status.code(), ?output);
267
268        if output.status.success() {
269            Ok(output)
270        } else {
271            let stdout = String::from_utf8_lossy(&output.stdout);
272            let stdout = stdout.trim();
273            let stderr = String::from_utf8_lossy(&output.stderr);
274            let stderr = stderr.trim();
275            let msg = if stdout.is_empty() {
276                stderr.to_string()
277            } else if stderr.is_empty() {
278                stdout.to_string()
279            } else {
280                format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
281            };
282
283            let mut name = self.get_program().to_string_lossy();
284            if let Some(arg) = self.get_args().next() {
285                let arg = arg.to_string_lossy();
286                if !arg.starts_with('-') {
287                    let name = name.to_mut();
288                    name.push(' ');
289                    name.push_str(&arg);
290                }
291            }
292
293            let mut err = match output.status.code() {
294                Some(code) => format!("{name} exited with code {code}"),
295                None => format!("{name} terminated by a signal"),
296            };
297            if !msg.is_empty() {
298                err.push(':');
299                err.push(if msg.lines().count() == 0 { ' ' } else { '\n' });
300                err.push_str(&msg);
301            }
302            Err(eyre::eyre!(err))
303        }
304    }
305
306    #[track_caller]
307    fn get_stdout_lossy(&mut self) -> Result<String> {
308        let output = self.exec()?;
309        let stdout = String::from_utf8_lossy(&output.stdout);
310        Ok(stdout.trim().into())
311    }
312}
313
314#[derive(Clone, Copy, Debug)]
315pub struct Git<'a> {
316    pub root: &'a Path,
317    pub quiet: bool,
318    pub shallow: bool,
319}
320
321impl<'a> Git<'a> {
322    pub fn new(root: &'a Path) -> Self {
323        Self { root, quiet: shell::is_quiet(), shallow: false }
324    }
325
326    pub fn from_config(config: &'a Config) -> Self {
327        Self::new(config.root.as_path())
328    }
329
330    pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
331        let output = Self::cmd_no_root()
332            .current_dir(relative_to)
333            .args(["rev-parse", "--show-toplevel"])
334            .get_stdout_lossy()?;
335        Ok(PathBuf::from(output))
336    }
337
338    pub fn clone_with_branch(
339        shallow: bool,
340        from: impl AsRef<OsStr>,
341        branch: impl AsRef<OsStr>,
342        to: Option<impl AsRef<OsStr>>,
343    ) -> Result<()> {
344        Self::cmd_no_root()
345            .stderr(Stdio::inherit())
346            .args(["clone", "--recurse-submodules"])
347            .args(shallow.then_some("--depth=1"))
348            .args(shallow.then_some("--shallow-submodules"))
349            .arg("-b")
350            .arg(branch)
351            .arg(from)
352            .args(to)
353            .exec()
354            .map(drop)
355    }
356
357    pub fn clone(
358        shallow: bool,
359        from: impl AsRef<OsStr>,
360        to: Option<impl AsRef<OsStr>>,
361    ) -> Result<()> {
362        Self::cmd_no_root()
363            .stderr(Stdio::inherit())
364            .args(["clone", "--recurse-submodules"])
365            .args(shallow.then_some("--depth=1"))
366            .args(shallow.then_some("--shallow-submodules"))
367            .arg(from)
368            .args(to)
369            .exec()
370            .map(drop)
371    }
372
373    pub fn fetch(
374        self,
375        shallow: bool,
376        remote: impl AsRef<OsStr>,
377        branch: Option<impl AsRef<OsStr>>,
378    ) -> Result<()> {
379        self.cmd()
380            .stderr(Stdio::inherit())
381            .arg("fetch")
382            .args(shallow.then_some("--no-tags"))
383            .args(shallow.then_some("--depth=1"))
384            .arg(remote)
385            .args(branch)
386            .exec()
387            .map(drop)
388    }
389
390    pub fn root(self, root: &Path) -> Git<'_> {
391        Git { root, ..self }
392    }
393
394    pub fn quiet(self, quiet: bool) -> Self {
395        Self { quiet, ..self }
396    }
397
398    /// True to perform shallow clones
399    pub fn shallow(self, shallow: bool) -> Self {
400        Self { shallow, ..self }
401    }
402
403    pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
404        self.cmd()
405            .arg("checkout")
406            .args(recursive.then_some("--recurse-submodules"))
407            .arg(tag)
408            .exec()
409            .map(drop)
410    }
411
412    /// Returns the current HEAD commit hash of the current branch.
413    pub fn head(self) -> Result<String> {
414        self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
415    }
416
417    pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
418        self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop)
419    }
420
421    pub fn init(self) -> Result<()> {
422        self.cmd().arg("init").exec().map(drop)
423    }
424
425    pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
426        let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
427        let branch =
428            self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
429        Ok((rev, branch))
430    }
431
432    #[expect(clippy::should_implement_trait)] // this is not std::ops::Add clippy
433    pub fn add<I, S>(self, paths: I) -> Result<()>
434    where
435        I: IntoIterator<Item = S>,
436        S: AsRef<OsStr>,
437    {
438        self.cmd().arg("add").args(paths).exec().map(drop)
439    }
440
441    pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
442        self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
443    }
444
445    pub fn commit_tree(
446        self,
447        tree: impl AsRef<OsStr>,
448        msg: Option<impl AsRef<OsStr>>,
449    ) -> Result<String> {
450        self.cmd()
451            .arg("commit-tree")
452            .arg(tree)
453            .args(msg.as_ref().is_some().then_some("-m"))
454            .args(msg)
455            .get_stdout_lossy()
456    }
457
458    pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
459    where
460        I: IntoIterator<Item = S>,
461        S: AsRef<OsStr>,
462    {
463        self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
464    }
465
466    pub fn commit(self, msg: &str) -> Result<()> {
467        let output = self
468            .cmd()
469            .args(["commit", "-m", msg])
470            .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
471            .output()?;
472        if !output.status.success() {
473            let stdout = String::from_utf8_lossy(&output.stdout);
474            let stderr = String::from_utf8_lossy(&output.stderr);
475            // ignore "nothing to commit" error
476            let msg = "nothing to commit, working tree clean";
477            if !(stdout.contains(msg) || stderr.contains(msg)) {
478                return Err(eyre::eyre!(
479                    "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
480                    output.status.code(),
481                    stdout.trim(),
482                    stderr.trim()
483                ));
484            }
485        }
486        Ok(())
487    }
488
489    pub fn is_in_repo(self) -> std::io::Result<bool> {
490        self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
491    }
492
493    pub fn is_repo_root(self) -> Result<bool> {
494        self.cmd().args(["rev-parse", "--show-cdup"]).exec().map(|out| out.stdout.is_empty())
495    }
496
497    pub fn is_clean(self) -> Result<bool> {
498        self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
499    }
500
501    pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
502        self.cmd_at(at)
503            .args(["branch", "--list", "--no-color"])
504            .arg(branch)
505            .get_stdout_lossy()
506            .map(|stdout| !stdout.is_empty())
507    }
508
509    pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
510        self.cmd_at(at)
511            .args(["tag", "--list"])
512            .arg(tag)
513            .get_stdout_lossy()
514            .map(|stdout| !stdout.is_empty())
515    }
516
517    pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
518        self.cmd_at(at)
519            .args(["cat-file", "-t"])
520            .arg(rev)
521            .get_stdout_lossy()
522            .map(|stdout| &stdout == "commit")
523    }
524
525    pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
526        self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
527    }
528
529    pub fn ensure_clean(self) -> Result<()> {
530        if self.is_clean()? {
531            Ok(())
532        } else {
533            Err(eyre::eyre!(
534                "\
535The target directory is a part of or on its own an already initialized git repository,
536and it requires clean working and staging areas, including no untracked files.
537
538Check the current git repository's status with `git status`.
539Then, you can track files with `git add ...` and then commit them with `git commit`,
540ignore them in the `.gitignore` file."
541            ))
542        }
543    }
544
545    pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
546        self.cmd()
547            .arg("rev-parse")
548            .args(short.then_some("--short"))
549            .arg(revision)
550            .get_stdout_lossy()
551    }
552
553    pub fn tag(self) -> Result<String> {
554        self.cmd().arg("tag").get_stdout_lossy()
555    }
556
557    /// Returns the tag the commit first appeared in.
558    ///
559    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
560    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
561    /// in `v0.2.0`.
562    ///
563    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
564    pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
565        self.cmd_at(at)
566            .args(["tag", "--contains"])
567            .arg(rev)
568            .get_stdout_lossy()
569            .map(|stdout| stdout.lines().next().map(str::to_string))
570    }
571
572    /// Returns a list of tuples of submodule paths and their respective branches.
573    ///
574    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
575    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
576    ///
577    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
578    /// `lib` is name of the directory where the submodules are located.
579    pub fn read_submodules_with_branch(
580        self,
581        at: &Path,
582        lib: &OsStr,
583    ) -> Result<HashMap<PathBuf, String>> {
584        // Read the .gitmodules file
585        let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
586
587        let paths = SUBMODULE_BRANCH_REGEX
588            .captures_iter(&gitmodules)
589            .map(|cap| {
590                let path_str = cap.get(1).unwrap().as_str();
591                let path = PathBuf::from_str(path_str).unwrap();
592                trace!(path = %path.display(), "unstripped path");
593
594                // Keep only the components that come after the lib directory.
595                // This needs to be done because the lockfile uses paths relative foundry project
596                // root whereas .gitmodules use paths relative to the git root which may not be the
597                // project root. e.g monorepo.
598                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
599                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
600                let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
601                let path = path
602                    .components()
603                    .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
604                    .collect::<PathBuf>();
605
606                let branch = cap.get(2).unwrap().as_str().to_string();
607                (path, branch)
608            })
609            .collect::<HashMap<_, _>>();
610
611        Ok(paths)
612    }
613
614    pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
615    where
616        I: IntoIterator<Item = S>,
617        S: AsRef<OsStr>,
618    {
619        self.cmd()
620            .args(["submodule", "status"])
621            .args(paths)
622            .get_stdout_lossy()
623            .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
624    }
625
626    /// Returns true if the given path has no submodules by checking `git submodule status`
627    pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
628    where
629        I: IntoIterator<Item = S>,
630        S: AsRef<OsStr>,
631    {
632        self.cmd()
633            .args(["submodule", "status"])
634            .args(paths)
635            .get_stdout_lossy()
636            .map(|stdout| stdout.trim().lines().next().is_some())
637    }
638
639    pub fn submodule_add(
640        self,
641        force: bool,
642        url: impl AsRef<OsStr>,
643        path: impl AsRef<OsStr>,
644    ) -> Result<()> {
645        self.cmd()
646            .stderr(self.stderr())
647            .args(["submodule", "add"])
648            .args(self.shallow.then_some("--depth=1"))
649            .args(force.then_some("--force"))
650            .arg(url)
651            .arg(path)
652            .exec()
653            .map(drop)
654    }
655
656    pub fn submodule_update<I, S>(
657        self,
658        force: bool,
659        remote: bool,
660        no_fetch: bool,
661        recursive: bool,
662        paths: I,
663    ) -> Result<()>
664    where
665        I: IntoIterator<Item = S>,
666        S: AsRef<OsStr>,
667    {
668        self.cmd()
669            .stderr(self.stderr())
670            .args(["submodule", "update", "--progress", "--init"])
671            .args(self.shallow.then_some("--depth=1"))
672            .args(force.then_some("--force"))
673            .args(remote.then_some("--remote"))
674            .args(no_fetch.then_some("--no-fetch"))
675            .args(recursive.then_some("--recursive"))
676            .args(paths)
677            .exec()
678            .map(drop)
679    }
680
681    pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
682        self.cmd()
683            .stderr(self.stderr())
684            .args(["submodule", "foreach"])
685            .args(recursive.then_some("--recursive"))
686            .arg(cmd)
687            .exec()
688            .map(drop)
689    }
690
691    /// If the status is prefix with `-`, the submodule is not initialized.
692    ///
693    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
694    pub fn submodules_uninitialized(self) -> Result<bool> {
695        self.cmd()
696            .args(["submodule", "status"])
697            .get_stdout_lossy()
698            .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
699    }
700
701    /// Initializes the git submodules.
702    pub fn submodule_init(self) -> Result<()> {
703        self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
704    }
705
706    pub fn submodules(&self) -> Result<Submodules> {
707        self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
708    }
709
710    pub fn submodule_sync(self) -> Result<()> {
711        self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
712    }
713
714    pub fn cmd(self) -> Command {
715        let mut cmd = Self::cmd_no_root();
716        cmd.current_dir(self.root);
717        cmd
718    }
719
720    pub fn cmd_at(self, path: &Path) -> Command {
721        let mut cmd = Self::cmd_no_root();
722        cmd.current_dir(path);
723        cmd
724    }
725
726    pub fn cmd_no_root() -> Command {
727        let mut cmd = Command::new("git");
728        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
729        cmd
730    }
731
732    // don't set this in cmd() because it's not wanted for all commands
733    fn stderr(self) -> Stdio {
734        if self.quiet { Stdio::piped() } else { Stdio::inherit() }
735    }
736}
737
738/// Deserialized `git submodule status lib/dep` output.
739#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
740pub struct Submodule {
741    /// Current commit hash the submodule is checked out at.
742    rev: String,
743    /// Relative path to the submodule.
744    path: PathBuf,
745}
746
747impl Submodule {
748    pub fn new(rev: String, path: PathBuf) -> Self {
749        Self { rev, path }
750    }
751
752    pub fn rev(&self) -> &str {
753        &self.rev
754    }
755
756    pub fn path(&self) -> &PathBuf {
757        &self.path
758    }
759}
760
761impl FromStr for Submodule {
762    type Err = eyre::Report;
763
764    fn from_str(s: &str) -> Result<Self> {
765        let caps = SUBMODULE_STATUS_REGEX
766            .captures(s)
767            .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
768
769        Ok(Self {
770            rev: caps.get(1).unwrap().as_str().to_string(),
771            path: PathBuf::from(caps.get(2).unwrap().as_str()),
772        })
773    }
774}
775
776/// Deserialized `git submodule status` output.
777#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct Submodules(pub Vec<Submodule>);
779
780impl Submodules {
781    pub fn len(&self) -> usize {
782        self.0.len()
783    }
784
785    pub fn is_empty(&self) -> bool {
786        self.0.is_empty()
787    }
788}
789
790impl FromStr for Submodules {
791    type Err = eyre::Report;
792
793    fn from_str(s: &str) -> Result<Self> {
794        let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
795        Ok(Self(subs))
796    }
797}
798
799impl<'a> IntoIterator for &'a Submodules {
800    type Item = &'a Submodule;
801    type IntoIter = std::slice::Iter<'a, Submodule>;
802
803    fn into_iter(self) -> Self::IntoIter {
804        self.0.iter()
805    }
806}
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use foundry_common::fs;
811    use std::{env, fs::File, io::Write};
812    use tempfile::tempdir;
813
814    #[test]
815    fn parse_submodule_status() {
816        let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
817        let sub = Submodule::from_str(s).unwrap();
818        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
819        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
820
821        let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
822        let sub = Submodule::from_str(s).unwrap();
823        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
824        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
825
826        let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
827        let sub = Submodule::from_str(s).unwrap();
828        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
829        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
830    }
831
832    #[test]
833    fn parse_multiline_submodule_status() {
834        let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
835+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
836"#;
837        let subs = Submodules::from_str(s).unwrap().0;
838        assert_eq!(subs.len(), 2);
839        assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
840        assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
841        assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
842        assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
843    }
844
845    #[test]
846    fn foundry_path_ext_works() {
847        let p = Path::new("contracts/MyTest.t.sol");
848        assert!(p.is_sol_test());
849        assert!(p.is_sol());
850        let p = Path::new("contracts/Greeter.sol");
851        assert!(!p.is_sol_test());
852    }
853
854    // loads .env from cwd and project dir, See [`find_project_root()`]
855    #[test]
856    fn can_load_dotenv() {
857        let temp = tempdir().unwrap();
858        Git::new(temp.path()).init().unwrap();
859        let cwd_env = temp.path().join(".env");
860        fs::create_file(temp.path().join("foundry.toml")).unwrap();
861        let nested = temp.path().join("nested");
862        fs::create_dir(&nested).unwrap();
863
864        let mut cwd_file = File::create(cwd_env).unwrap();
865        let mut prj_file = File::create(nested.join(".env")).unwrap();
866
867        cwd_file.write_all("TESTCWDKEY=cwd_val".as_bytes()).unwrap();
868        cwd_file.sync_all().unwrap();
869
870        prj_file.write_all("TESTPRJKEY=prj_val".as_bytes()).unwrap();
871        prj_file.sync_all().unwrap();
872
873        let cwd = env::current_dir().unwrap();
874        env::set_current_dir(nested).unwrap();
875        load_dotenv();
876        env::set_current_dir(cwd).unwrap();
877
878        assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
879        assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
880    }
881
882    #[test]
883    fn test_read_gitmodules_regex() {
884        let gitmodules = r#"
885        [submodule "lib/solady"]
886        path = lib/solady
887        url = ""
888        branch = v0.1.0
889        [submodule "lib/openzeppelin-contracts"]
890        path = lib/openzeppelin-contracts
891        url = ""
892        branch = v4.8.0-791-g8829465a
893        [submodule "lib/forge-std"]
894        path = lib/forge-std
895        url = ""
896"#;
897
898        let paths = SUBMODULE_BRANCH_REGEX
899            .captures_iter(gitmodules)
900            .map(|cap| {
901                (
902                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
903                    String::from(cap.get(2).unwrap().as_str()),
904                )
905            })
906            .collect::<HashMap<_, _>>();
907
908        assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
909        assert_eq!(
910            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
911            "v4.8.0-791-g8829465a"
912        );
913
914        let no_branch_gitmodules = r#"
915        [submodule "lib/solady"]
916        path = lib/solady
917        url = ""
918        [submodule "lib/openzeppelin-contracts"]
919        path = lib/openzeppelin-contracts
920        url = ""
921        [submodule "lib/forge-std"]
922        path = lib/forge-std
923        url = ""
924"#;
925        let paths = SUBMODULE_BRANCH_REGEX
926            .captures_iter(no_branch_gitmodules)
927            .map(|cap| {
928                (
929                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
930                    String::from(cap.get(2).unwrap().as_str()),
931                )
932            })
933            .collect::<HashMap<_, _>>();
934
935        assert!(paths.is_empty());
936
937        let branch_in_between = r#"
938        [submodule "lib/solady"]
939        path = lib/solady
940        url = ""
941        [submodule "lib/openzeppelin-contracts"]
942        path = lib/openzeppelin-contracts
943        url = ""
944        branch = v4.8.0-791-g8829465a
945        [submodule "lib/forge-std"]
946        path = lib/forge-std
947        url = ""
948        "#;
949
950        let paths = SUBMODULE_BRANCH_REGEX
951            .captures_iter(branch_in_between)
952            .map(|cap| {
953                (
954                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
955                    String::from(cap.get(2).unwrap().as_str()),
956                )
957            })
958            .collect::<HashMap<_, _>>();
959
960        assert_eq!(paths.len(), 1);
961        assert_eq!(
962            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
963            "v4.8.0-791-g8829465a"
964        );
965    }
966}