foundry_cli/utils/
mod.rs

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