Skip to main content

foundry_cli/utils/
mod.rs

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