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 collections::{BTreeMap, BTreeSet},
13 ffi::{OsStr, OsString},
14 path::{Path, PathBuf},
15 process::{Command, Output, Stdio},
16 str::FromStr,
17 sync::{LazyLock, OnceLock},
18 time::{Duration, SystemTime, UNIX_EPOCH},
19};
20use tracing_subscriber::{EnvFilter, prelude::*, reload};
21
22mod cmd;
23pub use cmd::*;
24
25mod suggestions;
26pub use suggestions::*;
27
28mod abi;
29pub use abi::*;
30
31mod allocator;
32pub use allocator::*;
33
34mod tempo;
35pub use tempo::*;
36
37#[doc(hidden)]
39pub use foundry_config::utils::*;
40
41pub const STATIC_FUZZ_SEED: [u8; 32] = [
45 0x01, 0x00, 0xfa, 0x69, 0xa5, 0xf1, 0x71, 0x0a, 0x95, 0xcd, 0xef, 0x94, 0x88, 0x9b, 0x02, 0x84,
46 0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6,
47];
48
49pub fn apply_gas_estimate_multiplier(estimate: u64, multiplier: Option<u64>) -> Result<u64> {
51 let Some(multiplier) = multiplier else { return Ok(estimate) };
52 let adjusted = u128::from(estimate) * u128::from(multiplier) / 100;
53 adjusted.try_into().map_err(|_| eyre::eyre!("multiplied gas estimate exceeds u64"))
54}
55
56pub static SUBMODULE_BRANCH_REGEX: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap());
59pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
61 LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
62
63static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
65 OnceLock::new();
66
67pub trait FoundryPathExt {
69 fn is_sol_test(&self) -> bool;
71
72 fn is_sol(&self) -> bool;
74
75 fn is_yul(&self) -> bool;
77}
78
79impl<T: AsRef<Path>> FoundryPathExt for T {
80 fn is_sol_test(&self) -> bool {
81 self.as_ref()
82 .file_name()
83 .and_then(|s| s.to_str())
84 .map(|s| s.ends_with(".t.sol"))
85 .unwrap_or_default()
86 }
87
88 fn is_sol(&self) -> bool {
89 self.as_ref().extension() == Some(std::ffi::OsStr::new("sol"))
90 }
91
92 fn is_yul(&self) -> bool {
93 self.as_ref().extension() == Some(std::ffi::OsStr::new("yul"))
94 }
95}
96
97pub fn subscriber() {
102 let (filter_layer, reload_handle) = reload::Layer::new(env_filter());
103 let registry = tracing_subscriber::Registry::default().with(filter_layer);
104 #[cfg(feature = "tracy")]
105 let registry = registry.with(tracing_tracy::TracyLayer::default());
106 registry.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)).init();
107 let _ = FILTER_RELOAD_HANDLE.set(reload_handle);
108}
109
110pub fn update_tracing_filter(directives: &str) {
115 let Some(handle) = FILTER_RELOAD_HANDLE.get() else {
116 return;
117 };
118 let Ok(new_filter) = directives.parse::<EnvFilter>() else {
119 return;
120 };
121 let _ = handle.reload(new_filter);
122}
123
124fn env_filter() -> EnvFilter {
125 const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt");
126 let mut filter = EnvFilter::from_default_env();
127 for &directive in DEFAULT_DIRECTIVES {
128 filter = filter.add_directive(directive.parse().unwrap());
129 }
130 filter
131}
132
133pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
135 get_provider_builder(config)?.build()
136}
137
138pub fn get_provider_builder(config: &Config) -> Result<ProviderBuilder> {
142 ProviderBuilder::from_config(config)
143}
144
145pub async fn get_chain<N, P>(chain: Option<Chain>, provider: P) -> Result<Chain>
146where
147 N: Network,
148 P: Provider<N>,
149{
150 match chain {
151 Some(chain) => Ok(chain),
152 None => Ok(Chain::from_id(provider.get_chain_id().await?)),
153 }
154}
155
156pub fn parse_ether_value(value: &str) -> Result<U256> {
163 Ok(if value.starts_with("0x") || value.starts_with("0X") {
164 U256::from_str(value)?
165 } else {
166 alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)?
167 .as_uint()
168 .wrap_err("Could not parse ether value from string")?
169 .0
170 })
171}
172
173pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
175 serde_json::from_str(value)
176}
177
178pub fn parse_delay(delay: &str) -> Result<Duration> {
180 let delay = if delay.ends_with("ms") {
181 let d: u64 = delay.trim_end_matches("ms").parse()?;
182 Duration::from_millis(d)
183 } else {
184 let d: f64 = delay.parse()?;
185 let delay = (d * 1000.0).round();
186 if delay.is_infinite() || delay.is_nan() || delay.is_sign_negative() {
187 eyre::bail!("delay must be finite and non-negative");
188 }
189
190 Duration::from_millis(delay as u64)
191 };
192 Ok(delay)
193}
194
195pub fn now() -> Duration {
197 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
198}
199
200pub fn common_setup() {
202 install_crypto_provider();
203 crate::handler::install();
204 load_dotenv();
205 enable_paint();
206}
207
208pub fn load_dotenv() {
210 let mut paths = Vec::new();
214 if let (Ok(cwd), Ok(project_root)) = (std::env::current_dir(), find_project_root(None)) {
215 let project_env = project_root.join(".env");
216 if project_env.is_file() {
217 paths.push(project_env);
218 }
219 if cwd != project_root {
220 let cwd_env = cwd.join(".env");
222 if cwd_env.is_file() {
223 paths.push(cwd_env);
224 }
225 }
226 }
227
228 for path in paths {
229 dotenvy::from_path(path).ok();
230 }
231}
232
233pub fn enable_paint() {
235 let enable = yansi::Condition::os_support() && yansi::Condition::tty_and_color_live();
236 yansi::whenever(yansi::Condition::cached(enable));
237}
238
239pub fn install_crypto_provider() {
250 rustls::crypto::ring::default_provider()
252 .install_default()
253 .expect("Failed to install default rustls crypto provider");
254}
255
256pub async fn fetch_abi_from_etherscan(
258 address: Address,
259 config: &foundry_config::Config,
260) -> Result<Vec<(JsonAbi, String)>> {
261 let chain = config.chain.unwrap_or_default();
262 let client = config
263 .get_etherscan_config_with_chain(Some(chain))?
264 .ok_or_else(|| eyre::eyre!("No Etherscan API key configured for chain {chain}"))?
265 .into_client_with_no_proxy(config.eth_rpc_no_proxy)?;
266 let source = client.contract_source_code(address).await?;
267 source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect()
268}
269
270pub trait CommandUtils {
272 fn exec(&mut self) -> Result<Output>;
274
275 fn get_stdout_lossy(&mut self) -> Result<String>;
277}
278
279impl CommandUtils for Command {
280 #[track_caller]
281 fn exec(&mut self) -> Result<Output> {
282 trace!(command=?self, "executing");
283
284 let output = self.output()?;
285
286 trace!(code=?output.status.code(), ?output);
287
288 if output.status.success() {
289 Ok(output)
290 } else {
291 let stdout = String::from_utf8_lossy(&output.stdout);
292 let stdout = stdout.trim();
293 let stderr = String::from_utf8_lossy(&output.stderr);
294 let stderr = stderr.trim();
295 let msg = if stdout.is_empty() {
296 stderr.to_string()
297 } else if stderr.is_empty() {
298 stdout.to_string()
299 } else {
300 format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
301 };
302
303 let mut name = self.get_program().to_string_lossy();
304 if let Some(arg) = self.get_args().next() {
305 let arg = arg.to_string_lossy();
306 if !arg.starts_with('-') {
307 let name = name.to_mut();
308 name.push(' ');
309 name.push_str(&arg);
310 }
311 }
312
313 let mut err = match output.status.code() {
314 Some(code) => format!("{name} exited with code {code}"),
315 None => format!("{name} terminated by a signal"),
316 };
317 if !msg.is_empty() {
318 err.push(':');
319 err.push(if msg.lines().count() == 1 { ' ' } else { '\n' });
320 err.push_str(&msg);
321 }
322 Err(eyre::eyre!(err))
323 }
324 }
325
326 #[track_caller]
327 fn get_stdout_lossy(&mut self) -> Result<String> {
328 let output = self.exec()?;
329 let stdout = String::from_utf8_lossy(&output.stdout);
330 Ok(stdout.trim().into())
331 }
332}
333
334#[derive(Clone, Copy, Debug)]
335pub struct Git<'a> {
336 pub root: &'a Path,
337 pub quiet: bool,
338 pub shallow: bool,
339}
340
341impl<'a> Git<'a> {
342 pub fn new(root: &'a Path) -> Self {
343 Self { root, quiet: shell::is_quiet(), shallow: false }
344 }
345
346 pub fn from_config(config: &'a Config) -> Self {
347 Self::new(config.root.as_path())
348 }
349
350 pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
351 let output = Self::cmd_no_root()
352 .current_dir(relative_to)
353 .args(["rev-parse", "--show-toplevel"])
354 .get_stdout_lossy()?;
355 Ok(PathBuf::from(output))
356 }
357
358 pub fn clone_with_branch(
359 shallow: bool,
360 from: impl AsRef<OsStr>,
361 branch: impl AsRef<OsStr>,
362 to: Option<impl AsRef<OsStr>>,
363 ) -> Result<()> {
364 Self::cmd_no_root()
365 .stderr(Stdio::inherit())
366 .args(["clone", "--recurse-submodules"])
367 .args(shallow.then_some("--depth=1"))
368 .args(shallow.then_some("--shallow-submodules"))
369 .arg("-b")
370 .arg(branch)
371 .arg(from)
372 .args(to)
373 .exec()
374 .map(drop)
375 }
376
377 pub fn clone(
378 shallow: bool,
379 from: impl AsRef<OsStr>,
380 to: Option<impl AsRef<OsStr>>,
381 ) -> Result<()> {
382 Self::cmd_no_root()
383 .stderr(Stdio::inherit())
384 .args(["clone", "--recurse-submodules"])
385 .args(shallow.then_some("--depth=1"))
386 .args(shallow.then_some("--shallow-submodules"))
387 .arg(from)
388 .args(to)
389 .exec()
390 .map(drop)
391 }
392
393 pub fn fetch(
394 self,
395 shallow: bool,
396 remote: impl AsRef<OsStr>,
397 branch: Option<impl AsRef<OsStr>>,
398 ) -> Result<()> {
399 self.cmd()
400 .stderr(Stdio::inherit())
401 .arg("fetch")
402 .args(shallow.then_some("--no-tags"))
403 .args(shallow.then_some("--depth=1"))
404 .arg(remote)
405 .args(branch)
406 .exec()
407 .map(drop)
408 }
409
410 pub const fn root(self, root: &Path) -> Git<'_> {
411 Git { root, ..self }
412 }
413
414 pub const fn quiet(self, quiet: bool) -> Self {
415 Self { quiet, ..self }
416 }
417
418 pub const fn shallow(self, shallow: bool) -> Self {
420 Self { shallow, ..self }
421 }
422
423 pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
424 self.cmd()
425 .arg("checkout")
426 .args(recursive.then_some("--recurse-submodules"))
427 .arg(tag)
428 .exec()
429 .map(drop)
430 }
431
432 pub fn head(self) -> Result<String> {
434 self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
435 }
436
437 pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
438 self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop)
439 }
440
441 pub fn init(self) -> Result<()> {
442 self.cmd().arg("init").exec().map(drop)
443 }
444
445 pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
446 let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
447 let branch =
448 self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
449 Ok((rev, branch))
450 }
451
452 #[expect(clippy::should_implement_trait)] pub fn add<I, S>(self, paths: I) -> Result<()>
454 where
455 I: IntoIterator<Item = S>,
456 S: AsRef<OsStr>,
457 {
458 self.cmd().arg("add").args(paths).exec().map(drop)
459 }
460
461 pub fn add_literal(self, path: &Path) -> Result<()> {
462 self.cmd().args(["--literal-pathspecs", "add", "--"]).arg(path).exec().map(drop)
463 }
464
465 pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
466 self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
467 }
468
469 pub fn commit_tree(
470 self,
471 tree: impl AsRef<OsStr>,
472 msg: Option<impl AsRef<OsStr>>,
473 ) -> Result<String> {
474 self.cmd()
475 .arg("commit-tree")
476 .arg(tree)
477 .args(msg.as_ref().is_some().then_some("-m"))
478 .args(msg)
479 .get_stdout_lossy()
480 }
481
482 pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
483 where
484 I: IntoIterator<Item = S>,
485 S: AsRef<OsStr>,
486 {
487 self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
488 }
489
490 pub fn remove_index_path(self, path: &Path) -> Result<()> {
491 self.cmd()
492 .args(["--literal-pathspecs", "rm", "--cached", "--force", "--"])
493 .arg(path)
494 .exec()
495 .map(drop)
496 }
497
498 pub fn commit(self, msg: &str) -> Result<()> {
499 let output = self
500 .cmd()
501 .args(["commit", "-m", msg])
502 .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
503 .output()?;
504 if !output.status.success() {
505 let stdout = String::from_utf8_lossy(&output.stdout);
506 let stderr = String::from_utf8_lossy(&output.stderr);
507 let msg = "nothing to commit, working tree clean";
509 if !(stdout.contains(msg) || stderr.contains(msg)) {
510 return Err(eyre::eyre!(
511 "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
512 output.status.code(),
513 stdout.trim(),
514 stderr.trim()
515 ));
516 }
517 }
518 Ok(())
519 }
520
521 pub fn is_in_repo(self) -> std::io::Result<bool> {
522 self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
523 }
524
525 pub fn is_repo_root(self) -> Result<bool> {
526 self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty())
527 }
528
529 pub fn is_clean(self) -> Result<bool> {
530 self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
531 }
532
533 pub fn is_path_clean(self, path: &Path) -> Result<bool> {
534 self.cmd()
535 .args(["--literal-pathspecs", "status", "--porcelain", "--"])
536 .arg(path)
537 .exec()
538 .map(|out| out.stdout.is_empty())
539 }
540
541 pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
542 self.cmd_at(at)
543 .args(["branch", "--list", "--no-color"])
544 .arg(branch)
545 .get_stdout_lossy()
546 .map(|stdout| !stdout.is_empty())
547 }
548
549 pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
550 self.cmd_at(at)
551 .args(["tag", "--list"])
552 .arg(tag)
553 .get_stdout_lossy()
554 .map(|stdout| !stdout.is_empty())
555 }
556
557 pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
558 self.cmd_at(at)
559 .args(["cat-file", "-t"])
560 .arg(rev)
561 .get_stdout_lossy()
562 .map(|stdout| &stdout == "commit")
563 }
564
565 pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
566 self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
567 }
568
569 pub fn ensure_clean(self) -> Result<()> {
570 if self.is_clean()? {
571 Ok(())
572 } else {
573 Err(eyre::eyre!(
574 "\
575The target directory is a part of or on its own an already initialized git repository,
576and it requires clean working and staging areas, including no untracked files.
577
578Check the current git repository's status with `git status`.
579Then, you can track files with `git add ...` and then commit them with `git commit`,
580ignore them in the `.gitignore` file."
581 ))
582 }
583 }
584
585 pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
586 self.cmd()
587 .arg("rev-parse")
588 .args(short.then_some("--short"))
589 .arg(revision)
590 .get_stdout_lossy()
591 }
592
593 pub fn tag(self) -> Result<String> {
594 self.cmd().arg("tag").get_stdout_lossy()
595 }
596
597 pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
605 self.cmd_at(at)
606 .args(["tag", "--contains"])
607 .arg(rev)
608 .get_stdout_lossy()
609 .map(|stdout| stdout.lines().next().map(str::to_string))
610 }
611
612 pub fn read_submodules_with_branch(
620 self,
621 at: &Path,
622 lib: &OsStr,
623 ) -> Result<HashMap<PathBuf, String>> {
624 let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
626
627 let paths = SUBMODULE_BRANCH_REGEX
628 .captures_iter(&gitmodules)
629 .map(|cap| {
630 let path_str = cap.get(1).unwrap().as_str();
631 let path = PathBuf::from_str(path_str).unwrap();
632 trace!(path = %path.display(), "unstripped path");
633
634 let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
641 let path = path
642 .components()
643 .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
644 .collect::<PathBuf>();
645
646 let branch = cap.get(2).unwrap().as_str().to_string();
647 (path, branch)
648 })
649 .collect::<HashMap<_, _>>();
650
651 Ok(paths)
652 }
653
654 pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
655 where
656 I: IntoIterator<Item = S>,
657 S: AsRef<OsStr>,
658 {
659 let paths = paths.into_iter().map(|path| path.as_ref().to_owned()).collect::<Vec<_>>();
660 if self.submodules_initialized(&paths).unwrap_or(false) {
661 return Ok(false);
662 }
663
664 self.cmd()
665 .args(["submodule", "status"])
666 .args(&paths)
667 .get_stdout_lossy()
668 .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
669 }
670
671 fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
673 let Some(root) = self.root.ancestors().find(|root| root.join(".git").exists()) else {
674 return Ok(false);
675 };
676 if paths.iter().any(|path| {
677 Path::new(path)
678 .components()
679 .any(|component| matches!(component, std::path::Component::ParentDir))
680 }) {
681 return Ok(false);
682 }
683 let relative_root = self.root.strip_prefix(root).unwrap_or_else(|_| Path::new(""));
684 let gitmodules = root.join(".gitmodules");
685 if !gitmodules.is_file() {
686 return Ok(false);
687 }
688
689 let output = Command::new("git")
690 .args(["config", "--null", "--file"])
691 .arg(gitmodules)
692 .args(["--get-regexp", r"^submodule\..*\.path$"])
693 .get_stdout_lossy()?;
694
695 for entry in output.split_terminator('\0') {
696 let (_, path) = entry
697 .split_once('\n')
698 .ok_or_else(|| eyre::eyre!("invalid submodule path config"))?;
699 let path = Path::new(path);
700 let matches = paths.is_empty()
701 || paths.iter().any(|prefix| {
702 let prefix = Path::new(prefix);
703 if prefix.is_absolute() {
704 prefix.strip_prefix(root).is_ok_and(|prefix| path.starts_with(prefix))
705 } else {
706 path.starts_with(relative_root.join(prefix))
707 }
708 });
709 if matches && !root.join(path).join(".git").exists() {
710 return Ok(false);
711 }
712 }
713 Ok(true)
714 }
715
716 pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
718 where
719 I: IntoIterator<Item = S>,
720 S: AsRef<OsStr>,
721 {
722 self.cmd()
723 .args(["submodule", "status"])
724 .args(paths)
725 .get_stdout_lossy()
726 .map(|stdout| stdout.trim().lines().next().is_some())
727 }
728
729 pub fn submodule_add(
730 self,
731 force: bool,
732 url: impl AsRef<OsStr>,
733 path: impl AsRef<OsStr>,
734 ) -> Result<()> {
735 self.cmd()
736 .arg("--literal-pathspecs")
737 .stderr(self.stderr())
738 .args(["submodule", "add"])
739 .args(self.shallow.then_some("--depth=1"))
740 .args(force.then_some("--force"))
741 .arg(url)
742 .arg(path)
743 .exec()
744 .map(drop)
745 }
746
747 pub fn submodule_update<I, S>(
748 self,
749 force: bool,
750 remote: bool,
751 no_fetch: bool,
752 recursive: bool,
753 paths: I,
754 ) -> Result<()>
755 where
756 I: IntoIterator<Item = S>,
757 S: AsRef<OsStr>,
758 {
759 self.cmd()
760 .stderr(self.stderr())
761 .args(["submodule", "update", "--progress", "--init"])
762 .args(self.shallow.then_some("--depth=1"))
763 .args(force.then_some("--force"))
764 .args(remote.then_some("--remote"))
765 .args(no_fetch.then_some("--no-fetch"))
766 .args(recursive.then_some("--recursive"))
767 .args(paths)
768 .exec()
769 .map(drop)
770 }
771
772 pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
773 self.cmd()
774 .stderr(self.stderr())
775 .args(["submodule", "foreach"])
776 .args(recursive.then_some("--recursive"))
777 .arg(cmd)
778 .exec()
779 .map(drop)
780 }
781
782 pub fn submodules_uninitialized(self) -> Result<bool> {
786 self.has_missing_dependencies(std::iter::empty::<&OsStr>())
789 }
790
791 pub fn submodule_init(self) -> Result<()> {
793 self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
794 }
795
796 pub fn submodules(&self) -> Result<Submodules> {
797 self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
798 }
799
800 pub fn submodules_in(&self, path: &Path) -> Result<Vec<SubmoduleCheckout>> {
802 self.submodules_in_worktree(path, self.root, Path::new(""))
803 }
804
805 pub fn submodules_in_worktree(
808 &self,
809 path: &Path,
810 worktree_root: &Path,
811 worktree_prefix: &Path,
812 ) -> Result<Vec<SubmoduleCheckout>> {
813 let pathspec = if path.as_os_str().is_empty() { Path::new(".") } else { path };
814 let output = self
815 .cmd()
816 .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
817 .arg(pathspec)
818 .exec()?;
819 let (_, mappings) = self.submodule_mappings_at(worktree_root)?;
820 let mut gitlinks = BTreeMap::new();
821 for entry in output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) {
822 let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
823 return Err(eyre::eyre!("invalid index entry"));
824 };
825 let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
826 let Some(mode) = fields.next() else {
827 return Err(eyre::eyre!("invalid index entry"));
828 };
829 let Some(rev) = fields.next() else {
830 return Err(eyre::eyre!("invalid index entry"));
831 };
832 let Some(stage) = fields.next() else {
833 return Err(eyre::eyre!("invalid index entry"));
834 };
835 if fields.next().is_some() {
836 return Err(eyre::eyre!("invalid index entry"));
837 }
838 if mode != "160000" {
839 continue;
840 }
841
842 let submodule_path = PathBuf::from(std::str::from_utf8(&entry[separator + 1..])?);
843 let worktree_path =
844 foundry_common::fs::normalize_path(&worktree_prefix.join(&submodule_path));
845 if !mappings.contains(&worktree_path) {
846 gitlinks.insert(
847 submodule_path.clone(),
848 SubmoduleCheckout {
849 status: SubmoduleCheckoutStatus::MissingMapping,
850 rev: rev.to_string(),
851 path: submodule_path,
852 },
853 );
854 continue;
855 }
856 if stage != "0" {
857 gitlinks.insert(
858 submodule_path.clone(),
859 SubmoduleCheckout {
860 status: SubmoduleCheckoutStatus::Conflicted,
861 rev: rev.to_string(),
862 path: submodule_path,
863 },
864 );
865 continue;
866 }
867
868 let status = self
869 .cmd()
870 .args(["--literal-pathspecs", "submodule", "status", "--"])
871 .arg(&submodule_path)
872 .get_stdout_lossy()?;
873 let (status, rev) = match status.as_bytes().first() {
874 Some(b'-') => (SubmoduleCheckoutStatus::Uninitialized, &status[1..]),
875 Some(b'+') => (SubmoduleCheckoutStatus::Modified, &status[1..]),
876 Some(b'U') => (SubmoduleCheckoutStatus::Conflicted, &status[1..]),
877 Some(_) => (SubmoduleCheckoutStatus::Current, status.as_str()),
878 None => return Err(eyre::eyre!("missing submodule status")),
879 };
880 let rev = rev
881 .split_ascii_whitespace()
882 .next()
883 .ok_or_else(|| eyre::eyre!("invalid submodule status"))?;
884 gitlinks.insert(
885 submodule_path.clone(),
886 SubmoduleCheckout { status, rev: rev.to_string(), path: submodule_path },
887 );
888 }
889 Ok(gitlinks.into_values().collect())
890 }
891
892 pub fn submodule_sync(self) -> Result<()> {
893 self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
894 }
895
896 pub fn submodule_url(self, path: &Path) -> Result<Option<String>> {
898 self.cmd()
899 .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())])
900 .get_stdout_lossy()
901 .map(|url| Some(url.trim().to_string()))
902 }
903
904 pub fn has_submodule_mapping(self, path: &Path) -> Result<bool> {
906 let (names, paths) = self.submodule_mappings()?;
907 Ok(names.contains(path) || paths.contains(path))
908 }
909
910 fn submodule_mappings(self) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
911 self.submodule_mappings_at(self.root)
912 }
913
914 fn submodule_mappings_at(self, root: &Path) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
915 let gitmodules = root.join(".gitmodules");
916 if !gitmodules.exists() {
917 return Ok(Default::default());
918 }
919
920 let output = self
921 .cmd()
922 .args(["config", "--null", "--file"])
923 .arg(gitmodules)
924 .args(["--get-regexp", r"^submodule\..*"])
925 .output()?;
926 match output.status.code() {
927 Some(0) => {
928 let mut names = BTreeSet::new();
929 let mut paths = BTreeSet::new();
930 for entry in
931 output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty())
932 {
933 let Some(separator) = entry.iter().position(|byte| *byte == b'\n') else {
934 return Err(eyre::eyre!("invalid submodule mapping entry"));
935 };
936 let key = std::str::from_utf8(&entry[..separator])?;
937 let value = std::str::from_utf8(&entry[separator + 1..])?;
938 let Some(key) = key.strip_prefix("submodule.") else { continue };
939 let Some((name, field)) = key.rsplit_once('.') else { continue };
940 names.insert(PathBuf::from(name));
941 if field == "path" {
942 paths.insert(PathBuf::from(value));
943 }
944 }
945 Ok((names, paths))
946 }
947 Some(1) => Ok(Default::default()),
948 _ => Err(eyre::eyre!(
949 "failed to inspect .gitmodules: {}",
950 String::from_utf8_lossy(&output.stderr).trim()
951 )),
952 }
953 }
954
955 pub fn is_gitlink(self, path: &Path) -> Result<bool> {
957 self.cmd().args(["ls-files", "--stage", "-z", "--"]).arg(path).exec().map(|output| {
958 let expected_path = path.to_slash_lossy();
959 output.stdout.split(|byte| *byte == 0).any(|entry| {
960 entry.starts_with(b"160000 ")
961 && entry
962 .iter()
963 .position(|byte| *byte == b'\t')
964 .and_then(|separator| entry.get(separator + 1..))
965 == Some(expected_path.as_bytes())
966 })
967 })
968 }
969
970 pub fn has_index_entries(self, path: &Path) -> Result<bool> {
972 self.cmd()
973 .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
974 .arg(path)
975 .exec()
976 .map(|output| !output.stdout.is_empty())
977 }
978
979 pub fn is_normal_tracked_file(self, path: &Path) -> Result<bool> {
981 let output = self
982 .cmd()
983 .args(["--literal-pathspecs", "ls-files", "--stage", "-v", "-z", "--"])
984 .arg(path)
985 .exec()?;
986 let mut entries = output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty());
987 let Some(entry) = entries.next() else { return Ok(false) };
988 if entries.next().is_some() {
989 return Ok(false);
990 }
991 let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
992 return Err(eyre::eyre!("invalid index entry"));
993 };
994 if entry.get(separator + 1..) != Some(path.to_slash_lossy().as_bytes()) {
995 return Ok(false);
996 }
997 let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
998 if fields.next() != Some("H") || !matches!(fields.next(), Some("100644" | "100755")) {
999 return Ok(false);
1000 }
1001 let Some(index_hash) = fields.next() else { return Ok(false) };
1002 if fields.next() != Some("0") || fields.next().is_some() {
1003 return Ok(false);
1004 }
1005 let worktree_hash = self.cmd().args(["hash-object", "--"]).arg(path).get_stdout_lossy()?;
1006 Ok(worktree_hash.trim() == index_hash)
1007 }
1008
1009 pub fn has_submodule_config(self, path: &Path) -> Result<bool> {
1011 let pattern = format!(r"^submodule\.{}\.", regex::escape(&path.to_slash_lossy()));
1012 let output = self.cmd().args(["config", "--local", "--get-regexp", &pattern]).output()?;
1013 match output.status.code() {
1014 Some(0) => Ok(true),
1015 Some(1) => Ok(false),
1016 _ => Err(eyre::eyre!(
1017 "failed to inspect submodule config: {}",
1018 String::from_utf8_lossy(&output.stderr).trim()
1019 )),
1020 }
1021 }
1022
1023 pub fn remove_submodule_config(self, path: &Path) -> Result<()> {
1025 if self.has_submodule_config(path)? {
1026 let section = format!("submodule.{}", path.to_slash_lossy());
1027 self.cmd().args(["config", "--local", "--remove-section", §ion]).exec()?;
1028 }
1029 Ok(())
1030 }
1031
1032 pub fn absolute_git_dir(self) -> Result<PathBuf> {
1034 self.cmd().args(["rev-parse", "--absolute-git-dir"]).get_stdout_lossy().map(PathBuf::from)
1035 }
1036
1037 pub fn remote_url(self, name: &str) -> Option<String> {
1039 self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
1040 }
1041
1042 pub fn set_submodule_branch(self, rel_path: &Path, branch: &str) -> Result<()> {
1044 self.cmd().args(["submodule", "set-branch", "-b", branch]).arg(rel_path).exec().map(drop)
1045 }
1046
1047 pub fn remote_branches(self) -> Result<String> {
1049 self.cmd().args(["branch", "-r"]).get_stdout_lossy()
1050 }
1051
1052 pub fn fetch_and_checkout_branch(self, at: &Path, branch: &str) -> Result<()> {
1054 self.cmd_at(at).args(["fetch", "origin", branch]).exec().map_err(|e| {
1055 eyre::eyre!(
1056 "Could not fetch latest changes for branch {branch} in submodule at {}: {e}",
1057 at.display()
1058 )
1059 })?;
1060 self.cmd_at(at)
1061 .args(["checkout", "-B", branch, &format!("origin/{branch}")])
1062 .exec()
1063 .map_err(|e| {
1064 eyre::eyre!(
1065 "Could not checkout and track origin/{branch} for submodule at {}: {e}",
1066 at.display()
1067 )
1068 })?;
1069 Ok(())
1070 }
1071
1072 fn cmd(self) -> Command {
1073 let mut cmd = Self::cmd_no_root();
1074 cmd.current_dir(self.root);
1075 cmd
1076 }
1077
1078 fn cmd_at(self, path: &Path) -> Command {
1079 let mut cmd = Self::cmd_no_root();
1080 cmd.current_dir(path);
1081 cmd
1082 }
1083
1084 fn cmd_no_root() -> Command {
1085 let mut cmd = Command::new("git");
1086 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1087 cmd
1088 }
1089
1090 fn stderr(self) -> Stdio {
1092 if self.quiet { Stdio::piped() } else { Stdio::inherit() }
1093 }
1094}
1095
1096#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1098pub struct Submodule {
1099 rev: String,
1101 path: PathBuf,
1103}
1104
1105impl Submodule {
1106 pub const fn new(rev: String, path: PathBuf) -> Self {
1107 Self { rev, path }
1108 }
1109
1110 pub fn rev(&self) -> &str {
1111 &self.rev
1112 }
1113
1114 pub const fn path(&self) -> &PathBuf {
1115 &self.path
1116 }
1117}
1118
1119#[derive(Debug, Clone, PartialEq, Eq)]
1121pub struct SubmoduleCheckout {
1122 status: SubmoduleCheckoutStatus,
1123 rev: String,
1124 path: PathBuf,
1125}
1126
1127impl SubmoduleCheckout {
1128 pub const fn status(&self) -> SubmoduleCheckoutStatus {
1129 self.status
1130 }
1131
1132 pub fn rev(&self) -> &str {
1133 &self.rev
1134 }
1135
1136 pub const fn path(&self) -> &PathBuf {
1137 &self.path
1138 }
1139}
1140
1141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub enum SubmoduleCheckoutStatus {
1144 Current,
1146 Uninitialized,
1148 Modified,
1150 Conflicted,
1152 MissingMapping,
1154}
1155
1156impl FromStr for Submodule {
1157 type Err = eyre::Report;
1158
1159 fn from_str(s: &str) -> Result<Self> {
1160 let caps = SUBMODULE_STATUS_REGEX
1161 .captures(s)
1162 .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
1163
1164 Ok(Self {
1165 rev: caps.get(1).unwrap().as_str().to_string(),
1166 path: PathBuf::from(caps.get(2).unwrap().as_str()),
1167 })
1168 }
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq)]
1173pub struct Submodules(pub Vec<Submodule>);
1174
1175impl Submodules {
1176 pub const fn len(&self) -> usize {
1177 self.0.len()
1178 }
1179
1180 pub const fn is_empty(&self) -> bool {
1181 self.0.is_empty()
1182 }
1183}
1184
1185impl FromStr for Submodules {
1186 type Err = eyre::Report;
1187
1188 fn from_str(s: &str) -> Result<Self> {
1189 let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
1190 Ok(Self(subs))
1191 }
1192}
1193
1194impl<'a> IntoIterator for &'a Submodules {
1195 type Item = &'a Submodule;
1196 type IntoIter = std::slice::Iter<'a, Submodule>;
1197
1198 fn into_iter(self) -> Self::IntoIter {
1199 self.0.iter()
1200 }
1201}
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use foundry_common::fs;
1206 use std::{env, fs::File, io::Write};
1207 use tempfile::tempdir;
1208
1209 #[test]
1210 fn applies_gas_estimate_multiplier() {
1211 assert_eq!(apply_gas_estimate_multiplier(21_000, None).unwrap(), 21_000);
1212 assert_eq!(apply_gas_estimate_multiplier(21_000, Some(150)).unwrap(), 31_500);
1213 assert_eq!(
1214 apply_gas_estimate_multiplier(21_000, Some(1_000_000_000_000_000)).unwrap(),
1215 210_000_000_000_000_000
1216 );
1217 assert!(apply_gas_estimate_multiplier(u64::MAX, Some(101)).is_err());
1218 }
1219
1220 #[test]
1221 fn parse_submodule_status() {
1222 let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
1223 let sub = Submodule::from_str(s).unwrap();
1224 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1225 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1226
1227 let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1228 let sub = Submodule::from_str(s).unwrap();
1229 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1230 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1231
1232 let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1233 let sub = Submodule::from_str(s).unwrap();
1234 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1235 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1236 }
1237
1238 #[test]
1239 fn parse_multiline_submodule_status() {
1240 let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
1241+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
1242"#;
1243 let subs = Submodules::from_str(s).unwrap().0;
1244 assert_eq!(subs.len(), 2);
1245 assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
1246 assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
1247 assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1248 assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
1249 }
1250
1251 #[test]
1252 fn deserialize_submodule() {
1253 let submodule: Submodule = serde_json::from_str(
1254 r#"{"rev":"8829465a08cac423dcf59852f21e448449c1a1a8","path":"lib/dep"}"#,
1255 )
1256 .unwrap();
1257 assert_eq!(submodule.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1258 assert_eq!(submodule.path(), Path::new("lib/dep"));
1259 assert_eq!(
1260 serde_json::to_value(submodule).unwrap(),
1261 serde_json::json!({
1262 "rev": "8829465a08cac423dcf59852f21e448449c1a1a8",
1263 "path": "lib/dep",
1264 })
1265 );
1266 }
1267
1268 #[test]
1269 fn skips_submodule_status_if_dependencies_are_initialized() {
1270 let tmp = tempdir().unwrap();
1271 let root = tmp.path();
1272 std::fs::create_dir(root.join(".git")).unwrap();
1273 std::fs::write(
1274 root.join(".gitmodules"),
1275 r#"[submodule "lib/forge-std"]
1276 path = lib/forge-std
1277 url = https://github.com/foundry-rs/forge-std
1278"#,
1279 )
1280 .unwrap();
1281 std::fs::create_dir_all(root.join("lib/forge-std/.git")).unwrap();
1282
1283 let git = Git::new(root);
1284 assert!(git.submodules_initialized(&["lib".into()]).unwrap());
1285 assert!(git.submodules_initialized(&[root.join("lib").into()]).unwrap());
1286
1287 let nested = root.join("packages/contracts");
1288 std::fs::create_dir_all(&nested).unwrap();
1289 assert!(!Git::new(&nested).submodules_initialized(&["../../lib".into()]).unwrap());
1290
1291 assert!(!git.has_missing_dependencies(["lib"]).unwrap());
1293
1294 std::fs::remove_dir(root.join("lib/forge-std/.git")).unwrap();
1295 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1296 }
1297
1298 #[test]
1299 fn foundry_path_ext_works() {
1300 let p = Path::new("contracts/MyTest.t.sol");
1301 assert!(p.is_sol_test());
1302 assert!(p.is_sol());
1303 let p = Path::new("contracts/Greeter.sol");
1304 assert!(!p.is_sol_test());
1305 }
1306
1307 #[test]
1308 fn parse_ether_value_accepts_hex_prefixed_wei() {
1309 assert_eq!(parse_ether_value("0x10").unwrap(), U256::from(16));
1310 assert_eq!(parse_ether_value("0X10").unwrap(), U256::from(16));
1311 assert_eq!(parse_ether_value("0x12").unwrap(), U256::from(0x12));
1312 assert_eq!(parse_ether_value("0xff").unwrap(), U256::from(0xff));
1313 assert_eq!(parse_ether_value("100").unwrap(), U256::from(100));
1314 assert_eq!(parse_ether_value("1ether").unwrap(), U256::from(1000000000000000000u128));
1315 }
1316
1317 #[test]
1319 fn can_load_dotenv() {
1320 let temp = tempdir().unwrap();
1321 Git::new(temp.path()).init().unwrap();
1322 let cwd_env = temp.path().join(".env");
1323 fs::create_file(temp.path().join("foundry.toml")).unwrap();
1324 let nested = temp.path().join("nested");
1325 fs::create_dir(&nested).unwrap();
1326
1327 let mut cwd_file = File::create(cwd_env).unwrap();
1328 let mut prj_file = File::create(nested.join(".env")).unwrap();
1329
1330 cwd_file.write_all(b"TESTCWDKEY=cwd_val\nBROWSER=./project-browser").unwrap();
1331 cwd_file.sync_all().unwrap();
1332
1333 prj_file.write_all(b"TESTPRJKEY=prj_val\nBROWSER=./nested-browser").unwrap();
1334 prj_file.sync_all().unwrap();
1335
1336 let browser = env::var_os("BROWSER");
1337 unsafe { env::remove_var("BROWSER") };
1338 let cwd = env::current_dir().unwrap();
1339 env::set_current_dir(&nested).unwrap();
1340 load_dotenv();
1341 env::set_current_dir(cwd).unwrap();
1342
1343 let loaded_browser = env::var_os("BROWSER");
1344 unsafe {
1345 if let Some(browser) = browser {
1346 env::set_var("BROWSER", browser);
1347 } else {
1348 env::remove_var("BROWSER");
1349 }
1350 }
1351
1352 assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
1353 assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
1354 assert_eq!(loaded_browser.as_deref(), Some(OsStr::new("./project-browser")));
1355 }
1356
1357 #[test]
1358 fn test_read_gitmodules_regex() {
1359 let gitmodules = r#"
1360 [submodule "lib/solady"]
1361 path = lib/solady
1362 url = ""
1363 branch = v0.1.0
1364 [submodule "lib/openzeppelin-contracts"]
1365 path = lib/openzeppelin-contracts
1366 url = ""
1367 branch = v4.8.0-791-g8829465a
1368 [submodule "lib/forge-std"]
1369 path = lib/forge-std
1370 url = ""
1371"#;
1372
1373 let paths = SUBMODULE_BRANCH_REGEX
1374 .captures_iter(gitmodules)
1375 .map(|cap| {
1376 (
1377 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1378 String::from(cap.get(2).unwrap().as_str()),
1379 )
1380 })
1381 .collect::<HashMap<_, _>>();
1382
1383 assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
1384 assert_eq!(
1385 paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1386 "v4.8.0-791-g8829465a"
1387 );
1388
1389 let no_branch_gitmodules = r#"
1390 [submodule "lib/solady"]
1391 path = lib/solady
1392 url = ""
1393 [submodule "lib/openzeppelin-contracts"]
1394 path = lib/openzeppelin-contracts
1395 url = ""
1396 [submodule "lib/forge-std"]
1397 path = lib/forge-std
1398 url = ""
1399"#;
1400 let paths = SUBMODULE_BRANCH_REGEX
1401 .captures_iter(no_branch_gitmodules)
1402 .map(|cap| {
1403 (
1404 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1405 String::from(cap.get(2).unwrap().as_str()),
1406 )
1407 })
1408 .collect::<HashMap<_, _>>();
1409
1410 assert!(paths.is_empty());
1411
1412 let branch_in_between = r#"
1413 [submodule "lib/solady"]
1414 path = lib/solady
1415 url = ""
1416 [submodule "lib/openzeppelin-contracts"]
1417 path = lib/openzeppelin-contracts
1418 url = ""
1419 branch = v4.8.0-791-g8829465a
1420 [submodule "lib/forge-std"]
1421 path = lib/forge-std
1422 url = ""
1423 "#;
1424
1425 let paths = SUBMODULE_BRANCH_REGEX
1426 .captures_iter(branch_in_between)
1427 .map(|cap| {
1428 (
1429 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1430 String::from(cap.get(2).unwrap().as_str()),
1431 )
1432 })
1433 .collect::<HashMap<_, _>>();
1434
1435 assert_eq!(paths.len(), 1);
1436 assert_eq!(
1437 paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1438 "v4.8.0-791-g8829465a"
1439 );
1440 }
1441}