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, OsString},
13    path::{Path, PathBuf},
14    process::{Command, Output, Stdio},
15    str::FromStr,
16    sync::{LazyLock, OnceLock},
17    time::{Duration, SystemTime, UNIX_EPOCH},
18};
19use tracing_subscriber::{EnvFilter, prelude::*, reload};
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/// Handle to reload the tracing `EnvFilter` at runtime.
56static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
57    OnceLock::new();
58
59/// Useful extensions to [`std::path::Path`].
60pub trait FoundryPathExt {
61    /// Returns true if the [`Path`] ends with `.t.sol`
62    fn is_sol_test(&self) -> bool;
63
64    /// Returns true if the  [`Path`] has a `sol` extension
65    fn is_sol(&self) -> bool;
66
67    /// Returns true if the  [`Path`] has a `yul` extension
68    fn is_yul(&self) -> bool;
69}
70
71impl<T: AsRef<Path>> FoundryPathExt for T {
72    fn is_sol_test(&self) -> bool {
73        self.as_ref()
74            .file_name()
75            .and_then(|s| s.to_str())
76            .map(|s| s.ends_with(".t.sol"))
77            .unwrap_or_default()
78    }
79
80    fn is_sol(&self) -> bool {
81        self.as_ref().extension() == Some(std::ffi::OsStr::new("sol"))
82    }
83
84    fn is_yul(&self) -> bool {
85        self.as_ref().extension() == Some(std::ffi::OsStr::new("yul"))
86    }
87}
88
89/// Initializes a tracing Subscriber for logging.
90///
91/// The `EnvFilter` is wrapped in a [`reload::Layer`] so it can be reconfigured at runtime via
92/// [`update_tracing_filter`].
93pub fn subscriber() {
94    let (filter_layer, reload_handle) = reload::Layer::new(env_filter());
95    let registry = tracing_subscriber::Registry::default().with(filter_layer);
96    #[cfg(feature = "tracy")]
97    let registry = registry.with(tracing_tracy::TracyLayer::default());
98    registry.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)).init();
99    let _ = FILTER_RELOAD_HANDLE.set(reload_handle);
100}
101
102/// Replaces the active tracing `EnvFilter` at runtime.
103///
104/// `directives` is parsed as an [`EnvFilter`] (e.g. `"info"`, `"debug,hyper=off"`).
105/// This is a no-op if [`subscriber`] has not been called yet.
106pub fn update_tracing_filter(directives: &str) {
107    let Some(handle) = FILTER_RELOAD_HANDLE.get() else {
108        return;
109    };
110    let Ok(new_filter) = directives.parse::<EnvFilter>() else {
111        return;
112    };
113    let _ = handle.reload(new_filter);
114}
115
116fn env_filter() -> EnvFilter {
117    const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt");
118    let mut filter = EnvFilter::from_default_env();
119    for &directive in DEFAULT_DIRECTIVES {
120        filter = filter.add_directive(directive.parse().unwrap());
121    }
122    filter
123}
124
125/// Returns a [`RootProvider`] instantiated using [Config]'s RPC settings.
126pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
127    get_provider_builder(config)?.build()
128}
129
130/// Returns a [ProviderBuilder] instantiated using [Config] values.
131///
132/// Defaults to `http://localhost:8545` and `Mainnet`.
133pub fn get_provider_builder(config: &Config) -> Result<ProviderBuilder> {
134    ProviderBuilder::from_config(config)
135}
136
137pub async fn get_chain<N, P>(chain: Option<Chain>, provider: P) -> Result<Chain>
138where
139    N: Network,
140    P: Provider<N>,
141{
142    match chain {
143        Some(chain) => Ok(chain),
144        None => Ok(Chain::from_id(provider.get_chain_id().await?)),
145    }
146}
147
148/// Parses an ether value from a string.
149///
150/// The amount can be tagged with a unit, e.g. "1ether".
151///
152/// If the string represents an untagged amount (e.g. "100") then
153/// it is interpreted as wei.
154pub fn parse_ether_value(value: &str) -> Result<U256> {
155    Ok(if value.starts_with("0x") || value.starts_with("0X") {
156        U256::from_str(value)?
157    } else {
158        alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)?
159            .as_uint()
160            .wrap_err("Could not parse ether value from string")?
161            .0
162    })
163}
164
165/// Parses a `T` from a string using [`serde_json::from_str`].
166pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
167    serde_json::from_str(value)
168}
169
170/// Parses a `Duration` from a &str
171pub fn parse_delay(delay: &str) -> Result<Duration> {
172    let delay = if delay.ends_with("ms") {
173        let d: u64 = delay.trim_end_matches("ms").parse()?;
174        Duration::from_millis(d)
175    } else {
176        let d: f64 = delay.parse()?;
177        let delay = (d * 1000.0).round();
178        if delay.is_infinite() || delay.is_nan() || delay.is_sign_negative() {
179            eyre::bail!("delay must be finite and non-negative");
180        }
181
182        Duration::from_millis(delay as u64)
183    };
184    Ok(delay)
185}
186
187/// Returns the current time as a [`Duration`] since the Unix epoch.
188pub fn now() -> Duration {
189    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
190}
191
192/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber).
193pub fn common_setup() {
194    install_crypto_provider();
195    crate::handler::install();
196    load_dotenv();
197    enable_paint();
198}
199
200/// Loads a dotenv file, from the cwd and the project root, ignoring potential failure.
201///
202/// We could use `warn!` here, but that would imply that the dotenv file can't configure
203/// the logging behavior of Foundry.
204///
205/// Similarly, we could just use `eprintln!`, but colors are off limits otherwise dotenv is implied
206/// to not be able to configure the colors. It would also mess up the JSON output.
207pub fn load_dotenv() {
208    let load = |p: &Path| {
209        dotenvy::from_path(p.join(".env")).ok();
210    };
211
212    // we only want the .env file of the cwd and project root
213    // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or
214    // both `Err`
215    if let (Ok(cwd), Ok(prj_root)) = (std::env::current_dir(), find_project_root(None)) {
216        load(&prj_root);
217        if cwd != prj_root {
218            // prj root and cwd can be identical
219            load(&cwd);
220        }
221    };
222}
223
224/// Sets the default [`yansi`] color output condition.
225pub fn enable_paint() {
226    let enable = yansi::Condition::os_support() && yansi::Condition::tty_and_color_live();
227    yansi::whenever(yansi::Condition::cached(enable));
228}
229
230/// This force installs the default crypto provider.
231///
232/// This is necessary in case there are more than one available backends enabled in rustls (ring,
233/// aws-lc-rs).
234///
235/// This should be called high in the main fn.
236///
237/// See also:
238///   <https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455100010>
239///   <https://github.com/awslabs/aws-sdk-rust/discussions/1257>
240pub fn install_crypto_provider() {
241    // https://github.com/snapview/tokio-tungstenite/issues/353
242    rustls::crypto::ring::default_provider()
243        .install_default()
244        .expect("Failed to install default rustls crypto provider");
245}
246
247/// Fetches the ABI of a contract from Etherscan.
248pub async fn fetch_abi_from_etherscan(
249    address: Address,
250    config: &foundry_config::Config,
251) -> Result<Vec<(JsonAbi, String)>> {
252    let chain = config.chain.unwrap_or_default();
253    let client = config
254        .get_etherscan_config_with_chain(Some(chain))?
255        .ok_or_else(|| eyre::eyre!("No Etherscan API key configured for chain {chain}"))?
256        .into_client_with_no_proxy(config.eth_rpc_no_proxy)?;
257    let source = client.contract_source_code(address).await?;
258    source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect()
259}
260
261/// Useful extensions to [`std::process::Command`].
262pub trait CommandUtils {
263    /// Returns the command's output if execution is successful, otherwise, throws an error.
264    fn exec(&mut self) -> Result<Output>;
265
266    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
267    fn get_stdout_lossy(&mut self) -> Result<String>;
268}
269
270impl CommandUtils for Command {
271    #[track_caller]
272    fn exec(&mut self) -> Result<Output> {
273        trace!(command=?self, "executing");
274
275        let output = self.output()?;
276
277        trace!(code=?output.status.code(), ?output);
278
279        if output.status.success() {
280            Ok(output)
281        } else {
282            let stdout = String::from_utf8_lossy(&output.stdout);
283            let stdout = stdout.trim();
284            let stderr = String::from_utf8_lossy(&output.stderr);
285            let stderr = stderr.trim();
286            let msg = if stdout.is_empty() {
287                stderr.to_string()
288            } else if stderr.is_empty() {
289                stdout.to_string()
290            } else {
291                format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
292            };
293
294            let mut name = self.get_program().to_string_lossy();
295            if let Some(arg) = self.get_args().next() {
296                let arg = arg.to_string_lossy();
297                if !arg.starts_with('-') {
298                    let name = name.to_mut();
299                    name.push(' ');
300                    name.push_str(&arg);
301                }
302            }
303
304            let mut err = match output.status.code() {
305                Some(code) => format!("{name} exited with code {code}"),
306                None => format!("{name} terminated by a signal"),
307            };
308            if !msg.is_empty() {
309                err.push(':');
310                err.push(if msg.lines().count() == 1 { ' ' } else { '\n' });
311                err.push_str(&msg);
312            }
313            Err(eyre::eyre!(err))
314        }
315    }
316
317    #[track_caller]
318    fn get_stdout_lossy(&mut self) -> Result<String> {
319        let output = self.exec()?;
320        let stdout = String::from_utf8_lossy(&output.stdout);
321        Ok(stdout.trim().into())
322    }
323}
324
325#[derive(Clone, Copy, Debug)]
326pub struct Git<'a> {
327    pub root: &'a Path,
328    pub quiet: bool,
329    pub shallow: bool,
330}
331
332impl<'a> Git<'a> {
333    pub fn new(root: &'a Path) -> Self {
334        Self { root, quiet: shell::is_quiet(), shallow: false }
335    }
336
337    pub fn from_config(config: &'a Config) -> Self {
338        Self::new(config.root.as_path())
339    }
340
341    pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
342        let output = Self::cmd_no_root()
343            .current_dir(relative_to)
344            .args(["rev-parse", "--show-toplevel"])
345            .get_stdout_lossy()?;
346        Ok(PathBuf::from(output))
347    }
348
349    pub fn clone_with_branch(
350        shallow: bool,
351        from: impl AsRef<OsStr>,
352        branch: impl AsRef<OsStr>,
353        to: Option<impl AsRef<OsStr>>,
354    ) -> Result<()> {
355        Self::cmd_no_root()
356            .stderr(Stdio::inherit())
357            .args(["clone", "--recurse-submodules"])
358            .args(shallow.then_some("--depth=1"))
359            .args(shallow.then_some("--shallow-submodules"))
360            .arg("-b")
361            .arg(branch)
362            .arg(from)
363            .args(to)
364            .exec()
365            .map(drop)
366    }
367
368    pub fn clone(
369        shallow: bool,
370        from: impl AsRef<OsStr>,
371        to: Option<impl AsRef<OsStr>>,
372    ) -> Result<()> {
373        Self::cmd_no_root()
374            .stderr(Stdio::inherit())
375            .args(["clone", "--recurse-submodules"])
376            .args(shallow.then_some("--depth=1"))
377            .args(shallow.then_some("--shallow-submodules"))
378            .arg(from)
379            .args(to)
380            .exec()
381            .map(drop)
382    }
383
384    pub fn fetch(
385        self,
386        shallow: bool,
387        remote: impl AsRef<OsStr>,
388        branch: Option<impl AsRef<OsStr>>,
389    ) -> Result<()> {
390        self.cmd()
391            .stderr(Stdio::inherit())
392            .arg("fetch")
393            .args(shallow.then_some("--no-tags"))
394            .args(shallow.then_some("--depth=1"))
395            .arg(remote)
396            .args(branch)
397            .exec()
398            .map(drop)
399    }
400
401    pub const fn root(self, root: &Path) -> Git<'_> {
402        Git { root, ..self }
403    }
404
405    pub const fn quiet(self, quiet: bool) -> Self {
406        Self { quiet, ..self }
407    }
408
409    /// True to perform shallow clones
410    pub const fn shallow(self, shallow: bool) -> Self {
411        Self { shallow, ..self }
412    }
413
414    pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
415        self.cmd()
416            .arg("checkout")
417            .args(recursive.then_some("--recurse-submodules"))
418            .arg(tag)
419            .exec()
420            .map(drop)
421    }
422
423    /// Returns the current HEAD commit hash of the current branch.
424    pub fn head(self) -> Result<String> {
425        self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
426    }
427
428    pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
429        self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop)
430    }
431
432    pub fn init(self) -> Result<()> {
433        self.cmd().arg("init").exec().map(drop)
434    }
435
436    pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
437        let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
438        let branch =
439            self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
440        Ok((rev, branch))
441    }
442
443    #[expect(clippy::should_implement_trait)] // this is not std::ops::Add clippy
444    pub fn add<I, S>(self, paths: I) -> Result<()>
445    where
446        I: IntoIterator<Item = S>,
447        S: AsRef<OsStr>,
448    {
449        self.cmd().arg("add").args(paths).exec().map(drop)
450    }
451
452    pub fn add_literal(self, path: &Path) -> Result<()> {
453        self.cmd().args(["--literal-pathspecs", "add", "--"]).arg(path).exec().map(drop)
454    }
455
456    pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
457        self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
458    }
459
460    pub fn commit_tree(
461        self,
462        tree: impl AsRef<OsStr>,
463        msg: Option<impl AsRef<OsStr>>,
464    ) -> Result<String> {
465        self.cmd()
466            .arg("commit-tree")
467            .arg(tree)
468            .args(msg.as_ref().is_some().then_some("-m"))
469            .args(msg)
470            .get_stdout_lossy()
471    }
472
473    pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
474    where
475        I: IntoIterator<Item = S>,
476        S: AsRef<OsStr>,
477    {
478        self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
479    }
480
481    pub fn remove_index_path(self, path: &Path) -> Result<()> {
482        self.cmd()
483            .args(["--literal-pathspecs", "rm", "--cached", "--force", "--"])
484            .arg(path)
485            .exec()
486            .map(drop)
487    }
488
489    pub fn commit(self, msg: &str) -> Result<()> {
490        let output = self
491            .cmd()
492            .args(["commit", "-m", msg])
493            .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
494            .output()?;
495        if !output.status.success() {
496            let stdout = String::from_utf8_lossy(&output.stdout);
497            let stderr = String::from_utf8_lossy(&output.stderr);
498            // ignore "nothing to commit" error
499            let msg = "nothing to commit, working tree clean";
500            if !(stdout.contains(msg) || stderr.contains(msg)) {
501                return Err(eyre::eyre!(
502                    "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
503                    output.status.code(),
504                    stdout.trim(),
505                    stderr.trim()
506                ));
507            }
508        }
509        Ok(())
510    }
511
512    pub fn is_in_repo(self) -> std::io::Result<bool> {
513        self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
514    }
515
516    pub fn is_repo_root(self) -> Result<bool> {
517        self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty())
518    }
519
520    pub fn is_clean(self) -> Result<bool> {
521        self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
522    }
523
524    pub fn is_path_clean(self, path: &Path) -> Result<bool> {
525        self.cmd()
526            .args(["--literal-pathspecs", "status", "--porcelain", "--"])
527            .arg(path)
528            .exec()
529            .map(|out| out.stdout.is_empty())
530    }
531
532    pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
533        self.cmd_at(at)
534            .args(["branch", "--list", "--no-color"])
535            .arg(branch)
536            .get_stdout_lossy()
537            .map(|stdout| !stdout.is_empty())
538    }
539
540    pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
541        self.cmd_at(at)
542            .args(["tag", "--list"])
543            .arg(tag)
544            .get_stdout_lossy()
545            .map(|stdout| !stdout.is_empty())
546    }
547
548    pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
549        self.cmd_at(at)
550            .args(["cat-file", "-t"])
551            .arg(rev)
552            .get_stdout_lossy()
553            .map(|stdout| &stdout == "commit")
554    }
555
556    pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
557        self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
558    }
559
560    pub fn ensure_clean(self) -> Result<()> {
561        if self.is_clean()? {
562            Ok(())
563        } else {
564            Err(eyre::eyre!(
565                "\
566The target directory is a part of or on its own an already initialized git repository,
567and it requires clean working and staging areas, including no untracked files.
568
569Check the current git repository's status with `git status`.
570Then, you can track files with `git add ...` and then commit them with `git commit`,
571ignore them in the `.gitignore` file."
572            ))
573        }
574    }
575
576    pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
577        self.cmd()
578            .arg("rev-parse")
579            .args(short.then_some("--short"))
580            .arg(revision)
581            .get_stdout_lossy()
582    }
583
584    pub fn tag(self) -> Result<String> {
585        self.cmd().arg("tag").get_stdout_lossy()
586    }
587
588    /// Returns the tag the commit first appeared in.
589    ///
590    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
591    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
592    /// in `v0.2.0`.
593    ///
594    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
595    pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
596        self.cmd_at(at)
597            .args(["tag", "--contains"])
598            .arg(rev)
599            .get_stdout_lossy()
600            .map(|stdout| stdout.lines().next().map(str::to_string))
601    }
602
603    /// Returns a list of tuples of submodule paths and their respective branches.
604    ///
605    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
606    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
607    ///
608    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
609    /// `lib` is name of the directory where the submodules are located.
610    pub fn read_submodules_with_branch(
611        self,
612        at: &Path,
613        lib: &OsStr,
614    ) -> Result<HashMap<PathBuf, String>> {
615        // Read the .gitmodules file
616        let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
617
618        let paths = SUBMODULE_BRANCH_REGEX
619            .captures_iter(&gitmodules)
620            .map(|cap| {
621                let path_str = cap.get(1).unwrap().as_str();
622                let path = PathBuf::from_str(path_str).unwrap();
623                trace!(path = %path.display(), "unstripped path");
624
625                // Keep only the components that come after the lib directory.
626                // This needs to be done because the lockfile uses paths relative foundry project
627                // root whereas .gitmodules use paths relative to the git root which may not be the
628                // project root. e.g monorepo.
629                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
630                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
631                let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
632                let path = path
633                    .components()
634                    .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
635                    .collect::<PathBuf>();
636
637                let branch = cap.get(2).unwrap().as_str().to_string();
638                (path, branch)
639            })
640            .collect::<HashMap<_, _>>();
641
642        Ok(paths)
643    }
644
645    pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
646    where
647        I: IntoIterator<Item = S>,
648        S: AsRef<OsStr>,
649    {
650        let paths = paths.into_iter().map(|path| path.as_ref().to_owned()).collect::<Vec<_>>();
651        if self.submodules_initialized(&paths).unwrap_or(false) {
652            return Ok(false);
653        }
654
655        self.cmd()
656            .args(["submodule", "status"])
657            .args(&paths)
658            .get_stdout_lossy()
659            .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
660    }
661
662    /// Returns true if all submodules matching `paths` have initialized worktrees.
663    fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
664        let Some(root) = self.root.ancestors().find(|root| root.join(".git").exists()) else {
665            return Ok(false);
666        };
667        if paths.iter().any(|path| {
668            Path::new(path)
669                .components()
670                .any(|component| matches!(component, std::path::Component::ParentDir))
671        }) {
672            return Ok(false);
673        }
674        let relative_root = self.root.strip_prefix(root).unwrap_or_else(|_| Path::new(""));
675        let gitmodules = root.join(".gitmodules");
676        if !gitmodules.is_file() {
677            return Ok(false);
678        }
679
680        let output = Command::new("git")
681            .args(["config", "--null", "--file"])
682            .arg(gitmodules)
683            .args(["--get-regexp", r"^submodule\..*\.path$"])
684            .get_stdout_lossy()?;
685
686        for entry in output.split_terminator('\0') {
687            let (_, path) = entry
688                .split_once('\n')
689                .ok_or_else(|| eyre::eyre!("invalid submodule path config"))?;
690            let path = Path::new(path);
691            let matches = paths.is_empty()
692                || paths.iter().any(|prefix| {
693                    let prefix = Path::new(prefix);
694                    if prefix.is_absolute() {
695                        prefix.strip_prefix(root).is_ok_and(|prefix| path.starts_with(prefix))
696                    } else {
697                        path.starts_with(relative_root.join(prefix))
698                    }
699                });
700            if matches && !root.join(path).join(".git").exists() {
701                return Ok(false);
702            }
703        }
704        Ok(true)
705    }
706
707    /// Returns true if the given path has submodules by checking `git submodule status`
708    pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
709    where
710        I: IntoIterator<Item = S>,
711        S: AsRef<OsStr>,
712    {
713        self.cmd()
714            .args(["submodule", "status"])
715            .args(paths)
716            .get_stdout_lossy()
717            .map(|stdout| stdout.trim().lines().next().is_some())
718    }
719
720    pub fn submodule_add(
721        self,
722        force: bool,
723        url: impl AsRef<OsStr>,
724        path: impl AsRef<OsStr>,
725    ) -> Result<()> {
726        self.cmd()
727            .arg("--literal-pathspecs")
728            .stderr(self.stderr())
729            .args(["submodule", "add"])
730            .args(self.shallow.then_some("--depth=1"))
731            .args(force.then_some("--force"))
732            .arg(url)
733            .arg(path)
734            .exec()
735            .map(drop)
736    }
737
738    pub fn submodule_update<I, S>(
739        self,
740        force: bool,
741        remote: bool,
742        no_fetch: bool,
743        recursive: bool,
744        paths: I,
745    ) -> Result<()>
746    where
747        I: IntoIterator<Item = S>,
748        S: AsRef<OsStr>,
749    {
750        self.cmd()
751            .stderr(self.stderr())
752            .args(["submodule", "update", "--progress", "--init"])
753            .args(self.shallow.then_some("--depth=1"))
754            .args(force.then_some("--force"))
755            .args(remote.then_some("--remote"))
756            .args(no_fetch.then_some("--no-fetch"))
757            .args(recursive.then_some("--recursive"))
758            .args(paths)
759            .exec()
760            .map(drop)
761    }
762
763    pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
764        self.cmd()
765            .stderr(self.stderr())
766            .args(["submodule", "foreach"])
767            .args(recursive.then_some("--recursive"))
768            .arg(cmd)
769            .exec()
770            .map(drop)
771    }
772
773    /// If the status is prefix with `-`, the submodule is not initialized.
774    ///
775    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
776    pub fn submodules_uninitialized(self) -> Result<bool> {
777        // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the
778        // "submodule status has '-' prefix" logic.
779        self.has_missing_dependencies(std::iter::empty::<&OsStr>())
780    }
781
782    /// Initializes the git submodules.
783    pub fn submodule_init(self) -> Result<()> {
784        self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
785    }
786
787    pub fn submodules(&self) -> Result<Submodules> {
788        self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
789    }
790
791    pub fn submodule_sync(self) -> Result<()> {
792        self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
793    }
794
795    /// Get the URL of a submodule from git config
796    pub fn submodule_url(self, path: &Path) -> Result<Option<String>> {
797        self.cmd()
798            .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())])
799            .get_stdout_lossy()
800            .map(|url| Some(url.trim().to_string()))
801    }
802
803    /// Returns whether `.gitmodules` contains the default section name or an exact path mapping.
804    pub fn has_submodule_mapping(self, path: &Path) -> Result<bool> {
805        let gitmodules = self.root.join(".gitmodules");
806        if !gitmodules.exists() {
807            return Ok(false);
808        }
809
810        let output = self
811            .cmd()
812            .args(["config", "--null", "--file"])
813            .arg(gitmodules)
814            .args(["--get-regexp", r"^submodule\..*"])
815            .output()?;
816        match output.status.code() {
817            Some(0) => {
818                let expected_path = path.to_slash_lossy();
819                for entry in
820                    output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty())
821                {
822                    let Some(separator) = entry.iter().position(|byte| *byte == b'\n') else {
823                        return Err(eyre::eyre!("invalid submodule mapping entry"));
824                    };
825                    let key = std::str::from_utf8(&entry[..separator])?;
826                    let value = std::str::from_utf8(&entry[separator + 1..])?;
827                    let Some(key) = key.strip_prefix("submodule.") else { continue };
828                    let Some((name, field)) = key.rsplit_once('.') else { continue };
829                    if name == expected_path || field == "path" && value == expected_path {
830                        return Ok(true);
831                    }
832                }
833                Ok(false)
834            }
835            Some(1) => Ok(false),
836            _ => Err(eyre::eyre!(
837                "failed to inspect .gitmodules: {}",
838                String::from_utf8_lossy(&output.stderr).trim()
839            )),
840        }
841    }
842
843    /// Returns whether the index contains a submodule at the given path.
844    pub fn is_gitlink(self, path: &Path) -> Result<bool> {
845        self.cmd().args(["ls-files", "--stage", "-z", "--"]).arg(path).exec().map(|output| {
846            let expected_path = path.to_slash_lossy();
847            output.stdout.split(|byte| *byte == 0).any(|entry| {
848                entry.starts_with(b"160000 ")
849                    && entry
850                        .iter()
851                        .position(|byte| *byte == b'\t')
852                        .and_then(|separator| entry.get(separator + 1..))
853                        == Some(expected_path.as_bytes())
854            })
855        })
856    }
857
858    /// Returns whether the index contains any entry at or below the given path.
859    pub fn has_index_entries(self, path: &Path) -> Result<bool> {
860        self.cmd()
861            .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
862            .arg(path)
863            .exec()
864            .map(|output| !output.stdout.is_empty())
865    }
866
867    /// Returns whether a regular stage-0 index entry has no flags and matches the worktree.
868    pub fn is_normal_tracked_file(self, path: &Path) -> Result<bool> {
869        let output = self
870            .cmd()
871            .args(["--literal-pathspecs", "ls-files", "--stage", "-v", "-z", "--"])
872            .arg(path)
873            .exec()?;
874        let mut entries = output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty());
875        let Some(entry) = entries.next() else { return Ok(false) };
876        if entries.next().is_some() {
877            return Ok(false);
878        }
879        let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
880            return Err(eyre::eyre!("invalid index entry"));
881        };
882        if entry.get(separator + 1..) != Some(path.to_slash_lossy().as_bytes()) {
883            return Ok(false);
884        }
885        let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
886        if fields.next() != Some("H") || !matches!(fields.next(), Some("100644" | "100755")) {
887            return Ok(false);
888        }
889        let Some(index_hash) = fields.next() else { return Ok(false) };
890        if fields.next() != Some("0") || fields.next().is_some() {
891            return Ok(false);
892        }
893        let worktree_hash = self.cmd().args(["hash-object", "--"]).arg(path).get_stdout_lossy()?;
894        Ok(worktree_hash.trim() == index_hash)
895    }
896
897    /// Returns whether local config contains values for the given submodule.
898    pub fn has_submodule_config(self, path: &Path) -> Result<bool> {
899        let pattern = format!(r"^submodule\.{}\.", regex::escape(&path.to_slash_lossy()));
900        let output = self.cmd().args(["config", "--local", "--get-regexp", &pattern]).output()?;
901        match output.status.code() {
902            Some(0) => Ok(true),
903            Some(1) => Ok(false),
904            _ => Err(eyre::eyre!(
905                "failed to inspect submodule config: {}",
906                String::from_utf8_lossy(&output.stderr).trim()
907            )),
908        }
909    }
910
911    /// Removes all local config values for the given submodule.
912    pub fn remove_submodule_config(self, path: &Path) -> Result<()> {
913        if self.has_submodule_config(path)? {
914            let section = format!("submodule.{}", path.to_slash_lossy());
915            self.cmd().args(["config", "--local", "--remove-section", &section]).exec()?;
916        }
917        Ok(())
918    }
919
920    /// Returns the absolute path to the repository's Git directory.
921    pub fn absolute_git_dir(self) -> Result<PathBuf> {
922        self.cmd().args(["rev-parse", "--absolute-git-dir"]).get_stdout_lossy().map(PathBuf::from)
923    }
924
925    /// Returns the fetch URL of the given remote, or `None` if it doesn't exist.
926    pub fn remote_url(self, name: &str) -> Option<String> {
927        self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
928    }
929
930    /// Sets the branch for a submodule.
931    pub fn set_submodule_branch(self, rel_path: &Path, branch: &str) -> Result<()> {
932        self.cmd().args(["submodule", "set-branch", "-b", branch]).arg(rel_path).exec().map(drop)
933    }
934
935    /// Returns remote branch names as a newline-separated string.
936    pub fn remote_branches(self) -> Result<String> {
937        self.cmd().args(["branch", "-r"]).get_stdout_lossy()
938    }
939
940    /// Fetches a branch from origin and checks out a local tracking branch at the given path.
941    pub fn fetch_and_checkout_branch(self, at: &Path, branch: &str) -> Result<()> {
942        self.cmd_at(at).args(["fetch", "origin", branch]).exec().map_err(|e| {
943            eyre::eyre!(
944                "Could not fetch latest changes for branch {branch} in submodule at {}: {e}",
945                at.display()
946            )
947        })?;
948        self.cmd_at(at)
949            .args(["checkout", "-B", branch, &format!("origin/{branch}")])
950            .exec()
951            .map_err(|e| {
952                eyre::eyre!(
953                    "Could not checkout and track origin/{branch} for submodule at {}: {e}",
954                    at.display()
955                )
956            })?;
957        Ok(())
958    }
959
960    fn cmd(self) -> Command {
961        let mut cmd = Self::cmd_no_root();
962        cmd.current_dir(self.root);
963        cmd
964    }
965
966    fn cmd_at(self, path: &Path) -> Command {
967        let mut cmd = Self::cmd_no_root();
968        cmd.current_dir(path);
969        cmd
970    }
971
972    fn cmd_no_root() -> Command {
973        let mut cmd = Command::new("git");
974        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
975        cmd
976    }
977
978    // don't set this in cmd() because it's not wanted for all commands
979    fn stderr(self) -> Stdio {
980        if self.quiet { Stdio::piped() } else { Stdio::inherit() }
981    }
982}
983
984/// Deserialized `git submodule status lib/dep` output.
985#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
986pub struct Submodule {
987    /// Current commit hash the submodule is checked out at.
988    rev: String,
989    /// Relative path to the submodule.
990    path: PathBuf,
991}
992
993impl Submodule {
994    pub const fn new(rev: String, path: PathBuf) -> Self {
995        Self { rev, path }
996    }
997
998    pub fn rev(&self) -> &str {
999        &self.rev
1000    }
1001
1002    pub const fn path(&self) -> &PathBuf {
1003        &self.path
1004    }
1005}
1006
1007impl FromStr for Submodule {
1008    type Err = eyre::Report;
1009
1010    fn from_str(s: &str) -> Result<Self> {
1011        let caps = SUBMODULE_STATUS_REGEX
1012            .captures(s)
1013            .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
1014
1015        Ok(Self {
1016            rev: caps.get(1).unwrap().as_str().to_string(),
1017            path: PathBuf::from(caps.get(2).unwrap().as_str()),
1018        })
1019    }
1020}
1021
1022/// Deserialized `git submodule status` output.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct Submodules(pub Vec<Submodule>);
1025
1026impl Submodules {
1027    pub const fn len(&self) -> usize {
1028        self.0.len()
1029    }
1030
1031    pub const fn is_empty(&self) -> bool {
1032        self.0.is_empty()
1033    }
1034}
1035
1036impl FromStr for Submodules {
1037    type Err = eyre::Report;
1038
1039    fn from_str(s: &str) -> Result<Self> {
1040        let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
1041        Ok(Self(subs))
1042    }
1043}
1044
1045impl<'a> IntoIterator for &'a Submodules {
1046    type Item = &'a Submodule;
1047    type IntoIter = std::slice::Iter<'a, Submodule>;
1048
1049    fn into_iter(self) -> Self::IntoIter {
1050        self.0.iter()
1051    }
1052}
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use foundry_common::fs;
1057    use std::{env, fs::File, io::Write};
1058    use tempfile::tempdir;
1059
1060    #[test]
1061    fn parse_submodule_status() {
1062        let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
1063        let sub = Submodule::from_str(s).unwrap();
1064        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1065        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1066
1067        let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1068        let sub = Submodule::from_str(s).unwrap();
1069        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1070        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1071
1072        let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1073        let sub = Submodule::from_str(s).unwrap();
1074        assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1075        assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1076    }
1077
1078    #[test]
1079    fn parse_multiline_submodule_status() {
1080        let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
1081+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
1082"#;
1083        let subs = Submodules::from_str(s).unwrap().0;
1084        assert_eq!(subs.len(), 2);
1085        assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
1086        assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
1087        assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1088        assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
1089    }
1090
1091    #[test]
1092    fn skips_submodule_status_if_dependencies_are_initialized() {
1093        let tmp = tempdir().unwrap();
1094        let root = tmp.path();
1095        std::fs::create_dir(root.join(".git")).unwrap();
1096        std::fs::write(
1097            root.join(".gitmodules"),
1098            r#"[submodule "lib/forge-std"]
1099	path = lib/forge-std
1100	url = https://github.com/foundry-rs/forge-std
1101"#,
1102        )
1103        .unwrap();
1104        std::fs::create_dir_all(root.join("lib/forge-std/.git")).unwrap();
1105
1106        let git = Git::new(root);
1107        assert!(git.submodules_initialized(&["lib".into()]).unwrap());
1108        assert!(git.submodules_initialized(&[root.join("lib").into()]).unwrap());
1109
1110        let nested = root.join("packages/contracts");
1111        std::fs::create_dir_all(&nested).unwrap();
1112        assert!(!Git::new(&nested).submodules_initialized(&["../../lib".into()]).unwrap());
1113
1114        // The fast path succeeds even though this is not a real Git repository.
1115        assert!(!git.has_missing_dependencies(["lib"]).unwrap());
1116
1117        std::fs::remove_dir(root.join("lib/forge-std/.git")).unwrap();
1118        assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1119    }
1120
1121    #[test]
1122    fn foundry_path_ext_works() {
1123        let p = Path::new("contracts/MyTest.t.sol");
1124        assert!(p.is_sol_test());
1125        assert!(p.is_sol());
1126        let p = Path::new("contracts/Greeter.sol");
1127        assert!(!p.is_sol_test());
1128    }
1129
1130    #[test]
1131    fn parse_ether_value_accepts_hex_prefixed_wei() {
1132        assert_eq!(parse_ether_value("0x10").unwrap(), U256::from(16));
1133        assert_eq!(parse_ether_value("0X10").unwrap(), U256::from(16));
1134        assert_eq!(parse_ether_value("0x12").unwrap(), U256::from(0x12));
1135        assert_eq!(parse_ether_value("0xff").unwrap(), U256::from(0xff));
1136        assert_eq!(parse_ether_value("100").unwrap(), U256::from(100));
1137        assert_eq!(parse_ether_value("1ether").unwrap(), U256::from(1000000000000000000u128));
1138    }
1139
1140    // loads .env from cwd and project dir, See [`find_project_root()`]
1141    #[test]
1142    fn can_load_dotenv() {
1143        let temp = tempdir().unwrap();
1144        Git::new(temp.path()).init().unwrap();
1145        let cwd_env = temp.path().join(".env");
1146        fs::create_file(temp.path().join("foundry.toml")).unwrap();
1147        let nested = temp.path().join("nested");
1148        fs::create_dir(&nested).unwrap();
1149
1150        let mut cwd_file = File::create(cwd_env).unwrap();
1151        let mut prj_file = File::create(nested.join(".env")).unwrap();
1152
1153        cwd_file.write_all(b"TESTCWDKEY=cwd_val").unwrap();
1154        cwd_file.sync_all().unwrap();
1155
1156        prj_file.write_all(b"TESTPRJKEY=prj_val").unwrap();
1157        prj_file.sync_all().unwrap();
1158
1159        let cwd = env::current_dir().unwrap();
1160        env::set_current_dir(nested).unwrap();
1161        load_dotenv();
1162        env::set_current_dir(cwd).unwrap();
1163
1164        assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
1165        assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
1166    }
1167
1168    #[test]
1169    fn test_read_gitmodules_regex() {
1170        let gitmodules = r#"
1171        [submodule "lib/solady"]
1172        path = lib/solady
1173        url = ""
1174        branch = v0.1.0
1175        [submodule "lib/openzeppelin-contracts"]
1176        path = lib/openzeppelin-contracts
1177        url = ""
1178        branch = v4.8.0-791-g8829465a
1179        [submodule "lib/forge-std"]
1180        path = lib/forge-std
1181        url = ""
1182"#;
1183
1184        let paths = SUBMODULE_BRANCH_REGEX
1185            .captures_iter(gitmodules)
1186            .map(|cap| {
1187                (
1188                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1189                    String::from(cap.get(2).unwrap().as_str()),
1190                )
1191            })
1192            .collect::<HashMap<_, _>>();
1193
1194        assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
1195        assert_eq!(
1196            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1197            "v4.8.0-791-g8829465a"
1198        );
1199
1200        let no_branch_gitmodules = r#"
1201        [submodule "lib/solady"]
1202        path = lib/solady
1203        url = ""
1204        [submodule "lib/openzeppelin-contracts"]
1205        path = lib/openzeppelin-contracts
1206        url = ""
1207        [submodule "lib/forge-std"]
1208        path = lib/forge-std
1209        url = ""
1210"#;
1211        let paths = SUBMODULE_BRANCH_REGEX
1212            .captures_iter(no_branch_gitmodules)
1213            .map(|cap| {
1214                (
1215                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1216                    String::from(cap.get(2).unwrap().as_str()),
1217                )
1218            })
1219            .collect::<HashMap<_, _>>();
1220
1221        assert!(paths.is_empty());
1222
1223        let branch_in_between = r#"
1224        [submodule "lib/solady"]
1225        path = lib/solady
1226        url = ""
1227        [submodule "lib/openzeppelin-contracts"]
1228        path = lib/openzeppelin-contracts
1229        url = ""
1230        branch = v4.8.0-791-g8829465a
1231        [submodule "lib/forge-std"]
1232        path = lib/forge-std
1233        url = ""
1234        "#;
1235
1236        let paths = SUBMODULE_BRANCH_REGEX
1237            .captures_iter(branch_in_between)
1238            .map(|cap| {
1239                (
1240                    PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1241                    String::from(cap.get(2).unwrap().as_str()),
1242                )
1243            })
1244            .collect::<HashMap<_, _>>();
1245
1246        assert_eq!(paths.len(), 1);
1247        assert_eq!(
1248            paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1249            "v4.8.0-791-g8829465a"
1250        );
1251    }
1252}