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