Repository: gakonst/ethers-rs
Pull request #678 (2021-12-11 19:24:21)

feat(solc): add workspace utils
<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Bug fixes and new features should include tests.

Contributors guide: https://github.com/gakonst/ethers-rs/blob/master/CONTRIBUTING.md

The contributors guide includes instructions for running rustfmt and building the
documentation.
-->

## Motivation
Add utils for `ethers-solc` to easily create temp workspaces we can also use in foundry.
<!--
Explain the context and why you're making that change. What is the problem
you're trying to solve? In some cases there is not a problem and this can be
thought of as being the motivation for your change.
-->

## Solution

<!--
Summarize the solution and provide any necessary context needed to understand
the code change.
-->

## PR Checklist

- [ ] Added Tests
- [ ] Added Documentation
- [ ] Updated the changelog



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index a2dede5b..7b8043fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1343,6 +1343,7 @@ dependencies = [
  "colored",
  "criterion",
  "ethers-core",
+ "fs_extra",
  "futures-util",
  "getrandom 0.2.3",
  "glob",
@@ -1441,6 +1442,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "fs_extra"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2022715d62ab30faffd124d40b76f4134a550a87792276512b18d63272333394"
+
 [[package]]
 name = "fuchsia-cprng"
 version = "0.1.1"
diff --git a/ethers-solc/Cargo.toml b/ethers-solc/Cargo.toml
index 27d63109..eb13c035 100644
--- a/ethers-solc/Cargo.toml
+++ b/ethers-solc/Cargo.toml
@@ -32,6 +32,8 @@ glob = "0.3.0"
 tracing = "0.1.29"
 num_cpus = "1.13.0"
 tiny-keccak = { version = "2.0.2", default-features = false }
+tempdir = { version = "0.3.7", optional = true }
+fs_extra = { version = "1.2.0", optional = true }
 
 [target.'cfg(any(not(any(target_arch = "x86", target_arch = "x86_64")), target_env = "msvc"))'.dependencies]
 sha2 = { version = "0.9.8", default-features = false }
@@ -48,14 +50,21 @@ getrandom = { version = "0.2", features = ["js"] }
 
 [dev-dependencies]
 criterion = { version = "0.3", features = ["async_tokio"] }
-tokio = { version = "1.12.0", features = ["full"] }
 tempdir = "0.3.7"
+tokio = { version = "1.12.0", features = ["full"] }
 
 [[bench]]
 name = "compile_many"
 harness = false
 
+[[test]]
+name = "project"
+path = "tests/project.rs"
+required-features = ["project-util"]
+
 [features]
 async = ["tokio", "futures-util"]
 full = ["async", "svm"]
+# Utilities for creating and testing project workspaces
+project-util = ["tempdir", "fs_extra"]
 tests = []
diff --git a/ethers-solc/src/artifacts.rs b/ethers-solc/src/artifacts.rs
index ddf57d56..20719dcc 100644
--- a/ethers-solc/src/artifacts.rs
+++ b/ethers-solc/src/artifacts.rs
@@ -523,6 +523,7 @@ pub struct OutputDiagnostics<'a> {
 }
 
 impl<'a> OutputDiagnostics<'a> {
+    /// Returns true if there is at least one error of high severity
     pub fn has_error(&self) -> bool {
         self.errors.iter().any(|err| err.severity.is_error())
     }
diff --git a/ethers-solc/src/config.rs b/ethers-solc/src/config.rs
index 6e1891fa..7332529e 100644
--- a/ethers-solc/src/config.rs
+++ b/ethers-solc/src/config.rs
@@ -58,6 +58,21 @@ impl ProjectPathsConfig {
     pub fn current_dapptools() -> Result<Self> {
         Self::dapptools(std::env::current_dir().map_err(|err| SolcError::io(err, "."))?)
     }
+
+    /// Creates all configured dirs and files
+    pub fn create_all(&self) -> std::result::Result<(), SolcIoError> {
+        if let Some(parent) = self.cache.parent() {
+            fs::create_dir_all(parent).map_err(|err| SolcIoError::new(err, parent))?;
+        }
+        fs::create_dir_all(&self.artifacts)
+            .map_err(|err| SolcIoError::new(err, &self.artifacts))?;
+        fs::create_dir_all(&self.sources).map_err(|err| SolcIoError::new(err, &self.sources))?;
+        fs::create_dir_all(&self.tests).map_err(|err| SolcIoError::new(err, &self.tests))?;
+        for lib in &self.libraries {
+            fs::create_dir_all(lib).map_err(|err| SolcIoError::new(err, lib))?;
+        }
+        Ok(())
+    }
 }
 
 #[derive(Debug, Clone, Eq, PartialEq)]
@@ -158,15 +173,9 @@ impl ProjectPathsConfigBuilder {
         self
     }
 
-    pub fn build(self) -> std::result::Result<ProjectPathsConfig, SolcIoError> {
-        let root = self
-            .root
-            .map(Ok)
-            .unwrap_or_else(std::env::current_dir)
-            .map_err(|err| SolcIoError::new(err, "."))?;
-        let root = std::fs::canonicalize(&root).map_err(|err| SolcIoError::new(err, root))?;
-
-        Ok(ProjectPathsConfig {
+    pub fn build_with_root(self, root: impl Into<PathBuf>) -> ProjectPathsConfig {
+        let root = root.into();
+        ProjectPathsConfig {
             cache: self
                 .cache
                 .unwrap_or_else(|| root.join("cache").join(SOLIDITY_FILES_CACHE_FILENAME)),
@@ -176,7 +185,18 @@ impl ProjectPathsConfigBuilder {
             libraries: self.libraries.unwrap_or_default(),
             remappings: self.remappings.unwrap_or_default(),
             root,
-        })
+        }
+    }
+
+    pub fn build(self) -> std::result::Result<ProjectPathsConfig, SolcIoError> {
+        let root = self
+            .root
+            .clone()
+            .map(Ok)
+            .unwrap_or_else(std::env::current_dir)
+            .map_err(|err| SolcIoError::new(err, "."))?;
+        let root = std::fs::canonicalize(&root).map_err(|err| SolcIoError::new(err, &root))?;
+        Ok(self.build_with_root(root))
     }
 }
 
diff --git a/ethers-solc/src/error.rs b/ethers-solc/src/error.rs
index 35299112..943c7644 100644
--- a/ethers-solc/src/error.rs
+++ b/ethers-solc/src/error.rs
@@ -33,6 +33,10 @@ pub enum SolcError {
     /// General purpose message
     #[error("{0}")]
     Message(String),
+
+    #[cfg(feature = "project-util")]
+    #[error(transparent)]
+    FsExtra(#[from] fs_extra::error::Error),
 }
 
 impl SolcError {
diff --git a/ethers-solc/src/lib.rs b/ethers-solc/src/lib.rs
index 1d0afc21..21b0edcd 100644
--- a/ethers-solc/src/lib.rs
+++ b/ethers-solc/src/lib.rs
@@ -7,6 +7,7 @@ use std::collections::btree_map::Entry;
 
 pub mod cache;
 pub mod hh;
+pub use hh::{HardhatArtifact, HardhatArtifacts};
 
 mod compile;
 
@@ -37,6 +38,10 @@ use std::{
     path::PathBuf,
 };
 
+/// Utilities for creating, mocking and testing of (temporary) projects
+#[cfg(feature = "project-util")]
+pub mod project_util;
+
 /// Represents a project workspace and handles `solc` compiling of all contracts in that workspace.
 #[derive(Debug)]
 pub struct Project<Artifacts: ArtifactOutput = MinimalCombinedArtifacts> {
@@ -93,6 +98,21 @@ impl Project {
 }
 
 impl<Artifacts: ArtifactOutput> Project<Artifacts> {
+    /// Returns the path to the artifacts directory
+    pub fn artifacts_path(&self) -> &PathBuf {
+        &self.paths.artifacts
+    }
+
+    /// Returns the path to the sources directory
+    pub fn sources_path(&self) -> &PathBuf {
+        &self.paths.sources
+    }
+
+    /// Returns the path to the cache file
+    pub fn cache_path(&self) -> &PathBuf {
+        &self.paths.cache
+    }
+
     /// Sets the maximum number of parallel `solc` processes to run simultaneously.
     pub fn set_solc_jobs(&mut self, jobs: usize) {
         assert!(jobs > 0);
@@ -468,15 +488,15 @@ impl<Artifacts: ArtifactOutput> Project<Artifacts> {
     /// Removes the project's artifacts and cache file
     pub fn cleanup(&self) -> std::result::Result<(), SolcIoError> {
         tracing::trace!("clean up project");
-        if self.paths.cache.exists() {
-            std::fs::remove_file(&self.paths.cache)
-                .map_err(|err| SolcIoError::new(err, self.paths.cache.clone()))?;
-            tracing::trace!("removed cache file \"{}\"", self.paths.cache.display());
+        if self.cache_path().exists() {
+            std::fs::remove_file(self.cache_path())
+                .map_err(|err| SolcIoError::new(err, self.cache_path()))?;
+            tracing::trace!("removed cache file \"{}\"", self.cache_path().display());
         }
         if self.paths.artifacts.exists() {
-            std::fs::remove_dir_all(&self.paths.artifacts)
-                .map_err(|err| SolcIoError::new(err, self.paths.artifacts.clone()))?;
-            tracing::trace!("removed artifacts dir \"{}\"", self.paths.artifacts.display());
+            std::fs::remove_dir_all(self.artifacts_path())
+                .map_err(|err| SolcIoError::new(err, self.artifacts_path().clone()))?;
+            tracing::trace!("removed artifacts dir \"{}\"", self.artifacts_path().display());
         }
         Ok(())
     }
diff --git a/ethers-solc/src/project_util.rs b/ethers-solc/src/project_util.rs
new file mode 100644
index 00000000..ab76cc2d
--- /dev/null
+++ b/ethers-solc/src/project_util.rs
@@ -0,0 +1,161 @@
+//! Utilities for mocking project workspaces
+use crate::{
+    config::ProjectPathsConfigBuilder,
+    error::{Result, SolcError},
+    hh::HardhatArtifacts,
+    ArtifactOutput, MinimalCombinedArtifacts, Project, ProjectCompileOutput, ProjectPathsConfig,
+    SolcIoError,
+};
+use fs_extra::{dir, file};
+use std::path::Path;
+use tempdir::TempDir;
+
+pub struct TempProject<T: ArtifactOutput> {
+    /// temporary workspace root
+    _root: TempDir,
+    /// actual project workspace with the `root` tempdir as its root
+    inner: Project<T>,
+}
+
+impl<T: ArtifactOutput> TempProject<T> {
+    /// Makes sure all resources are created
+    fn create_new(root: TempDir, inner: Project<T>) -> std::result::Result<Self, SolcIoError> {
+        let project = Self { _root: root, inner };
+        project.paths().create_all()?;
+        Ok(project)
+    }
+
+    pub fn new(paths: ProjectPathsConfigBuilder) -> Result<Self> {
+        let tmp_dir = TempDir::new("root").map_err(|err| SolcError::io(err, "root"))?;
+        let paths = paths.build_with_root(tmp_dir.path());
+        let inner = Project::builder().artifacts().paths(paths).build()?;
+        Ok(Self::create_new(tmp_dir, inner)?)
+    }
+
+    pub fn project(&self) -> &Project<T> {
+        &self.inner
+    }
+
+    pub fn compile(&self) -> Result<ProjectCompileOutput<T>> {
+        self.project().compile()
+    }
+
+    pub fn project_mut(&mut self) -> &mut Project<T> {
+        &mut self.inner
+    }
+
+    /// The configured paths of the project
+    pub fn paths(&self) -> &ProjectPathsConfig {
+        &self.project().paths
+    }
+
+    /// The root path of the temporary workspace
+    pub fn root(&self) -> &Path {
+        self.project().paths.root.as_path()
+    }
+
+    /// Copies a single file into the projects source
+    pub fn copy_source(&self, source: impl AsRef<Path>) -> Result<()> {
+        copy_file(source, &self.paths().sources)
+    }
+
+    pub fn copy_sources<I, S>(&self, sources: I) -> Result<()>
+    where
+        I: IntoIterator<Item = S>,
+        S: AsRef<Path>,
+    {
+        for path in sources {
+            self.copy_source(path)?;
+        }
+        Ok(())
+    }
+
+    /// Copies a single file into the project's main library directory
+    pub fn copy_lib(&self, lib: impl AsRef<Path>) -> Result<()> {
+        let lib_dir = self
+            .paths()
+            .libraries
+            .get(0)
+            .ok_or_else(|| SolcError::msg("No libraries folders configured"))?;
+        copy_file(lib, lib_dir)
+    }
+
+    /// Copy a series of files into the main library dir
+    pub fn copy_libs<I, S>(&self, libs: I) -> Result<()>
+    where
+        I: IntoIterator<Item = S>,
+        S: AsRef<Path>,
+    {
+        for path in libs {
+            self.copy_lib(path)?;
+        }
+        Ok(())
+    }
+}
+
+impl TempProject<HardhatArtifacts> {
+    /// Creates an empty new hardhat style workspace in a new temporary dir
+    pub fn hardhat() -> Result<Self> {
+        let tmp_dir = TempDir::new("tmp_hh").map_err(|err| SolcError::io(err, "tmp_hh"))?;
+
+        let paths = ProjectPathsConfig::hardhat(tmp_dir.path())?;
+
+        let inner = Project::builder().artifacts().paths(paths).build()?;
+        Ok(Self::create_new(tmp_dir, inner)?)
+    }
+}
+
+impl TempProject<MinimalCombinedArtifacts> {
+    /// Creates an empty new dapptools style workspace in a new temporary dir
+    pub fn dapptools() -> Result<Self> {
+        let tmp_dir = TempDir::new("tmp_dapp").map_err(|err| SolcError::io(err, "temp_dapp"))?;
+        let paths = ProjectPathsConfig::dapptools(tmp_dir.path())?;
+
+        let inner = Project::builder().artifacts().paths(paths).build()?;
+        Ok(Self::create_new(tmp_dir, inner)?)
+    }
+}
+
+impl<T: ArtifactOutput> AsRef<Project<T>> for TempProject<T> {
+    fn as_ref(&self) -> &Project<T> {
+        self.project()
+    }
+}
+
+fn dir_copy_options() -> dir::CopyOptions {
+    dir::CopyOptions {
+        overwrite: true,
+        skip_exist: false,
+        buffer_size: 64000, //64kb
+        copy_inside: true,
+        content_only: false,
+        depth: 0,
+    }
+}
+
+fn file_copy_options() -> file::CopyOptions {
+    file::CopyOptions {
+        overwrite: true,
+        skip_exist: false,
+        buffer_size: 64000, //64kb
+    }
+}
+
+/// Copies a single file into the given dir
+pub fn copy_file(source: impl AsRef<Path>, target_dir: impl AsRef<Path>) -> Result<()> {
+    let source = source.as_ref();
+    let target = target_dir.as_ref().join(
+        source
+            .file_name()
+            .ok_or_else(|| SolcError::msg(format!("No file name for {}", source.display())))?,
+    );
+
+    fs_extra::file::copy(source, target, &file_copy_options())?;
+    Ok(())
+}
+
+/// Copies all content of the source dir into the target dir
+pub fn copy_dir(source: impl AsRef<Path>, target_dir: impl AsRef<Path>) -> Result<()> {
+    fs_extra::dir::copy(source, target_dir, &dir_copy_options())?;
+    Ok(())
+}


Test patch:
diff --git a/ethers-solc/tests/project.rs b/ethers-solc/tests/project.rs
index 3157b66a..0073b388 100644
--- a/ethers-solc/tests/project.rs
+++ b/ethers-solc/tests/project.rs
@@ -1,6 +1,9 @@
 //! project tests
 
-use ethers_solc::{cache::SOLIDITY_FILES_CACHE_FILENAME, Project, ProjectPathsConfig};
+use ethers_solc::{
+    cache::SOLIDITY_FILES_CACHE_FILENAME, project_util::*, MinimalCombinedArtifacts, Project,
+    ProjectPathsConfig,
+};
 use std::{
     io,
     path::{Path, PathBuf},
@@ -9,23 +12,12 @@ use tempdir::TempDir;
 
 #[test]
 fn can_compile_hardhat_sample() {
-    let tmp_dir = TempDir::new("root").unwrap();
-    let cache = tmp_dir.path().join("cache");
-    let cache = cache.join(SOLIDITY_FILES_CACHE_FILENAME);
-    let artifacts = tmp_dir.path().join("artifacts");
-
     let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-data/hardhat-sample");
     let paths = ProjectPathsConfig::builder()
-        .cache(cache)
         .sources(root.join("contracts"))
-        .artifacts(artifacts)
-        .lib(root.join("node_modules"))
-        .root(root)
-        .build()
-        .unwrap();
-    // let paths = ProjectPathsConfig::hardhat(root).unwrap();
+        .lib(root.join("node_modules"));
+    let project = TempProject::<MinimalCombinedArtifacts>::new(paths).unwrap();
 
-    let project = Project::builder().paths(paths).build().unwrap();
     let compiled = project.compile().unwrap();
     assert!(compiled.find("Greeter").is_some());
     assert!(compiled.find("console").is_some());
@@ -38,7 +30,7 @@ fn can_compile_hardhat_sample() {
     assert!(compiled.is_unchanged());
 
     // delete artifacts
-    std::fs::remove_dir_all(&project.paths.artifacts).unwrap();
+    std::fs::remove_dir_all(&project.paths().artifacts).unwrap();
     let compiled = project.compile().unwrap();
     assert!(compiled.find("Greeter").is_some());
     assert!(compiled.find("console").is_some());
@@ -47,22 +39,10 @@ fn can_compile_hardhat_sample() {
 
 #[test]
 fn can_compile_dapp_sample() {
-    let tmp_dir = TempDir::new("root").unwrap();
-    let cache = tmp_dir.path().join("cache");
-    let cache = cache.join(SOLIDITY_FILES_CACHE_FILENAME);
-    let artifacts = tmp_dir.path().join("out");
-
     let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-data/dapp-sample");
-    let paths = ProjectPathsConfig::builder()
-        .cache(cache)
-        .sources(root.join("src"))
-        .artifacts(artifacts)
-        .lib(root.join("lib"))
-        .root(root)
-        .build()
-        .unwrap();
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MinimalCombinedArtifacts>::new(paths).unwrap();
 
-    let project = Project::builder().paths(paths).build().unwrap();
     let compiled = project.compile().unwrap();
     assert!(compiled.find("Dapp").is_some());
     assert!(!compiled.has_compiler_errors());
@@ -73,7 +53,7 @@ fn can_compile_dapp_sample() {
     assert!(compiled.is_unchanged());
 
     // delete artifacts
-    std::fs::remove_dir_all(&project.paths.artifacts).unwrap();
+    std::fs::remove_dir_all(&project.paths().artifacts).unwrap();
     let compiled = project.compile().unwrap();
     assert!(compiled.find("Dapp").is_some());
     assert!(!compiled.is_unchanged());
@@ -111,7 +91,7 @@ fn can_compile_dapp_sample_with_cache() {
     assert!(compiled.is_unchanged());
 
     // deleted artifacts cause recompile even with cache
-    std::fs::remove_dir_all(&project.paths.artifacts).unwrap();
+    std::fs::remove_dir_all(&project.artifacts_path()).unwrap();
     let compiled = project.compile().unwrap();
     assert!(compiled.find("Dapp").is_some());
     assert!(!compiled.is_unchanged());