diff --git a/bin/pcl/src/main.rs b/bin/pcl/src/main.rs index a071e07..6f73e8a 100644 --- a/bin/pcl/src/main.rs +++ b/bin/pcl/src/main.rs @@ -14,7 +14,10 @@ use pcl_core::{ ConfigArgs, }, }; -use pcl_phoundry::phorge::PhorgeTest; +use pcl_phoundry::{ + build::BuildArgs, + phorge_test::PhorgeTest, +}; use serde_json::json; const VERSION_MESSAGE: &str = concat!( @@ -50,6 +53,8 @@ enum Commands { Auth(AuthCommand), #[command(about = "Manage configuration")] Config(ConfigArgs), + #[command(name = "build")] + Build(BuildArgs), } #[tokio::main] @@ -85,6 +90,9 @@ async fn main() -> Result<()> { Commands::Config(config_cmd) => { config_cmd.run(&mut config)?; } + Commands::Build(build_cmd) => { + build_cmd.run()?; + } }; config.write_to_file(&cli.args)?; Ok::<_, Report>(()) diff --git a/crates/core/src/assertion_da.rs b/crates/core/src/assertion_da.rs index 1097a60..8545d3a 100644 --- a/crates/core/src/assertion_da.rs +++ b/crates/core/src/assertion_da.rs @@ -15,7 +15,7 @@ use indicatif::{ ProgressStyle, }; use pcl_common::args::CliArgs; -use pcl_phoundry::phorge::{ +use pcl_phoundry::build_and_flatten::{ BuildAndFlatOutput, BuildAndFlattenArgs, }; diff --git a/crates/core/tests/common/da_store_harness.rs b/crates/core/tests/common/da_store_harness.rs index aa34ba2..c12d995 100644 --- a/crates/core/tests/common/da_store_harness.rs +++ b/crates/core/tests/common/da_store_harness.rs @@ -10,7 +10,7 @@ use pcl_core::{ assertion_da::DaStoreArgs, error::DaSubmitError, }; -use pcl_phoundry::phorge::BuildAndFlattenArgs; +use pcl_phoundry::build_and_flatten::BuildAndFlattenArgs; use std::{ collections::HashMap, path::PathBuf, diff --git a/crates/phoundry/src/build.rs b/crates/phoundry/src/build.rs new file mode 100644 index 0000000..337f304 --- /dev/null +++ b/crates/phoundry/src/build.rs @@ -0,0 +1,222 @@ +use clap::{ + Parser, + ValueHint, +}; +use foundry_cli::opts::{ + BuildOpts, + ProjectPathOpts, +}; + +use std::path::PathBuf; + +use crate::compile::compile; +use crate::error::PhoundryError; + +/// Command-line arguments for building assertion contracts and tests. +#[derive(Debug, Default, Parser)] +#[clap(about = "Build contracts using Phorge")] +pub struct BuildArgs { + /// Root directory of the project + #[clap( + short = 'r', + long, + value_hint = ValueHint::DirPath, + help = "Root directory of the project" + )] + pub root: Option, +} + +impl BuildArgs { + /// Builds the assertion contract and tests + /// + /// # Returns + /// + /// - `Ok(())` + /// - `Err(PhoundryError)` if any step in the process fails + pub fn run(&self) -> Result<(), Box> { + let build_cmd = BuildOpts { + project_paths: ProjectPathOpts { + root: self.root.clone(), + // FIXME(Odysseas): this essentially hard-codes the location of the assertions to live in + // assertions/src + contracts: Some(PathBuf::from("assertions/src")), + ..Default::default() + }, + ..Default::default() + }; + + foundry_cli::utils::load_dotenv(); + + compile(build_cmd)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + // Helper function to create a temporary Solidity project with valid contracts + fn setup_valid_test_project() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("test_project"); + fs::create_dir_all(&project_root).unwrap(); + + // Create assertions/src directory structure + let contract_dir = project_root.join("assertions").join("src"); + fs::create_dir_all(&contract_dir).unwrap(); + + // Create a valid test contract + let contract_path = contract_dir.join("ValidContract.sol"); + fs::write( + &contract_path, + r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract ValidContract { + function test() public pure returns (bool) { + return true; + } +}"#, + ) + .unwrap(); + + (temp_dir, project_root) + } + + // Helper function to create a temporary Solidity project with compilation errors + fn setup_invalid_test_project() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("test_project"); + fs::create_dir_all(&project_root).unwrap(); + + // Create assertions/src directory structure + let contract_dir = project_root.join("assertions").join("src"); + fs::create_dir_all(&contract_dir).unwrap(); + + // Create a contract with compilation errors + let contract_path = contract_dir.join("InvalidContract.sol"); + fs::write( + &contract_path, + r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract InvalidContract { + // Missing semicolon - syntax error + uint256 public value = 42 + + // Invalid function syntax - missing parentheses + function test public pure returns (bool) { + // Type mismatch error + return "not a boolean"; + } + + // Undefined variable error + function anotherTest() public pure returns (uint256) { + return undefinedVariable; + } + + // Missing closing brace +}"#, + ) + .unwrap(); + + (temp_dir, project_root) + } + + // Helper function to create an empty project (no source files) + fn setup_empty_test_project() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("test_project"); + fs::create_dir_all(&project_root).unwrap(); + + // Create assertions/src directory but leave it empty + let contract_dir = project_root.join("assertions").join("src"); + fs::create_dir_all(&contract_dir).unwrap(); + + (temp_dir, project_root) + } + + #[test] + fn test_build_args_new() { + let args = BuildArgs { root: None }; + + assert!(args.root.is_none()); + } + + #[test] + fn test_build_args_with_root() { + let root_path = PathBuf::from("/test/path"); + let args = BuildArgs { + root: Some(root_path.clone()), + }; + + assert_eq!(args.root, Some(root_path)); + } + + #[test] + fn test_compilation_with_invalid_contract() { + let (_temp_dir, project_root) = setup_invalid_test_project(); + + let args = BuildArgs { + root: Some(project_root), + }; + + let result = args.run(); + + // Compilation should fail due to syntax errors + assert!( + result.is_err(), + "Expected compilation to fail with invalid contract" + ); + } + + #[test] + fn test_compilation_with_empty_directory() { + let (_temp_dir, project_root) = setup_empty_test_project(); + + let args = BuildArgs { + root: Some(project_root), + }; + + let result = args.run(); + + // Compilation should fail due to no source files + assert!( + result.is_err(), + "Expected compilation to fail with empty directory" + ); + } + + #[test] + fn test_compilation_with_nonexistent_directory() { + let temp_dir = TempDir::new().unwrap(); + let nonexistent_path = temp_dir.path().join("nonexistent_project"); + + let args = BuildArgs { + root: Some(nonexistent_path), + }; + + let result = args.run(); + + assert!( + result.is_err(), + "Expected compilation to fail with nonexistent directory" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_build_integration_with_valid_contract() { + let (_temp_dir, project_root) = setup_valid_test_project(); + + let args = BuildArgs { + root: Some(project_root), + }; + + let result = args.run(); + + assert!(result.is_ok()); + } +} diff --git a/crates/phoundry/src/phorge.rs b/crates/phoundry/src/build_and_flatten.rs similarity index 68% rename from crates/phoundry/src/phorge.rs rename to crates/phoundry/src/build_and_flatten.rs index d013693..b3927fc 100644 --- a/crates/phoundry/src/phorge.rs +++ b/crates/phoundry/src/build_and_flatten.rs @@ -2,17 +2,6 @@ use clap::{ Parser, ValueHint, }; -use color_eyre::Report; -use forge::{ - cmd::{ - build::BuildArgs, - test::TestArgs, - }, - opts::{ - Forge, - ForgeSubcommand, - }, -}; use foundry_cli::{ opts::{ BuildOpts, @@ -20,7 +9,6 @@ use foundry_cli::{ }, utils::LoadConfig, }; -use foundry_common::compile::ProjectCompiler; use foundry_compilers::{ flatten::{ Flattener, @@ -33,24 +21,11 @@ use foundry_compilers::{ use alloy_json_abi::JsonAbi; -use foundry_config::{ - error::ExtractConfigError, - find_project_root, -}; +use foundry_config::find_project_root; use std::path::PathBuf; -use tokio::task::spawn_blocking; use crate::error::PhoundryError; -/// Command-line interface for running Phorge tests. -/// This struct wraps the standard Foundry test arguments. -#[derive(Debug, Parser, Clone)] -#[clap(about = "Run tests using Phorge")] -pub struct PhorgeTest { - #[clap(flatten)] - pub test_args: TestArgs, -} - /// Output from building and flattening a Solidity contract. /// Contains the compiler version used and the flattened source code. #[derive(Debug, Default)] @@ -160,51 +135,18 @@ impl BuildAndFlattenArgs { /// Builds the project and returns the compilation output. fn build(&self) -> Result> { - let build_cmd = BuildArgs { - build: BuildOpts { - project_paths: ProjectPathOpts { - root: self.root.clone(), - // FIXME(Odysseas): this essentially hard-codes the location of the assertions to live in - // assertions/src - contracts: Some(PathBuf::from("assertions/src")), - ..Default::default() - }, + let build_opts = BuildOpts { + project_paths: ProjectPathOpts { + root: self.root.clone(), + // FIXME(Odysseas): this essentially hard-codes the location of the assertions to live in + // assertions/src + contracts: Some(PathBuf::from("assertions/src")), ..Default::default() }, ..Default::default() }; - let config = build_cmd.load_config()?; - - let project = config.project().map_err(PhoundryError::SolcError)?; - let contracts = project.sources_path(); - - match std::fs::read_dir(contracts) { - Ok(mut files) => { - // Check if the directory is empty - if files.next().is_none() { - return Err(Box::new(PhoundryError::NoSourceFilesFound)); - } - } - Err(_) => { - return Err(Box::new(PhoundryError::DirectoryNotFound( - contracts.to_path_buf(), - ))); - } - } - - let compiler = ProjectCompiler::new() - .dynamic_test_linking(config.dynamic_test_linking) - .print_names(build_cmd.names) - .print_sizes(build_cmd.sizes) - .ignore_eip_3860(build_cmd.ignore_eip_3860) - .bail(true) - .quiet(true); - - let res = compiler - .compile(&project) - .map_err(PhoundryError::CompilationError)?; - Ok(res) + crate::compile::compile(build_opts) } /// Flattens the contract source code. @@ -243,47 +185,6 @@ impl BuildAndFlattenArgs { } } -impl PhorgeTest { - /// Runs the test command in a separate blocking task. - /// This prevents blocking the current runtime while executing the forge command. - pub async fn run(self) -> Result<(), Box> { - // Extract the Send-safe parts of the test args - let test_args = self.test_args; - let global_opts = test_args.global.clone(); - global_opts.init()?; - // Spawn the blocking operation in a separate task - spawn_blocking(move || { - // Reconstruct the Forge struct inside the closure - let forge = Forge { - cmd: ForgeSubcommand::Test(test_args), - global: global_opts, - }; - forge::args::run_command(forge) - }) - .await - .map_err(|e| Box::new(PhoundryError::ForgeCommandFailed(e.into())))??; - Ok(()) - } -} - -impl From for Box { - fn from(error: ExtractConfigError) -> Self { - Box::new(PhoundryError::FoundryConfigError(error)) - } -} - -impl From for Box { - fn from(error: std::io::Error) -> Self { - Box::new(PhoundryError::from(error)) - } -} - -impl From for Box { - fn from(error: Report) -> Self { - Box::new(PhoundryError::ForgeCommandFailed(error)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/phoundry/src/compile.rs b/crates/phoundry/src/compile.rs new file mode 100644 index 0000000..462d527 --- /dev/null +++ b/crates/phoundry/src/compile.rs @@ -0,0 +1,47 @@ +use forge::cmd::build::BuildArgs; +use foundry_cli::opts::BuildOpts; +use foundry_cli::utils::LoadConfig; +use foundry_common::compile::ProjectCompiler; +use foundry_compilers::ProjectCompileOutput; + +use crate::error::PhoundryError; + +/// Compiles the project and returns the compilation output. +pub fn compile(build_opts: BuildOpts) -> Result> { + let build_cmd = BuildArgs { + build: build_opts, + ..Default::default() + }; + + let config = build_cmd.load_config()?; + + let project = config.project().map_err(PhoundryError::SolcError)?; + let contracts = project.sources_path(); + + match std::fs::read_dir(contracts) { + Ok(mut files) => { + // Check if the directory is empty + if files.next().is_none() { + return Err(Box::new(PhoundryError::NoSourceFilesFound)); + } + } + Err(_) => { + return Err(Box::new(PhoundryError::DirectoryNotFound( + contracts.to_path_buf(), + ))); + } + } + + let compiler = ProjectCompiler::new() + .dynamic_test_linking(config.dynamic_test_linking) + .print_names(build_cmd.names) + .print_sizes(build_cmd.sizes) + .ignore_eip_3860(build_cmd.ignore_eip_3860) + .bail(true) + .quiet(true); + + let res = compiler + .compile(&project) + .map_err(PhoundryError::CompilationError)?; + Ok(res) +} diff --git a/crates/phoundry/src/error.rs b/crates/phoundry/src/error.rs index cc2feff..636ee75 100644 --- a/crates/phoundry/src/error.rs +++ b/crates/phoundry/src/error.rs @@ -1,4 +1,7 @@ -use color_eyre::eyre; +use color_eyre::{ + eyre, + Report, +}; use foundry_compilers::{ error::SolcError, flatten::FlattenerError, @@ -9,6 +12,8 @@ use std::{ }; use thiserror::Error; +use foundry_config::error::ExtractConfigError; + #[derive(Error, Debug)] pub enum PhoundryError { #[error("forge is not installed or not available in PATH")] @@ -40,3 +45,21 @@ pub enum PhoundryError { #[error("Compilation failed:\n{0}")] CompilationError(eyre::Report), } + +impl From for Box { + fn from(error: ExtractConfigError) -> Self { + Box::new(PhoundryError::FoundryConfigError(error)) + } +} + +impl From for Box { + fn from(error: std::io::Error) -> Self { + Box::new(PhoundryError::from(error)) + } +} + +impl From for Box { + fn from(error: Report) -> Self { + Box::new(PhoundryError::ForgeCommandFailed(error)) + } +} diff --git a/crates/phoundry/src/lib.rs b/crates/phoundry/src/lib.rs index cac1f62..c85364c 100644 --- a/crates/phoundry/src/lib.rs +++ b/crates/phoundry/src/lib.rs @@ -1,2 +1,5 @@ +pub mod build; +pub mod build_and_flatten; +pub mod compile; pub mod error; -pub mod phorge; +pub mod phorge_test; diff --git a/crates/phoundry/src/phorge_test.rs b/crates/phoundry/src/phorge_test.rs new file mode 100644 index 0000000..937699f --- /dev/null +++ b/crates/phoundry/src/phorge_test.rs @@ -0,0 +1,44 @@ +use clap::Parser; +use forge::{ + cmd::test::TestArgs, + opts::{ + Forge, + ForgeSubcommand, + }, +}; + +use tokio::task::spawn_blocking; + +use crate::error::PhoundryError; + +/// Command-line interface for running Phorge tests. +/// This struct wraps the standard Foundry test arguments. +#[derive(Debug, Parser, Clone)] +#[clap(about = "Run tests using Phorge")] +pub struct PhorgeTest { + #[clap(flatten)] + pub test_args: TestArgs, +} + +impl PhorgeTest { + /// Runs the test command in a separate blocking task. + /// This prevents blocking the current runtime while executing the forge command. + pub async fn run(self) -> Result<(), Box> { + // Extract the Send-safe parts of the test args + let test_args = self.test_args; + let global_opts = test_args.global.clone(); + global_opts.init()?; + // Spawn the blocking operation in a separate task + spawn_blocking(move || { + // Reconstruct the Forge struct inside the closure + let forge = Forge { + cmd: ForgeSubcommand::Test(test_args), + global: global_opts, + }; + forge::args::run_command(forge) + }) + .await + .map_err(|e| Box::new(PhoundryError::ForgeCommandFailed(e.into())))??; + Ok(()) + } +}