use cargo_metadata::MetadataCommand; use once_cell::sync::Lazy; use std::{ collections::HashMap, env, fmt, fs, path::{Path, PathBuf}, }; use strum::{EnumCount, EnumIter, EnumString, EnumVariantNames, IntoEnumIterator}; /// `ethers_crate => name` type CrateNames = HashMap; const DIRS: [&str; 3] = ["benches", "examples", "tests"]; /// Maps an [`EthersCrate`] to its path string. /// /// See [`ProjectEnvironment`] for more information. /// /// Note: this static variable cannot hold [`syn::Path`] because it is not [`Sync`], so the names /// must be parsed at every call. static ETHERS_CRATE_NAMES: Lazy = Lazy::new(|| { ProjectEnvironment::new_from_env() .and_then(|x| x.determine_ethers_crates()) .unwrap_or_else(|| EthersCrate::ethers_path_names().collect()) }); /// Returns the `core` crate's [`Path`][syn::Path]. #[inline] pub fn ethers_core_crate() -> syn::Path { get_crate_path(EthersCrate::EthersCore) } /// Returns the `contract` crate's [`Path`][syn::Path]. #[inline] pub fn ethers_contract_crate() -> syn::Path { get_crate_path(EthersCrate::EthersContract) } /// Returns the `providers` crate's [`Path`][syn::Path]. #[inline] pub fn ethers_providers_crate() -> syn::Path { get_crate_path(EthersCrate::EthersProviders) } /// Returns an [`EthersCrate`]'s [`Path`][syn::Path] in the current project. #[inline(always)] pub fn get_crate_path(krate: EthersCrate) -> syn::Path { krate.get_path() } /// Represents a generic Rust/Cargo project's environment. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectEnvironment { manifest_dir: PathBuf, crate_name: Option, } impl ProjectEnvironment { /// Creates a new instance using the given manifest dir and crate name. pub fn new, U: Into>(manifest_dir: T, crate_name: U) -> Self { Self { manifest_dir: manifest_dir.into(), crate_name: Some(crate_name.into()) } } /// Creates a new instance using the the `CARGO_MANIFEST_DIR` and `CARGO_CRATE_NAME` environment /// variables. pub fn new_from_env() -> Option { Some(Self { manifest_dir: env::var_os("CARGO_MANIFEST_DIR")?.into(), crate_name: env::var("CARGO_CRATE_NAME").ok(), }) } /// Determines the crate paths to use by looking at the [metadata][cargo_metadata] of the /// project. /// /// The names will be: /// - `ethers::*` if `ethers` is a dependency for all crates; /// - for each `crate`: /// - `ethers_` if it is a dependency, otherwise `ethers::`. #[inline] pub fn determine_ethers_crates(&self) -> Option { let lock_file = self.manifest_dir.join("Cargo.lock"); let lock_file_existed = lock_file.exists(); let names = self.crate_names_from_metadata(); // remove the lock file created from running the command if !lock_file_existed && lock_file.exists() { let _ = std::fs::remove_file(lock_file); } names } #[inline] fn crate_names_from_metadata(&self) -> Option { let metadata = MetadataCommand::new().current_dir(&self.manifest_dir).exec().ok()?; let pkg = metadata.root_package()?; // return ethers_* if the root package is an internal ethers crate since `ethers` is not // available if pkg.name.parse::().is_ok() || pkg.name == "ethers" { return Some(EthersCrate::path_names().collect()) } let mut names: CrateNames = EthersCrate::ethers_path_names().collect(); for dep in pkg.dependencies.iter() { let name = dep.name.as_str(); if name.starts_with("ethers") { if name == "ethers" { return None } else if let Ok(dep) = name.parse::() { names.insert(dep, dep.path_name()); } } } Some(names) } /// Returns whether the `crate` path identifier refers to the root package. /// /// This is false for integration tests, benches, and examples, as the `crate` keyword will not /// refer to the root package. /// /// We can find this using some [environment variables set by Cargo during compilation][ref]: /// - `CARGO_TARGET_TMPDIR` is only set when building integration test or benchmark code; /// - When `CARGO_MANIFEST_DIR` contains `/benches/` or `/examples/` /// - `CARGO_CRATE_NAME`, see `is_crate_name_in_dirs`. /// /// [ref]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates #[inline] pub fn is_crate_root(&self) -> bool { env::var_os("CARGO_TARGET_TMPDIR").is_none() && self.manifest_dir.components().all(|c| { let s = c.as_os_str(); s != "examples" && s != "benches" }) && !self.is_crate_name_in_dirs() } /// Returns whether `crate_name` is the name of a file or directory in the first level of /// `manifest_dir/{benches,examples,tests}/`. /// /// # Example /// /// With this project structure: /// /// ```text /// . /// ├── Cargo.lock /// ├── Cargo.toml /// ├── src/ /// │   ... /// ├── benches/ /// │   ├── large-input.rs /// │   └── multi-file-bench/ /// │   ├── main.rs /// │   └── bench_module.rs /// ├── examples/ /// │   ├── simple.rs /// │   └── multi-file-example/ /// │   ├── main.rs /// │   └── ex_module.rs /// └── tests/ /// ├── some-integration-tests.rs /// └── multi-file-test/ /// ├── main.rs /// └── test_module.rs /// ``` /// /// The resulting `CARGO_CRATE_NAME` values will be: /// /// | Path | Value | /// |:-------------------------------------- | ----------------------:| /// | benches/large-input.rs | large-input | /// | benches/multi-file-bench/\*\*/\*.rs | multi-file-bench | /// | examples/simple.rs | simple | /// | examples/multi-file-example/\*\*/\*.rs | multi-file-example | /// | tests/some-integration-tests.rs | some-integration-tests | /// | tests/multi-file-test/\*\*/\*.rs | multi-file-test | #[inline] pub fn is_crate_name_in_dirs(&self) -> bool { let crate_name = match self.crate_name.as_ref() { Some(name) => name, None => return false, }; let dirs = DIRS.map(|dir| self.manifest_dir.join(dir)); dirs.iter().any(|dir| { fs::read_dir(dir) .ok() .and_then(|entries| { entries .filter_map(Result::ok) .find(|entry| file_stem_eq(entry.path(), crate_name)) }) .is_some() }) } } /// An `ethers-rs` internal crate. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, EnumCount, EnumIter, EnumString, EnumVariantNames, )] #[strum(serialize_all = "kebab-case")] pub enum EthersCrate { EthersAddressbook, EthersContract, EthersContractAbigen, EthersContractDerive, EthersCore, EthersDeriveEip712, EthersEtherscan, EthersMiddleware, EthersProviders, EthersSigners, EthersSolc, } impl AsRef for EthersCrate { fn as_ref(&self) -> &str { self.crate_name() } } impl fmt::Display for EthersCrate { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.pad(self.as_ref()) } } impl EthersCrate { /// "``" #[inline] pub const fn crate_name(self) -> &'static str { match self { Self::EthersAddressbook => "ethers-addressbook", Self::EthersContract => "ethers-contract", Self::EthersContractAbigen => "ethers-contract-abigen", Self::EthersContractDerive => "ethers-contract-derive", Self::EthersCore => "ethers-core", Self::EthersDeriveEip712 => "ethers-derive-eip712", Self::EthersEtherscan => "ethers-etherscan", Self::EthersMiddleware => "ethers-middleware", Self::EthersProviders => "ethers-providers", Self::EthersSigners => "ethers-signers", Self::EthersSolc => "ethers-solc", } } /// "`::`" #[inline] pub const fn path_name(self) -> &'static str { match self { Self::EthersAddressbook => "::ethers_addressbook", Self::EthersContract => "::ethers_contract", Self::EthersContractAbigen => "::ethers_contract_abigen", Self::EthersContractDerive => "::ethers_contract_derive", Self::EthersCore => "::ethers_core", Self::EthersDeriveEip712 => "::ethers_derive_eip712", Self::EthersEtherscan => "::ethers_etherscan", Self::EthersMiddleware => "::ethers_middleware", Self::EthersProviders => "::ethers_providers", Self::EthersSigners => "::ethers_signers", Self::EthersSolc => "::ethers_solc", } } /// "::ethers::``" #[inline] pub const fn ethers_path_name(self) -> &'static str { match self { // re-exported in ethers::contract Self::EthersContractAbigen => "::ethers::contract", // partially Self::EthersContractDerive => "::ethers::contract", Self::EthersDeriveEip712 => "::ethers::contract", Self::EthersAddressbook => "::ethers::addressbook", Self::EthersContract => "::ethers::contract", Self::EthersCore => "::ethers::core", Self::EthersEtherscan => "::ethers::etherscan", Self::EthersMiddleware => "::ethers::middleware", Self::EthersProviders => "::ethers::providers", Self::EthersSigners => "::ethers::signers", Self::EthersSolc => "::ethers::solc", } } /// The path on the file system, from an `ethers-rs` root directory. #[inline] pub const fn fs_path(self) -> &'static str { match self { Self::EthersContractAbigen => "ethers-contract/ethers-contract-abigen", Self::EthersContractDerive => "ethers-contract/ethers-contract-derive", Self::EthersDeriveEip712 => "ethers-core/ethers-derive-eip712", _ => self.crate_name(), } } /// `` #[inline] pub fn path_names() -> impl Iterator { Self::iter().map(|x| (x, x.path_name())) } /// `` #[inline] pub fn ethers_path_names() -> impl Iterator { Self::iter().map(|x| (x, x.ethers_path_name())) } /// Returns the [`Path`][syn::Path] in the current project. #[inline] pub fn get_path(&self) -> syn::Path { let name = ETHERS_CRATE_NAMES[self]; syn::parse_str(name).unwrap() } } /// `path.file_stem() == s` #[inline] fn file_stem_eq, U: AsRef>(path: T, s: U) -> bool { if let Some(stem) = path.as_ref().file_stem() { if let Some(stem) = stem.to_str() { return stem == s.as_ref() } } false } #[cfg(test)] mod tests { use super::*; use rand::{ distributions::{Distribution, Standard}, thread_rng, Rng, }; use std::{ collections::{BTreeMap, HashSet}, env, fs, }; use tempfile::TempDir; impl Distribution for Standard { fn sample(&self, rng: &mut R) -> EthersCrate { const RANGE: std::ops::Range = 0..EthersCrate::COUNT as u8; // SAFETY: generates in the safe range unsafe { std::mem::transmute(rng.gen_range(RANGE)) } } } #[test] fn test_names() { fn assert_names(s: &ProjectEnvironment, ethers: bool, dependencies: &[EthersCrate]) { write_manifest(s, ethers, dependencies); // speeds up consecutive runs by not having to re-create and delete the lockfile // this is tested separately: test_lock_file std::fs::write(s.manifest_dir.join("Cargo.lock"), "").unwrap(); let names = s .determine_ethers_crates() .unwrap_or_else(|| EthersCrate::ethers_path_names().collect()); let krate = s.crate_name.as_ref().and_then(|x| x.parse::().ok()); let is_internal = krate.is_some(); let expected: CrateNames = match (is_internal, ethers) { // internal (true, _) => EthersCrate::path_names().collect(), // ethers (_, true) => EthersCrate::ethers_path_names().collect(), // no ethers (_, false) => { let mut n: CrateNames = EthersCrate::ethers_path_names().collect(); for &dep in dependencies { n.insert(dep, dep.path_name()); } n } }; // don't use assert for a better custom message if names != expected { // BTreeMap sorts the keys let names: BTreeMap<_, _> = names.into_iter().collect(); let expected: BTreeMap<_, _> = expected.into_iter().collect(); panic!("\nCase failed: (`{:?}`, `{ethers}`, `{dependencies:?}`)\nNames: {names:#?}\nExpected: {expected:#?}\n", s.crate_name); } } fn gen_unique() -> [EthersCrate; N] { assert!(N < EthersCrate::COUNT); let rng = &mut thread_rng(); let mut set = HashSet::with_capacity(N); while set.len() < N { set.insert(rng.gen()); } let vec: Vec<_> = set.into_iter().collect(); vec.try_into().unwrap() } let (s, _dir) = test_project(); // crate_name -> represents an external crate // "ethers-contract" -> represents an internal crate for name in [s.crate_name.as_ref().unwrap(), "ethers-contract"] { let s = ProjectEnvironment::new(&s.manifest_dir, name); // only ethers assert_names(&s, true, &[]); // only others assert_names(&s, false, gen_unique::<3>().as_slice()); // ethers and others assert_names(&s, true, gen_unique::<3>().as_slice()); } } #[test] fn test_lock_file() { let (s, _dir) = test_project(); write_manifest(&s, true, &[]); let lock_file = s.manifest_dir.join("Cargo.lock"); assert!(!lock_file.exists()); s.determine_ethers_crates(); assert!(!lock_file.exists()); std::fs::write(&lock_file, "").unwrap(); assert!(lock_file.exists()); s.determine_ethers_crates(); assert!(lock_file.exists()); assert!(!std::fs::read(lock_file).unwrap().is_empty()); } #[test] fn test_is_crate_root() { let (s, _dir) = test_project(); assert!(s.is_crate_root()); // `CARGO_MANIFEST_DIR` // complex path has `/{dir_name}/` in the path // name or path validity not checked let s = ProjectEnvironment::new( s.manifest_dir.join("examples/complex_examples"), "complex-examples", ); assert!(!s.is_crate_root()); let s = ProjectEnvironment::new( s.manifest_dir.join("benches/complex_benches"), "complex-benches", ); assert!(!s.is_crate_root()); } #[test] fn test_is_crate_name_in_dirs() { let (s, _dir) = test_project(); let root = &s.manifest_dir; for dir_name in DIRS { for ty in ["simple", "complex"] { let s = ProjectEnvironment::new(root, format!("{ty}_{dir_name}")); assert!(s.is_crate_name_in_dirs(), "{s:?}"); } } let s = ProjectEnvironment::new(root, "non_existant"); assert!(!s.is_crate_name_in_dirs()); let s = ProjectEnvironment::new(root.join("does-not-exist"), "foo_bar"); assert!(!s.is_crate_name_in_dirs()); } #[test] fn test_file_stem_eq() { let path = Path::new("/tmp/foo.rs"); assert!(file_stem_eq(path, "foo")); assert!(!file_stem_eq(path, "tmp")); assert!(!file_stem_eq(path, "foo.rs")); assert!(!file_stem_eq(path, "fo")); assert!(!file_stem_eq(path, "f")); assert!(!file_stem_eq(path, "")); let path = Path::new("/tmp/foo/"); assert!(file_stem_eq(path, "foo")); assert!(!file_stem_eq(path, "tmp")); assert!(!file_stem_eq(path, "fo")); assert!(!file_stem_eq(path, "f")); assert!(!file_stem_eq(path, "")); } // utils /// Creates: /// /// ```text /// - new_dir /// - src /// - main.rs /// - {dir_name} for dir_name in DIRS /// - simple_{dir_name}.rs /// - complex_{dir_name} /// - src if not "tests" /// - main.rs /// - module.rs /// ``` fn test_project() -> (ProjectEnvironment, TempDir) { // change the prefix to one without the default `.` because it is not a valid crate name let dir = tempfile::Builder::new().prefix("tmp").tempdir().unwrap(); let root = dir.path(); let name = root.file_name().unwrap().to_str().unwrap(); // No Cargo.toml, git fs::create_dir_all(root).unwrap(); let src = root.join("src"); fs::create_dir(&src).unwrap(); fs::write(src.join("main.rs"), "fn main(){}").unwrap(); for dir_name in DIRS { let new_dir = root.join(dir_name); fs::create_dir(&new_dir).unwrap(); let simple = new_dir.join(format!("simple_{dir_name}.rs")); fs::write(simple, "").unwrap(); let mut complex = new_dir.join(format!("complex_{dir_name}")); if dir_name != "tests" { fs::create_dir(&complex).unwrap(); fs::write(complex.join("Cargo.toml"), "").unwrap(); complex.push("src"); } fs::create_dir(&complex).unwrap(); fs::write(complex.join("main.rs"), "").unwrap(); fs::write(complex.join("module.rs"), "").unwrap(); } // create target dirs let target = root.join("target"); fs::create_dir(&target).unwrap(); fs::create_dir_all(target.join("tmp")).unwrap(); (ProjectEnvironment::new(root, name), dir) } /// Writes a test manifest to `{root}/Cargo.toml`. fn write_manifest(s: &ProjectEnvironment, ethers: bool, dependencies: &[EthersCrate]) { // use paths to avoid downloading dependencies const ETHERS_CORE: &str = env!("CARGO_MANIFEST_DIR"); let ethers_root = Path::new(ETHERS_CORE).parent().unwrap(); let mut dependencies_toml = String::with_capacity(150 * (ethers as usize + dependencies.len())); if ethers { let path = ethers_root.join("ethers"); let ethers = format!("ethers = {{ path = \"{}\" }}\n", path.display()); dependencies_toml.push_str(ðers); } for dep in dependencies.iter() { let path = ethers_root.join(dep.fs_path()); let dep = format!("{dep} = {{ path = \"{}\" }}\n", path.display()); dependencies_toml.push_str(&dep); } let contents = format!( r#" [package] name = "{}" version = "0.0.0" edition = "2021" [dependencies] {dependencies_toml} "#, s.crate_name.as_ref().unwrap() ); fs::write(s.manifest_dir.join("Cargo.toml"), contents).unwrap(); } }