Skip to main content

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