use std::{ cmp::Ordering, collections::BTreeMap, fmt::Display, fs::File, ops::Deref, path::{Path, PathBuf}, str::FromStr, }; use serde::{Deserialize, Serialize}; use revive_common::EVMVersion; use revive_dt_common::{ cached_fs::read_to_string, iterators::FilesWithExtensionIterator, macros::define_wrapper_type, types::Mode, }; use tracing::error; use crate::{case::Case, mode::ParsedMode}; pub const METADATA_FILE_EXTENSION: &str = "json"; pub const SOLIDITY_CASE_FILE_EXTENSION: &str = "sol"; pub const SOLIDITY_CASE_COMMENT_MARKER: &str = "//!"; #[derive(Debug, Default, Deserialize, Clone, Eq, PartialEq)] pub struct MetadataFile { /// The path of the metadata file. This will either be a JSON or solidity file. pub metadata_file_path: PathBuf, /// This is the path contained within the corpus file. This could either be the path of some dir /// or could be the actual metadata file path. pub corpus_file_path: PathBuf, /// The metadata contained within the file. pub content: Metadata, } impl MetadataFile { pub fn relative_path(&self) -> &Path { if self.corpus_file_path.is_file() { &self.corpus_file_path } else { self.metadata_file_path .strip_prefix(&self.corpus_file_path) .unwrap() } } } impl Deref for MetadataFile { type Target = Metadata; fn deref(&self) -> &Self::Target { &self.content } } #[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)] pub struct Metadata { /// A comment on the test case that's added for human-readability. #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ignore: Option, #[serde(skip_serializing_if = "Option::is_none")] pub targets: Option>, pub cases: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub contracts: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub libraries: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub modes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, /// This field specifies an EVM version requirement that the test case has where the test might /// be run of the evm version of the nodes match the evm version specified here. #[serde(skip_serializing_if = "Option::is_none")] pub required_evm_version: Option, /// A set of compilation directives that will be passed to the compiler whenever the contracts for /// the test are being compiled. Note that this differs from the [`Mode`]s in that a [`Mode`] is /// just a filter for when a test can run whereas this is an instruction to the compiler. #[serde(skip_serializing_if = "Option::is_none")] pub compiler_directives: Option, } impl Metadata { /// Returns the modes that we should test from this metadata. pub fn solc_modes(&self) -> Vec { match &self.modes { Some(modes) => ParsedMode::many_to_modes(modes.iter()).collect(), None => Mode::all().collect(), } } /// Returns the base directory of this metadata. pub fn directory(&self) -> anyhow::Result { Ok(self .file_path .as_ref() .and_then(|path| path.parent()) .ok_or_else(|| anyhow::anyhow!("metadata invalid file path: {:?}", self.file_path))? .to_path_buf()) } /// Returns the contract sources with canonicalized paths for the files pub fn contract_sources( &self, ) -> anyhow::Result> { let directory = self.directory()?; let mut sources = BTreeMap::new(); let Some(contracts) = &self.contracts else { return Ok(sources); }; for ( alias, ContractPathAndIdent { contract_source_path, contract_ident, }, ) in contracts { let alias = alias.clone(); let absolute_path = directory.join(contract_source_path).canonicalize()?; let contract_ident = contract_ident.clone(); sources.insert( alias, ContractPathAndIdent { contract_source_path: absolute_path, contract_ident, }, ); } Ok(sources) } /// Try to parse the test metadata struct from the given file at `path`. /// /// Returns `None` if `path` didn't contain a test metadata or case definition. /// /// # Panics /// Expects the supplied `path` to be a file. pub fn try_from_file(path: &Path) -> Option { assert!(path.is_file(), "not a file: {}", path.display()); let file_extension = path.extension()?; if file_extension == METADATA_FILE_EXTENSION { return Self::try_from_json(path); } if file_extension == SOLIDITY_CASE_FILE_EXTENSION { return Self::try_from_solidity(path); } None } fn try_from_json(path: &Path) -> Option { let file = File::open(path) .inspect_err(|err| error!(path = %path.display(), %err, "Failed to open file")) .ok()?; match serde_json::from_reader::<_, Metadata>(file) { Ok(mut metadata) => { metadata.file_path = Some(path.to_path_buf()); Some(metadata) } Err(err) => { error!(path = %path.display(), %err, "Deserialization of metadata failed"); None } } } fn try_from_solidity(path: &Path) -> Option { let spec = read_to_string(path) .inspect_err(|err| error!(path = %path.display(), %err, "Failed to read file content")) .ok()? .lines() .filter_map(|line| line.strip_prefix(SOLIDITY_CASE_COMMENT_MARKER)) .fold(String::new(), |mut buf, string| { buf.push_str(string); buf }); if spec.is_empty() { return None; } match serde_json::from_str::(&spec) { Ok(mut metadata) => { metadata.file_path = Some(path.to_path_buf()); metadata.contracts = Some( [( ContractInstance::new("Test"), ContractPathAndIdent { contract_source_path: path.to_path_buf(), contract_ident: ContractIdent::new("Test"), }, )] .into(), ); Some(metadata) } Err(err) => { error!(path = %path.display(), %err, "Failed to deserialize metadata"); None } } } /// Returns an iterator over all of the solidity files that needs to be compiled for this /// [`Metadata`] object /// /// Note: if the metadata is contained within a solidity file then this is the only file that /// we wish to compile since this is a self-contained test. Otherwise, if it's a JSON file /// then we need to compile all of the contracts that are in the directory since imports are /// allowed in there. pub fn files_to_compile(&self) -> anyhow::Result>> { let Some(ref metadata_file_path) = self.file_path else { anyhow::bail!("The metadata file path is not defined"); }; if metadata_file_path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("sol")) { Ok(Box::new(std::iter::once(metadata_file_path.clone()))) } else { Ok(Box::new( FilesWithExtensionIterator::new(self.directory()?) .with_allowed_extension("sol") .with_use_cached_fs(true), )) } } } define_wrapper_type!( /// Represents a contract instance found a metadata file. /// /// Typically, this is used as the key to the "contracts" field of metadata files. #[derive( Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, )] #[serde(transparent)] pub struct ContractInstance(String) impl Display; ); define_wrapper_type!( /// Represents a contract identifier found a metadata file. /// /// A contract identifier is the name of the contract in the source code. #[derive( Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, )] #[serde(transparent)] pub struct ContractIdent(String) impl Display; ); /// Represents an identifier used for contracts. /// /// The type supports serialization from and into the following string format: /// /// ```text /// ${path}:${contract_ident} /// ``` #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ContractPathAndIdent { /// The path of the contract source code relative to the directory containing the metadata file. pub contract_source_path: PathBuf, /// The identifier of the contract. pub contract_ident: ContractIdent, } impl Display for ContractPathAndIdent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}:{}", self.contract_source_path.display(), self.contract_ident.as_ref() ) } } impl FromStr for ContractPathAndIdent { type Err = anyhow::Error; fn from_str(s: &str) -> Result { let mut splitted_string = s.split(":").peekable(); let mut path = None::; let mut identifier = None::; loop { let Some(next_item) = splitted_string.next() else { break; }; if splitted_string.peek().is_some() { match path { Some(ref mut path) => { path.push(':'); path.push_str(next_item); } None => path = Some(next_item.to_owned()), } } else { identifier = Some(next_item.to_owned()) } } match (path, identifier) { (Some(path), Some(identifier)) => Ok(Self { contract_source_path: PathBuf::from(path), contract_ident: ContractIdent::new(identifier), }), (None, Some(path)) | (Some(path), None) => { let Some(identifier) = path.split(".").next().map(ToOwned::to_owned) else { anyhow::bail!("Failed to find identifier"); }; Ok(Self { contract_source_path: PathBuf::from(path), contract_ident: ContractIdent::new(identifier), }) } (None, None) => anyhow::bail!("Failed to find the path and identifier"), } } } impl TryFrom for ContractPathAndIdent { type Error = anyhow::Error; fn try_from(value: String) -> Result { Self::from_str(&value) } } impl From for String { fn from(value: ContractPathAndIdent) -> Self { value.to_string() } } /// An EVM version requirement that the test case has. This gets serialized and /// deserialized from and into [`String`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct EvmVersionRequirement { ordering: Ordering, or_equal: bool, evm_version: EVMVersion, } impl EvmVersionRequirement { pub fn new_greater_than_or_equals(version: EVMVersion) -> Self { Self { ordering: Ordering::Greater, or_equal: true, evm_version: version, } } pub fn new_greater_than(version: EVMVersion) -> Self { Self { ordering: Ordering::Greater, or_equal: false, evm_version: version, } } pub fn new_equals(version: EVMVersion) -> Self { Self { ordering: Ordering::Equal, or_equal: false, evm_version: version, } } pub fn new_less_than(version: EVMVersion) -> Self { Self { ordering: Ordering::Less, or_equal: false, evm_version: version, } } pub fn new_less_than_or_equals(version: EVMVersion) -> Self { Self { ordering: Ordering::Less, or_equal: true, evm_version: version, } } pub fn matches(&self, other: &EVMVersion) -> bool { let ordering = other.cmp(&self.evm_version); ordering == self.ordering || (self.or_equal && matches!(ordering, Ordering::Equal)) } } impl Display for EvmVersionRequirement { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { ordering, or_equal, evm_version, } = self; match ordering { Ordering::Less => write!(f, "<")?, Ordering::Equal => write!(f, "=")?, Ordering::Greater => write!(f, ">")?, } if *or_equal && !matches!(ordering, Ordering::Equal) { write!(f, "=")?; } write!(f, "{evm_version}") } } impl FromStr for EvmVersionRequirement { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s.as_bytes() { [b'>', b'=', remaining @ ..] => Ok(Self { ordering: Ordering::Greater, or_equal: true, evm_version: str::from_utf8(remaining)?.try_into()?, }), [b'>', remaining @ ..] => Ok(Self { ordering: Ordering::Greater, or_equal: false, evm_version: str::from_utf8(remaining)?.try_into()?, }), [b'<', b'=', remaining @ ..] => Ok(Self { ordering: Ordering::Less, or_equal: true, evm_version: str::from_utf8(remaining)?.try_into()?, }), [b'<', remaining @ ..] => Ok(Self { ordering: Ordering::Less, or_equal: false, evm_version: str::from_utf8(remaining)?.try_into()?, }), [b'=', remaining @ ..] => Ok(Self { ordering: Ordering::Equal, or_equal: false, evm_version: str::from_utf8(remaining)?.try_into()?, }), _ => anyhow::bail!("Invalid EVM version requirement {s}"), } } } impl TryFrom for EvmVersionRequirement { type Error = anyhow::Error; fn try_from(value: String) -> Result { value.parse() } } impl From for String { fn from(value: EvmVersionRequirement) -> Self { value.to_string() } } /// A set of compilation directives that will be passed to the compiler whenever the contracts for /// the test are being compiled. Note that this differs from the [`Mode`]s in that a [`Mode`] is /// just a filter for when a test can run whereas this is an instruction to the compiler. /// Defines how the compiler should handle revert strings. #[derive( Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, )] pub struct CompilationDirectives { /// Defines how the revert strings should be handled. pub revert_string_handling: Option, } /// Defines how the compiler should handle revert strings. #[derive( Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, )] #[serde(rename_all = "camelCase")] pub enum RevertString { #[default] Default, Debug, Strip, VerboseDebug, } #[cfg(test)] mod test { use super::*; #[test] fn contract_identifier_respects_roundtrip_property() { // Arrange let string = "ERC20/ERC20.sol:ERC20"; // Act let identifier = ContractPathAndIdent::from_str(string); // Assert let identifier = identifier.expect("Failed to parse"); assert_eq!( identifier.contract_source_path.display().to_string(), "ERC20/ERC20.sol" ); assert_eq!(identifier.contract_ident, "ERC20".to_owned().into()); // Act let reserialized = identifier.to_string(); // Assert assert_eq!(string, reserialized); } #[test] fn complex_metadata_file_can_be_deserialized() { // Arrange const JSON: &str = include_str!("../../../assets/test_metadata.json"); // Act let metadata = serde_json::from_str::(JSON); // Assert metadata.expect("Failed to deserialize metadata"); } }