Skip to main content

foundry_cli/utils/
mod.rs

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