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") {
133        U256::from_str_radix(value, 16)?
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 api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default();
231    let client = foundry_block_explorers::Client::new(chain, api_key)?;
232    let source = client.contract_source_code(address).await?;
233    source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect()
234}
235
236/// Useful extensions to [`std::process::Command`].
237pub trait CommandUtils {
238    /// Returns the command's output if execution is successful, otherwise, throws an error.
239    fn exec(&mut self) -> Result<Output>;
240
241    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
242    fn get_stdout_lossy(&mut self) -> Result<String>;
243}
244
245impl CommandUtils for Command {
246    #[track_caller]
247    fn exec(&mut self) -> Result<Output> {
248        trace!(command=?self, "executing");
249
250        let output = self.output()?;
251
252        trace!(code=?output.status.code(), ?output);
253
254        if output.status.success() {
255            Ok(output)
256        } else {
257            let stdout = String::from_utf8_lossy(&output.stdout);
258            let stdout = stdout.trim();
259            let stderr = String::from_utf8_lossy(&output.stderr);
260            let stderr = stderr.trim();
261            let msg = if stdout.is_empty() {
262                stderr.to_string()
263            } else if stderr.is_empty() {
264                stdout.to_string()
265            } else {
266                format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
267            };
268
269            let mut name = self.get_program().to_string_lossy();
270            if let Some(arg) = self.get_args().next() {
271                let arg = arg.to_string_lossy();
272                if !arg.starts_with('-') {
273                    let name = name.to_mut();
274                    name.push(' ');
275                    name.push_str(&arg);
276                }
277            }
278
279            let mut err = match output.status.code() {
280                Some(code) => format!("{name} exited with code {code}"),
281                None => format!("{name} terminated by a signal"),
282            };
283            if !msg.is_empty() {
284                err.push(':');
285                err.push(if msg.lines().count() == 1 { ' ' } else { '\n' });
286                err.push_str(&msg);
287            }
288            Err(eyre::eyre!(err))
289        }
290    }
291
292    #[track_caller]
293    fn get_stdout_lossy(&mut self) -> Result<String> {
294        let output = self.exec()?;
295        let stdout = String::from_utf8_lossy(&output.stdout);
296        Ok(stdout.trim().into())
297    }
298}
299
300#[derive(Clone, Copy, Debug)]
301pub struct Git<'a> {
302    pub root: &'a Path,
303    pub quiet: bool,
304    pub shallow: bool,
305}
306
307impl<'a> Git<'a> {
308    pub fn new(root: &'a Path) -> Self {
309        Self { root, quiet: shell::is_quiet(), shallow: false }
310    }
311
312    pub fn from_config(config: &'a Config) -> Self {
313        Self::new(config.root.as_path())
314    }
315
316    pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
317        let output = Self::cmd_no_root()
318            .current_dir(relative_to)
319            .args(["rev-parse", "--show-toplevel"])
320            .get_stdout_lossy()?;
321        Ok(PathBuf::from(output))
322    }
323
324    pub fn clone_with_branch(
325        shallow: bool,
326        from: impl AsRef<OsStr>,
327        branch: impl AsRef<OsStr>,
328        to: Option<impl AsRef<OsStr>>,
329    ) -> Result<()> {
330        Self::cmd_no_root()
331            .stderr(Stdio::inherit())
332            .args(["clone", "--recurse-submodules"])
333            .args(shallow.then_some("--depth=1"))
334            .args(shallow.then_some("--shallow-submodules"))
335            .arg("-b")
336            .arg(branch)
337            .arg(from)
338            .args(to)
339            .exec()
340            .map(drop)
341    }
342
343    pub fn clone(
344        shallow: bool,
345        from: impl AsRef<OsStr>,
346        to: Option<impl AsRef<OsStr>>,
347    ) -> Result<()> {
348        Self::cmd_no_root()
349            .stderr(Stdio::inherit())
350            .args(["clone", "--recurse-submodules"])
351            .args(shallow.then_some("--depth=1"))
352            .args(shallow.then_some("--shallow-submodules"))
353            .arg(from)
354            .args(to)
355            .exec()
356            .map(drop)
357    }
358
359    pub fn fetch(
360        self,
361        shallow: bool,
362        remote: impl AsRef<OsStr>,
363        branch: Option<impl AsRef<OsStr>>,
364    ) -> Result<()> {
365        self.cmd()
366            .stderr(Stdio::inherit())
367            .arg("fetch")
368            .args(shallow.then_some("--no-tags"))
369            .args(shallow.then_some("--depth=1"))
370            .arg(remote)
371            .args(branch)
372            .exec()
373            .map(drop)
374    }
375
376    pub fn root(self, root: &Path) -> Git<'_> {
377        Git { root, ..self }
378    }
379
380    pub fn quiet(self, quiet: bool) -> Self {
381        Self { quiet, ..self }
382    }
383
384    /// True to perform shallow clones
385    pub fn shallow(self, shallow: bool) -> Self {
386        Self { shallow, ..self }
387    }
388
389    pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
390        self.cmd()
391            .arg("checkout")
392            .args(recursive.then_some("--recurse-submodules"))
393            .arg(tag)
394            .exec()
395            .map(drop)
396    }
397
398    /// Returns the current HEAD commit hash of the current branch.
399    pub fn head(self) -> Result<String> {
400        self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
401    }
402
403    pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
404        self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop)
405    }
406
407    pub fn init(self) -> Result<()> {
408        self.cmd().arg("init").exec().map(drop)
409    }
410
411    pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
412        let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
413        let branch =
414            self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
415        Ok((rev, branch))
416    }
417
418    #[expect(clippy::should_implement_trait)] // this is not std::ops::Add clippy
419    pub fn add<I, S>(self, paths: I) -> Result<()>
420    where
421        I: IntoIterator<Item = S>,
422        S: AsRef<OsStr>,
423    {
424        self.cmd().arg("add").args(paths).exec().map(drop)
425    }
426
427    pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
428        self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
429    }
430
431    pub fn commit_tree(
432        self,
433        tree: impl AsRef<OsStr>,
434        msg: Option<impl AsRef<OsStr>>,
435    ) -> Result<String> {
436        self.cmd()
437            .arg("commit-tree")
438            .arg(tree)
439            .args(msg.as_ref().is_some().then_some("-m"))
440            .args(msg)
441            .get_stdout_lossy()
442    }
443
444    pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
445    where
446        I: IntoIterator<Item = S>,
447        S: AsRef<OsStr>,
448    {
449        self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
450    }
451
452    pub fn commit(self, msg: &str) -> Result<()> {
453        let output = self
454            .cmd()
455            .args(["commit", "-m", msg])
456            .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
457            .output()?;
458        if !output.status.success() {
459            let stdout = String::from_utf8_lossy(&output.stdout);
460            let stderr = String::from_utf8_lossy(&output.stderr);
461            // ignore "nothing to commit" error
462            let msg = "nothing to commit, working tree clean";
463            if !(stdout.contains(msg) || stderr.contains(msg)) {
464                return Err(eyre::eyre!(
465                    "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
466                    output.status.code(),
467                    stdout.trim(),
468                    stderr.trim()
469                ));
470            }
471        }
472        Ok(())
473    }
474
475    pub fn is_in_repo(self) -> std::io::Result<bool> {
476        self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
477    }
478
479    pub fn is_repo_root(self) -> Result<bool> {
480        self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty())
481    }
482
483    pub fn is_clean(self) -> Result<bool> {
484        self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
485    }
486
487    pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
488        self.cmd_at(at)
489            .args(["branch", "--list", "--no-color"])
490            .arg(branch)
491            .get_stdout_lossy()
492            .map(|stdout| !stdout.is_empty())
493    }
494
495    pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
496        self.cmd_at(at)
497            .args(["tag", "--list"])
498            .arg(tag)
499            .get_stdout_lossy()
500            .map(|stdout| !stdout.is_empty())
501    }
502
503    pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
504        self.cmd_at(at)
505            .args(["cat-file", "-t"])
506            .arg(rev)
507            .get_stdout_lossy()
508            .map(|stdout| &stdout == "commit")
509    }
510
511    pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
512        self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
513    }
514
515    pub fn ensure_clean(self) -> Result<()> {
516        if self.is_clean()? {
517            Ok(())
518        } else {
519            Err(eyre::eyre!(
520                "\
521The target directory is a part of or on its own an already initialized git repository,
522and it requires clean working and staging areas, including no untracked files.
523
524Check the current git repository's status with `git status`.
525Then, you can track files with `git add ...` and then commit them with `git commit`,
526ignore them in the `.gitignore` file."
527            ))
528        }
529    }
530
531    pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
532        self.cmd()
533            .arg("rev-parse")
534            .args(short.then_some("--short"))
535            .arg(revision)
536            .get_stdout_lossy()
537    }
538
539    pub fn tag(self) -> Result<String> {
540        self.cmd().arg("tag").get_stdout_lossy()
541    }
542
543    /// Returns the tag the commit first appeared in.
544    ///
545    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
546    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
547    /// in `v0.2.0`.
548    ///
549    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
550    pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
551        self.cmd_at(at)
552            .args(["tag", "--contains"])
553            .arg(rev)
554            .get_stdout_lossy()
555            .map(|stdout| stdout.lines().next().map(str::to_string))
556    }
557
558    /// Returns a list of tuples of submodule paths and their respective branches.
559    ///
560    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
561    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
562    ///
563    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
564    /// `lib` is name of the directory where the submodules are located.
565    pub fn read_submodules_with_branch(
566        self,
567        at: &Path,
568        lib: &OsStr,
569    ) -> Result<HashMap<PathBuf, String>> {
570        // Read the .gitmodules file
571        let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
572
573        let paths = SUBMODULE_BRANCH_REGEX
574            .captures_iter(&gitmodules)
575            .map(|cap| {
576                let path_str = cap.get(1).unwrap().as_str();
577                let path = PathBuf::from_str(path_str).unwrap();
578                trace!(path = %path.display(), "unstripped path");
579
580                // Keep only the components that come after the lib directory.
581                // This needs to be done because the lockfile uses paths relative foundry project
582                // root whereas .gitmodules use paths relative to the git root which may not be the
583                // project root. e.g monorepo.
584                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
585                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
586                let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
587                let path = path
588                    .components()
589                    .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
590                    .collect::<PathBuf>();
591
592                let branch = cap.get(2).unwrap().as_str().to_string();
593                (path, branch)
594            })
595            .collect::<HashMap<_, _>>();
596
597        Ok(paths)
598    }
599
600    pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
601    where
602        I: IntoIterator<Item = S>,
603        S: AsRef<OsStr>,
604    {
605        self.cmd()
606            .args(["submodule", "status"])
607            .args(paths)
608            .get_stdout_lossy()
609            .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
610    }
611
612    /// Returns true if the given path has submodules by checking `git submodule status`
613    pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
614    where
615        I: IntoIterator<Item = S>,
616        S: AsRef<OsStr>,
617    {
618        self.cmd()
619            .args(["submodule", "status"])
620            .args(paths)
621            .get_stdout_lossy()
622            .map(|stdout| stdout.trim().lines().next().is_some())
623    }
624
625    pub fn submodule_add(
626        self,
627        force: bool,
628        url: impl AsRef<OsStr>,
629        path: impl AsRef<OsStr>,
630    ) -> Result<()> {
631        self.cmd()
632            .stderr(self.stderr())
633            .args(["submodule", "add"])
634            .args(self.shallow.then_some("--depth=1"))
635            .args(force.then_some("--force"))
636            .arg(url)
637            .arg(path)
638            .exec()
639            .map(drop)
640    }
641
642    pub fn submodule_update<I, S>(
643        self,
644        force: bool,
645        remote: bool,
646        no_fetch: bool,
647        recursive: bool,
648        paths: I,
649    ) -> Result<()>
650    where
651        I: IntoIterator<Item = S>,
652        S: AsRef<OsStr>,
653    {
654        self.cmd()
655            .stderr(self.stderr())
656            .args(["submodule", "update", "--progress", "--init"])
657            .args(self.shallow.then_some("--depth=1"))
658            .args(force.then_some("--force"))
659            .args(remote.then_some("--remote"))
660            .args(no_fetch.then_some("--no-fetch"))
661            .args(recursive.then_some("--recursive"))
662            .args(paths)
663            .exec()
664            .map(drop)
665    }
666
667    pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
668        self.cmd()
669            .stderr(self.stderr())
670            .args(["submodule", "foreach"])
671            .args(recursive.then_some("--recursive"))
672            .arg(cmd)
673            .exec()
674            .map(drop)
675    }
676
677    /// If the status is prefix with `-`, the submodule is not initialized.
678    ///
679    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
680    pub fn submodules_uninitialized(self) -> Result<bool> {
681        // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the
682        // "submodule status has '-' prefix" logic.
683        self.has_missing_dependencies(std::iter::empty::<&OsStr>())
684    }
685
686    /// Initializes the git submodules.
687    pub fn submodule_init(self) -> Result<()> {
688        self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
689    }
690
691    pub fn submodules(&self) -> Result<Submodules> {
692        self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
693    }
694
695    pub fn submodule_sync(self) -> Result<()> {
696        self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
697    }
698
699    /// Get the URL of a submodule from git config
700    pub fn submodule_url(self, path: &Path) -> Result<Option<String>> {
701        self.cmd()
702            .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())])
703            .get_stdout_lossy()
704            .map(|url| Some(url.trim().to_string()))
705    }
706
707    pub fn cmd(self) -> Command {
708        let mut cmd = Self::cmd_no_root();
709        cmd.current_dir(self.root);
710        cmd
711    }
712
713    pub fn cmd_at(self, path: &Path) -> Command {
714        let mut cmd = Self::cmd_no_root();
715        cmd.current_dir(path);
716        cmd
717    }
718
719    pub fn cmd_no_root() -> Command {
720        let mut cmd = Command::new("git");
721        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
722        cmd
723    }
724
725    // don't set this in cmd() because it's not wanted for all commands
726    fn stderr(self) -> Stdio {
727        if self.quiet { Stdio::piped() } else { Stdio::inherit() }
728    }
729}
730
731/// Deserialized `git submodule status lib/dep` output.
732#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
733pub struct Submodule {
734    /// Current commit hash the submodule is checked out at.
735    rev: String,
736    /// Relative path to the submodule.
737    path: PathBuf,
738}
739
740impl Submodule {
741    pub fn new(rev: String, path: PathBuf) -> Self {
742        Self { rev, path }
743    }
744
745    pub fn rev(&self) -> &str {
746        &self.rev
747    }
748
749    pub fn path(&self) -> &PathBuf {
750        &self.path
751    }
752}
753
754impl FromStr for Submodule {
755    type Err = eyre::Report;
756
757    fn from_str(s: &str) -> Result<Self> {
758        let caps = SUBMODULE_STATUS_REGEX
759            .captures(s)
760            .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
761
762        Ok(Self {
763            rev: caps.get(1).unwrap().as_str().to_string(),
764            path: PathBuf::from(caps.get(2).unwrap().as_str()),
765        })
766    }
767}
768
769/// Deserialized `git submodule status` output.
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct Submodules(pub Vec<Submodule>);
772
773impl Submodules {
774    pub fn len(&self) -> usize {
775        self.0.len()
776    }
777
778    pub fn is_empty(&self) -> bool {
779        self.0.is_empty()
780    }
781}
782
783impl FromStr for Submodules {
784    type Err = eyre::Report;
785
786    fn from_str(s: &str) -> Result<Self> {
787        let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
788        Ok(Self(subs))
789    }
790}
791
792impl<'a> IntoIterator for &'a Submodules {
793    type Item = &'a Submodule;
794    type IntoIter = std::slice::Iter<'a, Submodule>;
795
796    fn into_iter(self) -> Self::IntoIter {
797        self.0.iter()
798    }
799}
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use foundry_common::fs;
804    use std::{env, fs::File, io::Write};
805    use tempfile::tempdir;
806
807    #[test]
808    fn parse_submodule_status() {
809        let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
810        let sub = Submodule::from_str(s).unwrap();
811        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
812        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
813
814        let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
815        let sub = Submodule::from_str(s).unwrap();
816        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
817        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
818
819        let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
820        let sub = Submodule::from_str(s).unwrap();
821        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
822        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
823    }
824
825    #[test]
826    fn parse_multiline_submodule_status() {
827        let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
828+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
829"#;
830        let subs = Submodules::from_str(s).unwrap().0;
831        assert_eq!(subs.len(), 2);
832        assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
833        assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
834        assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
835        assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
836    }
837
838    #[test]
839    fn foundry_path_ext_works() {
840        let p = Path::new("contracts/MyTest.t.sol");
841        assert!(p.is_sol_test());
842        assert!(p.is_sol());
843        let p = Path::new("contracts/Greeter.sol");
844        assert!(!p.is_sol_test());
845    }
846
847    // loads .env from cwd and project dir, See [`find_project_root()`]
848    #[test]
849    fn can_load_dotenv() {
850        let temp = tempdir().unwrap();
851        Git::new(temp.path()).init().unwrap();
852        let cwd_env = temp.path().join(".env");
853        fs::create_file(temp.path().join("foundry.toml")).unwrap();
854        let nested = temp.path().join("nested");
855        fs::create_dir(&nested).unwrap();
856
857        let mut cwd_file = File::create(cwd_env).unwrap();
858        let mut prj_file = File::create(nested.join(".env")).unwrap();
859
860        cwd_file.write_all("TESTCWDKEY=cwd_val".as_bytes()).unwrap();
861        cwd_file.sync_all().unwrap();
862
863        prj_file.write_all("TESTPRJKEY=prj_val".as_bytes()).unwrap();
864        prj_file.sync_all().unwrap();
865
866        let cwd = env::current_dir().unwrap();
867        env::set_current_dir(nested).unwrap();
868        load_dotenv();
869        env::set_current_dir(cwd).unwrap();
870
871        assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
872        assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
873    }
874
875    #[test]
876    fn test_read_gitmodules_regex() {
877        let gitmodules = r#"
878        [submodule "lib/solady"]
879        path = lib/solady
880        url = ""
881        branch = v0.1.0
882        [submodule "lib/openzeppelin-contracts"]
883        path = lib/openzeppelin-contracts
884        url = ""
885        branch = v4.8.0-791-g8829465a
886        [submodule "lib/forge-std"]
887        path = lib/forge-std
888        url = ""
889"#;
890
891        let paths = SUBMODULE_BRANCH_REGEX
892            .captures_iter(gitmodules)
893            .map(|cap| {
894                (
895                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
896                    String::from(cap.get(2).unwrap().as_str()),
897                )
898            })
899            .collect::<HashMap<_, _>>();
900
901        assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
902        assert_eq!(
903            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
904            "v4.8.0-791-g8829465a"
905        );
906
907        let no_branch_gitmodules = r#"
908        [submodule "lib/solady"]
909        path = lib/solady
910        url = ""
911        [submodule "lib/openzeppelin-contracts"]
912        path = lib/openzeppelin-contracts
913        url = ""
914        [submodule "lib/forge-std"]
915        path = lib/forge-std
916        url = ""
917"#;
918        let paths = SUBMODULE_BRANCH_REGEX
919            .captures_iter(no_branch_gitmodules)
920            .map(|cap| {
921                (
922                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
923                    String::from(cap.get(2).unwrap().as_str()),
924                )
925            })
926            .collect::<HashMap<_, _>>();
927
928        assert!(paths.is_empty());
929
930        let branch_in_between = r#"
931        [submodule "lib/solady"]
932        path = lib/solady
933        url = ""
934        [submodule "lib/openzeppelin-contracts"]
935        path = lib/openzeppelin-contracts
936        url = ""
937        branch = v4.8.0-791-g8829465a
938        [submodule "lib/forge-std"]
939        path = lib/forge-std
940        url = ""
941        "#;
942
943        let paths = SUBMODULE_BRANCH_REGEX
944            .captures_iter(branch_in_between)
945            .map(|cap| {
946                (
947                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
948                    String::from(cap.get(2).unwrap().as_str()),
949                )
950            })
951            .collect::<HashMap<_, _>>();
952
953        assert_eq!(paths.len(), 1);
954        assert_eq!(
955            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
956            "v4.8.0-791-g8829465a"
957        );
958    }
959}