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