From eb20e258240ea5f873bdfa1b50efa0a085e770e9 Mon Sep 17 00:00:00 2001 From: "Valentin B." <703631+beeb@users.noreply.github.com> Date: Mon, 5 Aug 2024 09:26:36 +0200 Subject: [PATCH] refactor!: big rewrite (#118) Closes #106 Closes #107 Closes #117 Closes #119 Closes #121 --- .gitignore | 3 +- Cargo.lock | 26 +- Cargo.toml | 22 +- src/auth.rs | 127 ++- src/commands.rs | 130 ++- src/config.rs | 1766 +++++++++++++++++++++++----------- src/dependency_downloader.rs | 746 +++++++------- src/errors.rs | 267 ++--- src/janitor.rs | 150 ++- src/lib.rs | 619 +++++------- src/lock.rs | 203 ++-- src/main.rs | 4 +- src/remote.rs | 59 +- src/utils.rs | 172 ++-- src/versioning.rs | 263 ++--- tests/ci/foundry.rs | 51 +- 16 files changed, 2473 insertions(+), 2135 deletions(-) diff --git a/.gitignore b/.gitignore index 986d87e..d9e56c1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ test/* !emptyfile !emptyfile2 test_push_sensitive -test_push_skip_sensitive \ No newline at end of file +test_push_skip_sensitive +.soldeer/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b9328aa..d145b2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,9 +1542,9 @@ checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" [[package]] name = "simple-home-dir" -version = "0.3.5" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c221cbc8c1ff6bdf949b12cc011456c510ec6840654b444c7374c78e928ce344" +checksum = "ee786d36d89b647cb282c7253385e4c563f363b83e6cdc5c2737724980a8a6a6" dependencies = [ "windows-sys 0.52.0", ] @@ -1595,8 +1595,8 @@ dependencies = [ "serial_test", "sha256", "simple-home-dir", + "thiserror", "tokio", - "toml", "toml_edit", "uuid", "walkdir", @@ -1643,18 +1643,18 @@ checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", @@ -1729,18 +1729,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - [[package]] name = "toml_datetime" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 13c2f7f..1030f67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,35 +13,35 @@ repository = "https://github.com/mario-eth/soldeer" version = "0.2.19" [dependencies] -chrono = {version = "0.4.38", default-features = false, features = [ +chrono = { version = "0.4.38", default-features = false, features = [ "std", "serde", -]} -clap = {version = "4.5.9", features = ["derive"]} +] } +clap = { version = "4.5.9", features = ["derive"] } email-address-parser = "2.0.0" futures = "0.3.30" once_cell = "1.19" regex = "1.10.5" -reqwest = {version = "0.12.5", features = [ +reqwest = { version = "0.12.5", features = [ "blocking", "json", "multipart", "stream", -], default-features = false} +], default-features = false } rpassword = "7.3.1" serde = "1.0.204" serde_derive = "1.0.204" serde_json = "1.0.120" sha256 = "1.5.0" -simple-home-dir = "0.3.5" -tokio = {version = "1.38.0", features = ["rt-multi-thread", "macros"]} -toml = "0.8.14" -toml_edit = "0.22.15" -uuid = {version = "1.10.0", features = ["serde", "v4"]} +simple-home-dir = "0.4.0" +thiserror = "1.0.63" +tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros"] } +toml_edit = { version = "0.22.15", features = ["serde"] } +uuid = { version = "1.10.0", features = ["serde", "v4"] } walkdir = "2.5.0" yansi = "1.0.1" yash-fnmatch = "1.1.1" -zip = {version = "2.1.3", default-features = false, features = ["deflate"]} +zip = { version = "2.1.3", default-features = false, features = ["deflate"] } zip-extract = "0.1.3" [dev-dependencies] diff --git a/src/auth.rs b/src/auth.rs index 860b9f0..1bb6960 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,16 +1,18 @@ use crate::{ - errors::LoginError, + errors::AuthError, utils::{define_security_file_location, get_base_url, read_file}, }; use email_address_parser::{EmailAddress, ParsingOptions}; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use rpassword::read_password; use serde_derive::{Deserialize, Serialize}; use std::{ fs::OpenOptions, io::{self, Write}, }; -use yansi::Paint; +use yansi::Paint as _; + +pub type Result = std::result::Result; #[derive(Debug, Serialize, Deserialize)] pub struct Login { @@ -24,12 +26,12 @@ pub struct LoginResponse { pub token: String, } -pub async fn login() -> Result<(), LoginError> { +pub async fn login() -> Result<()> { print!("ℹ️ If you do not have an account, please go to soldeer.xyz to create one.\n📧 Please enter your email: "); std::io::stdout().flush().unwrap(); let mut email = String::new(); if io::stdin().read_line(&mut email).is_err() { - return Err(LoginError { cause: "Invalid email".to_string() }); + return Err(AuthError::InvalidEmail); } email = match check_email(email) { Ok(e) => e, @@ -46,40 +48,40 @@ pub async fn login() -> Result<(), LoginError> { Ok(()) } -pub fn get_token() -> Result { - let security_file = define_security_file_location(); +pub fn get_token() -> Result { + let security_file = define_security_file_location()?; let jwt = read_file(security_file); match jwt { Ok(token) => Ok(String::from_utf8(token) .expect("You are not logged in. Please login using the 'soldeer login' command")), - Err(_) => Err(LoginError { - cause: "You are not logged in. Please login using the 'login' command".to_string(), - }), + Err(_) => Err(AuthError::MissingToken), } } -fn check_email(email_str: String) -> Result { +fn check_email(email_str: String) -> Result { let email_str = email_str.trim().to_string().to_ascii_lowercase(); let email: Option = EmailAddress::parse(&email_str, Some(ParsingOptions::default())); if email.is_none() { - Err(LoginError { cause: "Invalid email".to_string() }) + Err(AuthError::InvalidEmail) } else { Ok(email_str) } } -async fn execute_login(login: Login) -> Result<(), LoginError> { +async fn execute_login(login: Login) -> Result<()> { let url = format!("{}/api/v1/auth/login", get_base_url()); let req = Client::new().post(url).json(&login); let login_response = req.send().await; - let security_file = define_security_file_location(); - if let Ok(response) = login_response { - if response.status().is_success() { - println!("{}", Paint::green("Login successful")); + let security_file = define_security_file_location()?; + let response = login_response?; + + match response.status() { + s if s.is_success() => { + println!("{}", "Login successful".green()); let jwt = serde_json::from_str::(&response.text().await.unwrap()) .unwrap() .token; @@ -90,32 +92,13 @@ async fn execute_login(login: Login) -> Result<(), LoginError> { .append(false) .open(&security_file) .unwrap(); - if let Err(err) = write!(file, "{}", &jwt) { - return Err(LoginError { - cause: format!( - "Couldn't write to the security file {}: {}", - &security_file, err - ), - }); - } - println!("{}", Paint::green(&format!("Login details saved in: {:?}", &security_file))); - - return Ok(()); - } else if response.status().as_u16() == 401 { - return Err(LoginError { - cause: "Authentication failed. Invalid email or password".to_string(), - }); - } else { - return Err(LoginError { - cause: format!( - "Authentication failed. Server response: {}", - response.status().as_u16() - ), - }); + write!(file, "{}", &jwt)?; + println!("{}", format!("Login details saved in: {:?}", &security_file).green()); + Ok(()) } + StatusCode::UNAUTHORIZED => Err(AuthError::InvalidCredentials), + _ => Err(AuthError::HttpError(response.error_for_status().unwrap_err())), } - - Err(LoginError { cause: format!("Authentication failed. Unknown error.{:?}", login_response) }) } #[cfg(test)] @@ -133,8 +116,7 @@ mod tests { assert_eq!(check_email(valid_email.clone()).unwrap(), valid_email); - let expected_error = LoginError { cause: "Invalid email".to_string() }; - assert_eq!(check_email(invalid_email).err().unwrap(), expected_error); + assert!(matches!(check_email(invalid_email), Err(AuthError::InvalidEmail))); } #[tokio::test] @@ -148,7 +130,10 @@ mod tests { // Request a new server from the pool let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", format!("http://{}", server.host_with_port())); + } // Create a mock let _ = server @@ -179,7 +164,10 @@ mod tests { #[serial] async fn login_401() { let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", format!("http://{}", server.host_with_port())); + } let data = r#" { @@ -193,27 +181,25 @@ mod tests { .with_body(data) .create(); - match execute_login(Login { - email: "test@test.com".to_string(), - password: "1234".to_string(), - }) - .await - { - Ok(_) => {} - Err(err) => { - let expected_error = LoginError { - cause: "Authentication failed. Invalid email or password".to_string(), - }; - assert_eq!(err, expected_error); - } - }; + assert!(matches!( + execute_login(Login { + email: "test@test.com".to_string(), + password: "1234".to_string(), + }) + .await, + Err(AuthError::InvalidCredentials) + )); } #[tokio::test] #[serial] async fn login_500() { let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); + + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", format!("http://{}", server.host_with_port())); + } let data = r#" { @@ -227,18 +213,13 @@ mod tests { .with_body(data) .create(); - match execute_login(Login { - email: "test@test.com".to_string(), - password: "1234".to_string(), - }) - .await - { - Ok(_) => {} - Err(err) => { - let expected_error = - LoginError { cause: "Authentication failed. Server response: 500".to_string() }; - assert_eq!(err, expected_error); - } - }; + assert!(matches!( + execute_login(Login { + email: "test@test.com".to_string(), + password: "1234".to_string(), + }) + .await, + Err(AuthError::HttpError(_)) + )); } } diff --git a/src/commands.rs b/src/commands.rs index e8f39e0..e513e54 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -17,83 +17,121 @@ pub enum Subcommands { Login(Login), Push(Push), Uninstall(Uninstall), - VersionDryRun(VersionDryRun), + Version(Version), } +/// Initialize a new Soldeer project for use with Foundry #[derive(Debug, Clone, Parser)] -#[clap( - about = "Initialize a new Soldeer project for use with Foundry. -Use --clean true if you want to delete .gitmodules and lib directory that were created in Foundry.", - after_help = "For more information, read the README.md", - override_usage = "soldeer init" -)] +#[clap(after_help = "For more information, read the README.md")] pub struct Init { - #[arg(long, value_parser = clap::value_parser!(bool))] - pub clean: Option, + /// Clean the Foundry project by removing .gitmodules and the lib directory + #[arg(long, default_value_t = false)] + pub clean: bool, } +fn validate_dependency(dep: &str) -> Result { + if dep.split('~').count() != 2 { + return Err("The dependency should be in the format ~".to_string()); + } + Ok(dep.to_string()) +} + +/// Install a dependency #[derive(Debug, Clone, Parser)] #[clap( - about = "Install a dependency from Soldeer repository or from a custom url that points to a zip file or from git using a git link. - IMPORTANT!! The `~` when specifying the dependency is very important to differentiate between the name and the version that needs to be installed. - Example from remote repository: soldeer install @openzeppelin-contracts~2.3.0 - Example custom url: soldeer install @openzeppelin-contracts~2.3.0 https://github.com/OpenZeppelin/openzeppelin-contracts/archive/refs/tags/v5.0.2.zip - Example git: soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git - Example git with specified commit: soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git --rev 05f218fb6617932e56bf5388c3b389c3028a7b73\n", - after_help = "For more information, read the README.md", - override_usage = "soldeer install ~ [URL]" + long_about = "Install a dependency + +You can install a dependency from the Soldeer repository, a custom URL pointing to a zip file, or from Git using a Git link. +**Important:** The `~` symbol when specifying the dependency is crucial to differentiate between the name and the version that needs to be installed. +- **Example from Soldeer repository:** + soldeer install @openzeppelin-contracts~2.3.0 +- **Example from a custom URL:** + soldeer install @openzeppelin-contracts~2.3.0 https://github.com/OpenZeppelin/openzeppelin-contracts/archive/refs/tags/v5.0.2.zip +- **Example from Git:** + soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git +- **Example from Git with a specified commit:** + soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git --rev 05f218fb6617932e56bf5388c3b389c3028a7b73", + after_help = "For more information, read the README.md" )] pub struct Install { - #[clap(required = false)] + /// The dependency name and version, separated by a tilde. + /// + /// If not present, this command will perform `soldeer update` + #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY~VERSION")] pub dependency: Option, - #[clap(required = false)] + + /// The URL to the dependency zip file, if not from the Soldeer repository + /// + /// Example: https://my-domain/dep.zip + #[arg(value_name = "URL")] pub remote_url: Option, - #[arg(long, value_parser = clap::value_parser!(String))] + + /// The revision of the dependency, if from Git + #[arg(long)] pub rev: Option, + + /// If set, this command will delete the existing remappings and re-create them + #[arg(long, default_value_t = false)] + pub regenerate_remappings: bool, } +/// Update dependencies by reading the config file #[derive(Debug, Clone, Parser)] -#[clap( - about = "Update dependencies by reading the config file.", - after_help = "For more information, read the README.md", - override_usage = "soldeer update" -)] -pub struct Update {} +#[clap(after_help = "For more information, read the README.md")] +pub struct Update { + /// If set, this command will delete the existing remappings and re-create them + #[arg(long, default_value_t = false)] + pub regenerate_remappings: bool, +} +/// Display the version of Soldeer #[derive(Debug, Clone, Parser)] -pub struct VersionDryRun {} +pub struct Version {} +/// Log into the central repository to push the dependencies #[derive(Debug, Clone, Parser)] -#[clap( - about = "Login into the central repository to push the dependencies.", - after_help = "For more information, read the README.md", - override_usage = "soldeer login" -)] +#[clap(after_help = "For more information, read the README.md")] pub struct Login {} +/// Push a dependency to the repository #[derive(Debug, Clone, Parser)] #[clap( - about = "Push a dependency to the repository. The PATH_TO_DEPENDENCY is optional and if not provided, the current directory will be used.\nExample: If the directory is /home/soldeer/my_project and you do not specify the PATH_TO_DEPENDENCY,\nthe files inside the /home/soldeer/my_project will be pushed to the repository.\nIf you specify the PATH_TO_DEPENDENCY, the files inside the specified directory will be pushed to the repository.\nIf you want to ignore certain files, you can create a .soldeerignore file in the root of the project and add the files you want to ignore.\nThe .soldeerignore works like .gitignore.\nFor dry-run please use the --dry-run argument set to true, `soldeer push ... --dry-run true`. This will create a zip file that you can inspect and see what it will be pushed to the central repository.", - after_help = "For more information, read the README.md", - override_usage = "soldeer push ~ [PATH_TO_DEPENDENCY]" + long_about = "Push a Dependency to the Repository +The `PATH_TO_DEPENDENCY` is optional. If not provided, the current directory will be used. +**Example:** +- If the current directory is `/home/soldeer/my_project` and you do not specify the `PATH_TO_DEPENDENCY`, the files inside `/home/soldeer/my_project` will be pushed to the repository. +- If you specify the `PATH_TO_DEPENDENCY`, the files inside the specified directory will be pushed to the repository. +To ignore certain files, create a `.soldeerignore` file in the root of the project and add the files you want to ignore. The `.soldeerignore` works like a `.gitignore`. +For a dry run, use the `--dry-run` argument set to `true`: `soldeer push ... --dry-run true`. This will create a zip file that you can inspect to see what will be pushed to the central repository.", + after_help = "For more information, read the README.md" )] pub struct Push { - #[clap(required = true)] + /// The dependency name and version, separated by a tilde. + /// + /// This should always be used when you want to push a dependency to the central repository: ``. + #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY>~, - #[arg(short, long)] - pub dry_run: Option, - #[arg(long, value_parser = clap::value_parser!(bool))] - pub skip_warnings: Option, + + /// Use this if you want to run a dry run. If set, this will generate a zip file that you can + /// inspect to see what will be pushed. + #[arg(short, long, default_value_t = false)] + pub dry_run: bool, + + /// Use this if you want to skip the warnings that can be triggered when trying to push + /// dotfiles like .env. + #[arg(long, default_value_t = false)] + pub skip_warnings: bool, } +/// Uninstall a dependency #[derive(Debug, Clone, Parser)] -#[clap( - about = "Uninstall a dependency. soldeer uninstall ", - after_help = "For more information, read the README.md", - override_usage = "soldeer uninstall " -)] +#[clap(after_help = "For more information, read the README.md")] pub struct Uninstall { - #[clap(required = true)] + /// The dependency name. Specifying a version is not necessary. pub dependency: String, } diff --git a/src/config.rs b/src/config.rs index bf8df5a..fb19540 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,350 +1,422 @@ use crate::{ errors::ConfigError, - remote::get_dependency_url_remote, - utils::{get_current_working_dir, read_file_to_string, remove_empty_lines}, + utils::{get_current_working_dir, read_file_to_string}, FOUNDRY_CONFIG_FILE, SOLDEER_CONFIG_FILE, }; -use serde_derive::Deserialize; +use serde_derive::{Deserialize, Serialize}; use std::{ env, fs::{self, remove_dir_all, remove_file, File}, - io, - io::Write, - path::Path, + io::{self, Write}, + path::{Path, PathBuf}, }; -use toml::Table; -use toml_edit::{value, DocumentMut, Item}; -use yansi::Paint; - -// Top level struct to hold the TOML data. -#[derive(Deserialize, Debug)] -struct Data { - dependencies: Table, +use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table}; +use yansi::Paint as _; + +pub type Result = std::result::Result; + +/// Location where to store the remappings, either in `remappings.txt` or the config file +/// (foundry/soldeer) +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RemappingsLocation { + #[default] + Txt, + Config, } -// Dependency object used to store a dependency data -#[derive(Deserialize, Clone, Debug, PartialEq)] -pub struct Dependency { +fn default_true() -> bool { + true +} + +/// The Soldeer config options +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct SoldeerConfig { + #[serde(default = "default_true")] + pub remappings_generate: bool, + + #[serde(default)] + pub remappings_regenerate: bool, + + #[serde(default = "default_true")] + pub remappings_version: bool, + + #[serde(default)] + pub remappings_prefix: String, + + #[serde(default)] + pub remappings_location: RemappingsLocation, +} + +impl Default for SoldeerConfig { + fn default() -> Self { + SoldeerConfig { + remappings_generate: true, + remappings_regenerate: false, + remappings_version: true, + remappings_prefix: String::new(), + remappings_location: Default::default(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct GitDependency { + pub name: String, + pub version: String, + pub git: String, + pub rev: Option, +} + +impl core::fmt::Display for GitDependency { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "{}~{}", self.name, self.version) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct HttpDependency { pub name: String, pub version: String, - pub url: String, - pub hash: String, + pub url: Option, + pub checksum: Option, } -#[derive(Deserialize, Debug)] -struct Foundry { - remappings: Table, +impl core::fmt::Display for HttpDependency { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "{}~{}", self.name, self.version) + } +} + +// Dependency object used to store a dependency data +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum Dependency { + Http(HttpDependency), + Git(GitDependency), } -pub async fn read_config(filename: String) -> Result, ConfigError> { - let mut filename: String = filename; - if filename.is_empty() { - filename = match define_config_file() { - Ok(file) => file, - Err(err) => return Err(err), +impl Dependency { + pub fn name(&self) -> &str { + match self { + Dependency::Http(dep) => &dep.name, + Dependency::Git(dep) => &dep.name, } } - let contents = read_file_to_string(&filename); - // reading the contents into a data structure using toml::from_str - let data: Data = match toml::from_str(&contents) { - Ok(d) => d, - Err(_) => { - return Err(ConfigError { - cause: format!("Could not read the config file {}", filename), - }); + pub fn version(&self) -> &str { + match self { + Dependency::Http(dep) => &dep.version, + Dependency::Git(dep) => &dep.version, } - }; + } - let mut dependencies: Vec = Vec::new(); - let iterator = data.dependencies.iter(); - for (name, v) in iterator { - #[allow(clippy::needless_late_init)] - let url; - let version; - let mut rev = String::new(); - - // checks if the format is dependency = {version = "1.1.1" } - if v.get("version").is_some() { - // clear any string quotes added by mistake - version = v["version"].to_string().replace('"', ""); + pub fn url(&self) -> Option<&String> { + match self { + Dependency::Http(dep) => dep.url.as_ref(), + Dependency::Git(dep) => Some(&dep.git), + } + } + + pub fn to_toml_value(&self) -> (String, Item) { + match self { + Dependency::Http(dep) => ( + dep.name.clone(), + match &dep.url { + Some(url) => { + let mut table = InlineTable::new(); + table.insert( + "version", + value(&dep.version) + .into_value() + .expect("version should be a valid toml value"), + ); + table.insert( + "url", + value(url).into_value().expect("url should be a valid toml value"), + ); + value(table) + } + None => value(&dep.version), + }, + ), + Dependency::Git(dep) => ( + dep.name.clone(), + match &dep.rev { + Some(rev) => { + let mut table = InlineTable::new(); + table.insert( + "version", + value(&dep.version) + .into_value() + .expect("version should be a valid toml value"), + ); + table.insert( + "git", + value(&dep.git) + .into_value() + .expect("git URL should be a valid toml value"), + ); + table.insert( + "rev", + value(rev).into_value().expect("rev should be a valid toml value"), + ); + value(table) + } + None => { + let mut table = InlineTable::new(); + table.insert( + "version", + value(&dep.version) + .into_value() + .expect("version should be a valid toml value"), + ); + table.insert( + "git", + value(&dep.git) + .into_value() + .expect("git URL should be a valid toml value"), + ); + + value(table) + } + }, + ), + } + } + + pub fn as_http(&self) -> Option<&HttpDependency> { + if let Self::Http(v) = self { + Some(v) } else { - // checks if the format is dependency = "1.1.1" - version = String::from(v.as_str().unwrap()); - if version.is_empty() { - return Err(ConfigError { - cause: "Could not get the config correctly from the config file".to_string(), - }); - } + None } + } - if v.get("url").is_some() { - // clear any string quotes added by mistake - url = v["url"].to_string().replace('\"', ""); - } else if v.get("git").is_some() { - url = v["git"].to_string().replace('\"', ""); - if v.get("rev").is_some() { - rev = v["rev"].to_string().replace('\"', ""); - } + pub fn as_git(&self) -> Option<&GitDependency> { + if let Self::Git(v) = self { + Some(v) } else { - // we don't have a specified url, means we will rely on the remote server to give it to - // us - url = match get_dependency_url_remote(name, &version).await { - Ok(u) => u, - Err(_) => { - return Err(ConfigError { cause: "Could not get the url".to_string() }); - } - } + None } + } +} - dependencies.push(Dependency { name: name.to_string(), version, url, hash: rev }); +impl core::fmt::Display for Dependency { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + Dependency::Http(dep) => write!(f, "{}", dep), + Dependency::Git(dep) => write!(f, "{}", dep), + } } +} - Ok(dependencies) +impl From for Dependency { + fn from(dep: HttpDependency) -> Self { + Dependency::Http(dep) + } } -pub fn define_config_file() -> Result { - let mut filename: String; - if cfg!(test) { - filename = - env::var("config_file").unwrap_or(String::from(FOUNDRY_CONFIG_FILE.to_str().unwrap())) +impl From for Dependency { + fn from(dep: GitDependency) -> Self { + Dependency::Git(dep) + } +} + +pub fn get_config_path() -> Result { + let foundry_path: PathBuf = if cfg!(test) { + env::var("config_file").map(|s| s.into()).unwrap_or(FOUNDRY_CONFIG_FILE.clone()) } else { - filename = String::from(FOUNDRY_CONFIG_FILE.to_str().unwrap()); + FOUNDRY_CONFIG_FILE.clone() }; - // check if the foundry.toml has the dependencies defined, if so then we setup the foundry.toml - // as the config file - if fs::metadata(&filename).is_ok() { - return Ok(filename); + if let Ok(contents) = fs::read_to_string(&foundry_path) { + let doc: DocumentMut = contents.parse::()?; + if doc.contains_table("dependencies") { + return Ok(foundry_path); + } } - filename = String::from(SOLDEER_CONFIG_FILE.to_str().unwrap()); - match fs::metadata(&filename) { - Ok(_) => {} + let soldeer_path = SOLDEER_CONFIG_FILE.clone(); + match fs::metadata(&soldeer_path) { + Ok(_) => Ok(soldeer_path), Err(_) => { - println!("{}", Paint::blue("No config file found. If you wish to proceed, please select how you want Soldeer to be configured:\n1. Using foundry.toml\n2. Using soldeer.toml\n(Press 1 or 2), default is foundry.toml")); + println!("{}", "No config file found. If you wish to proceed, please select how you want Soldeer to be configured:\n1. Using foundry.toml\n2. Using soldeer.toml\n(Press 1 or 2), default is foundry.toml".blue()); std::io::stdout().flush().unwrap(); let mut option = String::new(); - if io::stdin().read_line(&mut option).is_err() { - return Err(ConfigError { cause: "Option invalid.".to_string() }); - } + io::stdin() + .read_line(&mut option) + .map_err(|e| ConfigError::PromptError { source: e })?; if option.is_empty() { option = "1".to_string(); } - return create_example_config(&option); + create_example_config(&option) } } +} + +pub fn read_config_deps(path: Option) -> Result> { + let path: PathBuf = match path { + Some(p) => p, + None => get_config_path()?, + }; + let contents = read_file_to_string(&path); + let doc: DocumentMut = contents.parse::()?; + let Some(Some(data)) = doc.get("dependencies").map(|v| v.as_table()) else { + return Err(ConfigError::MissingDependencies); + }; + + let mut dependencies: Vec = Vec::new(); + for (name, v) in data { + dependencies.push(parse_dependency(name, v)?); + } + Ok(dependencies) +} + +pub fn read_soldeer_config(path: Option) -> Result { + let path: PathBuf = match path { + Some(p) => p, + None => get_config_path()?, + }; + let contents = read_file_to_string(&path); + + #[derive(Deserialize)] + struct SoldeerConfigParsed { + #[serde(default)] + soldeer: SoldeerConfig, + } + let config: SoldeerConfigParsed = toml_edit::de::from_str(&contents)?; - Ok(filename) + Ok(config.soldeer) } -pub fn add_to_config( - dependency: &Dependency, - custom_url: bool, - config_file: &str, - via_git: bool, -) -> Result<(), ConfigError> { +pub fn add_to_config(dependency: &Dependency, config_path: impl AsRef) -> Result<()> { println!( "{}", - Paint::green(&format!( + format!( "Adding dependency {}-{} to the config file", - dependency.name, dependency.version - )) + dependency.name(), + dependency.version() + ) + .green() ); - let contents = read_file_to_string(config_file); - let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); + let contents = read_file_to_string(&config_path); + let mut doc: DocumentMut = contents.parse::()?; - // in case we don't have dependencies defined in the config file, we add it and re-read the doc + // in case we don't have the dependencies section defined in the config file, we add it if !doc.contains_table("dependencies") { - let mut file: std::fs::File = - fs::OpenOptions::new().append(true).open(config_file).unwrap(); - if let Err(e) = write!(file, "{}", String::from("\n[dependencies]\n")) { - eprintln!("Couldn't write to the config file: {}", e); - } - - doc = read_file_to_string(config_file).parse::().expect("invalid doc"); - } - let mut new_dependencies: String = String::new(); - - new_dependencies.push_str(&format!( - " \"{}~{}\" = \"{}\"\n", - dependency.name, dependency.version, dependency.url - )); - - let mut new_item: Item = Item::None; - if custom_url && !via_git { - new_item["version"] = value(dependency.version.clone()); - new_item["url"] = value(dependency.url.clone()); - } else if via_git { - new_item["version"] = value(dependency.version.clone()); - new_item["git"] = value(dependency.url.clone()); - new_item["rev"] = value(dependency.hash.clone()); - } else { - new_item = value(dependency.version.clone()) + doc.insert("dependencies", Item::Table(Table::default())); } + let (name, value) = dependency.to_toml_value(); doc["dependencies"] .as_table_mut() - .unwrap() - .insert(dependency.name.to_string().as_str(), new_item); - let mut file: std::fs::File = - fs::OpenOptions::new().write(true).append(false).truncate(true).open(config_file).unwrap(); - if let Err(e) = write!(file, "{}", doc) { - eprintln!("Couldn't write to the config file: {}", e); - } + .expect("dependencies should be a table") + .insert(&name, value); + + fs::write(config_path, doc.to_string())?; + Ok(()) } -pub async fn remappings() -> Result<(), ConfigError> { +#[derive(Debug, Clone, PartialEq)] +pub enum RemappingsAction { + Add(Dependency), + Remove(Dependency), + None, +} + +pub async fn remappings_txt( + dependency: &RemappingsAction, + soldeer_config: &SoldeerConfig, +) -> Result<()> { let remappings_path = get_current_working_dir().join("remappings.txt"); + if soldeer_config.remappings_regenerate { + remove_file(&remappings_path).map_err(ConfigError::RemappingsError)?; + } if !remappings_path.exists() { File::create(remappings_path.clone()).unwrap(); } - let contents = read_file_to_string(&remappings_path); - - let existing_remappings: Vec = contents.split('\n').map(|s| s.to_string()).collect(); - let mut new_remappings: String = String::new(); - let dependencies: Vec = match read_config(String::new()).await { - Ok(dep) => dep, - Err(err) => { - return Err(err); + let new_remappings = match dependency { + RemappingsAction::None => generate_remappings(dependency, soldeer_config, vec![])?, + _ => { + let contents = read_file_to_string(&remappings_path); + let existing_remappings = contents.lines().filter_map(|r| r.split_once('=')).collect(); + generate_remappings(dependency, soldeer_config, existing_remappings)? } }; - let mut existing_remap: Vec = Vec::new(); - existing_remappings.iter().for_each(|remapping| { - let split: Vec<&str> = remapping.split('=').collect::>(); - if split.len() == 1 { - // skip empty lines - return; - } - existing_remap.push(String::from(split[0])); - }); - - dependencies.iter().for_each(|dependency| { - let mut dependency_name_formatted = format!("{}-{}", &dependency.name, &dependency.version); - if !dependency_name_formatted.contains('@') { - dependency_name_formatted = format!("@{}", dependency_name_formatted); - } - let index = existing_remap.iter().position(|r| r == &dependency_name_formatted); - if index.is_none() { - println!( - "{}", - Paint::green(&format!( - "Added a new dependency to remappings {}", - &dependency_name_formatted - )) - ); - new_remappings.push_str(&format!( - "\n{}=dependencies/{}-{}", - &dependency_name_formatted, &dependency.name, &dependency.version - )); - } - }); - - if new_remappings.is_empty() { - remove_empty_lines("remappings.txt"); - return Ok(()); - } - - let mut file: std::fs::File = - fs::OpenOptions::new().append(true).open(Path::new("remappings.txt")).unwrap(); - - match write!(file, "{}", &new_remappings) { - Ok(_) => {} - Err(_) => { - println!("{}", Paint::yellow(&"Could not write to the remappings file".to_string())); - } + let mut file = File::create(remappings_path)?; + for remapping in new_remappings { + writeln!(file, "{}", remapping)?; } - remove_empty_lines("remappings.txt"); Ok(()) } -pub fn get_foundry_setup() -> Result, ConfigError> { - let filename = match define_config_file() { - Ok(file) => file, - Err(err) => { - return Err(err); - } +pub async fn remappings_foundry( + dependency: &RemappingsAction, + config_path: impl AsRef, + soldeer_config: &SoldeerConfig, +) -> Result<()> { + let contents = read_file_to_string(&config_path); + let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); + + let Some(profiles) = doc["profile"].as_table_mut() else { + // we don't add remappings if there are no profiles + return Ok(()); }; - if filename.contains("foundry.toml") { - return Ok(vec![true]); - } - let contents: String = read_file_to_string(&filename); - // reading the contents into a data structure using toml::from_str - let data: Foundry = match toml::from_str(&contents) { - Ok(d) => d, - Err(_) => { - println!( - "{}", - Paint::yellow(&"The remappings field not found in the soldeer.toml and no foundry config file found or the foundry.toml does not contain the `[dependencies]` field.\nThe foundry.toml file should contain the `[dependencies]` field if you want to use it as a config file. If you want to use the soldeer.toml file, please add the `[remappings]` field to it with the `enabled` key set to `true` or `false`.\nMore info on https://github.com/mario-eth/soldeer\nThe installation was successful but the remappings feature was skipped.".to_string()) - ); - return Ok(vec![false]); + for (name, profile) in profiles.iter_mut() { + // we normally only edit remappings of profiles which already have a remappings key + let Some(Some(remappings)) = profile.get_mut("remappings").map(|v| v.as_array_mut()) else { + // except the default profile, where we always add the remappings + if name == "default" { + let new_remappings = generate_remappings(dependency, soldeer_config, vec![])?; + let array = Array::from_iter(new_remappings.into_iter()); + profile["remappings"] = value(array); + } + continue; + }; + let existing_remappings: Vec<_> = remappings + .iter() + .filter_map(|r| r.as_str()) + .filter_map(|r| r.split_once('=')) + .collect(); + let new_remappings = generate_remappings(dependency, soldeer_config, existing_remappings)?; + remappings.clear(); + for remapping in new_remappings { + remappings.push(remapping); } - }; - if data.remappings.get("enabled").is_none() { - println!( - "{}", - Paint::yellow(&"The remappings field not found in the soldeer.toml and no foundry config file found or the foundry.toml does not contain the `[dependencies]` field.\nThe foundry.toml file should contain the `[dependencies]` field if you want to use it as a config file. If you want to use the soldeer.toml file, please add the `[remappings]` field to it with the `enabled` key set to `true` or `false`.\nMore info on https://github.com/mario-eth/soldeer\nThe installation was successful but the remappings feature was skipped.".to_string()) - ); - return Ok(vec![false]); } - Ok(vec![data.remappings.get("enabled").unwrap().as_bool().unwrap()]) + + fs::write(config_path, doc.to_string())?; + Ok(()) } -pub fn delete_config( - dependency_name: &String, - config_file: &str, -) -> Result { +pub fn delete_config(dependency_name: &str, path: impl AsRef) -> Result { println!( "{}", - Paint::green(&format!("Removing the dependency {} from the config file", dependency_name)) + format!("Removing the dependency {dependency_name} from the config file").green() ); - let contents = read_file_to_string(config_file); + let contents = read_file_to_string(&path); let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); - if !doc.contains_table("dependencies") { - return Err(ConfigError { - cause: format!("Could not read the config file {}", config_file), - }); - } - - let item_removed = doc["dependencies"].as_table_mut().unwrap().remove(dependency_name); + let Some(item_removed) = doc["dependencies"].as_table_mut().unwrap().remove(dependency_name) + else { + return Err(ConfigError::MissingDependency(dependency_name.to_string())); + }; - if item_removed.is_none() { - return Err(ConfigError { - cause: format!("The dependency {} does not exists in the config file", dependency_name), - }); - } + let dependency = parse_dependency(dependency_name, &item_removed)?; - let dependency = Dependency { - name: dependency_name.clone(), - version: item_removed - .unwrap() - .as_value() - .unwrap() - .to_string() - .replace("\"", "") - .trim() - .to_string(), - hash: "".to_string(), - url: "".to_string(), - }; + fs::write(path, doc.to_string())?; - let mut file: std::fs::File = - fs::OpenOptions::new().write(true).append(false).truncate(true).open(config_file).unwrap(); - if let Err(e) = write!(file, "{}", doc) { - return Err(ConfigError { cause: format!("Couldn't write to the config file {}", e) }); - } Ok(dependency) } -pub fn remove_forge_lib() -> Result<(), ConfigError> { +pub fn remove_forge_lib() -> Result<()> { let lib_dir = get_current_working_dir().join("lib/"); let gitmodules_file = get_current_working_dir().join(".gitmodules"); @@ -353,12 +425,155 @@ pub fn remove_forge_lib() -> Result<(), ConfigError> { Ok(()) } -fn create_example_config(option: &str) -> Result { - let config_file: &str; - let content: &str; - if option.trim() == "1" { - config_file = FOUNDRY_CONFIG_FILE.to_str().unwrap(); - content = r#" +fn parse_dependency(name: impl Into, value: &Item) -> Result { + let name: String = name.into(); + if let Some(version) = value.as_str() { + if version.is_empty() { + return Err(ConfigError::EmptyVersion(name)); + } + // this function does not retrieve the url + return Ok( + HttpDependency { name, version: version.to_string(), url: None, checksum: None }.into() + ); + } + + // we should have a table or inline table + let table = { + match value.as_inline_table() { + Some(table) => table, + None => match value.as_table() { + // we normalize to inline table + Some(table) => &table.clone().into_inline_table(), + None => { + return Err(ConfigError::InvalidDependency(name)); + } + }, + } + }; + + // version is needed in both cases + let version = match table.get("version").map(|v| v.as_str()) { + Some(None) => { + return Err(ConfigError::InvalidField { field: "version".to_string(), dep: name }); + } + None => { + return Err(ConfigError::MissingField { field: "version".to_string(), dep: name }); + } + Some(Some(version)) => version.to_string(), + }; + + // check if it's a git dependency + match table.get("git").map(|v| v.as_str()) { + Some(None) => { + return Err(ConfigError::InvalidField { field: "git".to_string(), dep: name }); + } + Some(Some(git)) => { + // rev field is optional but needs to be a string if present + let rev = match table.get("rev").map(|v| v.as_str()) { + Some(Some(rev)) => Some(rev.to_string()), + Some(None) => { + return Err(ConfigError::InvalidField { field: "rev".to_string(), dep: name }); + } + None => None, + }; + return Ok(Dependency::Git(GitDependency { + name: name.to_string(), + git: git.to_string(), + version, + rev, + })); + } + None => {} + } + + // we should have a HTTP dependency + match table.get("url").map(|v| v.as_str()) { + Some(None) => Err(ConfigError::InvalidField { field: "url".to_string(), dep: name }), + None => Err(ConfigError::MissingField { field: "url".to_string(), dep: name }), + Some(Some(url)) => Ok(Dependency::Http(HttpDependency { + name: name.to_string(), + version, + url: Some(url.to_string()), + checksum: None, + })), + } +} + +fn generate_remappings( + dependency: &RemappingsAction, + soldeer_config: &SoldeerConfig, + existing_remappings: Vec<(&str, &str)>, +) -> Result> { + let mut new_remappings = Vec::new(); + if soldeer_config.remappings_regenerate { + let dependencies = read_config_deps(None)?; + + dependencies.iter().for_each(|dependency| { + let dependency_name_formatted = format_remap_name(soldeer_config, dependency); + + println!("{}", format!("Adding {dependency} to remappings").green()); + new_remappings.push(format!( + "{dependency_name_formatted}=dependencies/{}-{}/", + dependency.name(), + dependency.version() + )); + }); + } else { + match &dependency { + RemappingsAction::Remove(remove_dep) => { + // only keep items not matching the dependency to remove + let remove_dep_orig = + format!("dependencies/{}-{}/", remove_dep.name(), remove_dep.version()); + for (remapped, orig) in existing_remappings { + if orig != remove_dep_orig { + new_remappings.push(format!("{}={}", remapped, orig)); + } else { + println!("{}", format!("Removed {remove_dep} from remappings").green()); + } + } + } + RemappingsAction::Add(add_dep) => { + // we only add the remapping if it's not already existing, otherwise we keep the old + // remapping + let new_dep_remapped = format_remap_name(soldeer_config, add_dep); + let new_dep_orig = + format!("dependencies/{}-{}/", add_dep.name(), add_dep.version()); + let mut found = false; // whether a remapping existed for that dep already + for (remapped, orig) in existing_remappings { + new_remappings.push(format!("{}={}", remapped, orig)); + if orig == new_dep_orig { + found = true; + } + } + if !found { + new_remappings.push(format!("{}={}", new_dep_remapped, new_dep_orig)); + println!("{}", format!("Added {add_dep} to remappings").green()); + } + } + RemappingsAction::None => { + for (remapped, orig) in existing_remappings { + new_remappings.push(format!("{}={}", remapped, orig)); + } + } + } + } + + // sort the remappings + new_remappings.sort_unstable(); + Ok(new_remappings) +} + +fn format_remap_name(soldeer_config: &SoldeerConfig, dependency: &Dependency) -> String { + let version_suffix = + if soldeer_config.remappings_version { &format!("-{}", dependency.version()) } else { "" }; + format!("{}{}{}/", soldeer_config.remappings_prefix, dependency.name(), version_suffix) +} + +fn create_example_config(option: &str) -> Result { + let (config_path, contents) = match option.trim() { + "1" => ( + FOUNDRY_CONFIG_FILE.clone(), + r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config [profile.default] @@ -369,29 +584,24 @@ test = "test" libs = ["dependencies"] [dependencies] -"#; - } else if option.trim() == "2" { - config_file = SOLDEER_CONFIG_FILE.to_str().unwrap(); - content = r#" +"#, + ), + "2" => ( + SOLDEER_CONFIG_FILE.clone(), + r#" [remappings] enabled = true [dependencies] -"#; - } else { - return Err(ConfigError { cause: "Option invalid".to_string() }); - } +"#, + ), + _ => { + return Err(ConfigError::InvalidPromptOption); + } + }; - std::fs::File::create(config_file).unwrap(); - let mut file: std::fs::File = fs::OpenOptions::new().write(true).open(config_file).unwrap(); - if write!(file, "{}", content).is_err() { - return Err(ConfigError { cause: "Could not create a new config file".to_string() }); - } - let mut filename = String::from(FOUNDRY_CONFIG_FILE.to_str().unwrap()); - if option.trim() == "2" { - filename = String::from(SOLDEER_CONFIG_FILE.to_str().unwrap()); - } - Ok(filename) + fs::write(&config_path, contents)?; + Ok(config_path) } ////////////// TESTS ////////////// @@ -403,17 +613,14 @@ mod tests { use rand::{distributions::Alphanumeric, Rng}; use serial_test::serial; use std::{ - env, - fs::{ - remove_file, {self}, - }, + fs::{self, remove_file}, io::Write, path::PathBuf, }; #[tokio::test] // check dependencies as {version = "1.1.1"} #[serial] - async fn read_foundry_config_version_v1_ok() -> Result<(), ConfigError> { + async fn read_foundry_config_version_v1_ok() -> Result<()> { let config_contents = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -428,46 +635,26 @@ libs = ["dependencies"] write_to_config(&target_config, config_contents); - ////////////// MOCK ////////////// - // Request a new server from the pool, TODO i tried to move this into a fn but the mock is - // dropped at the end of the function... - let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); - - let _ = server - .mock("GET", mockito::Matcher::Regex(r"^/api/v1/revision-cli.*".to_string())) - .with_status(201) - .with_header("content-type", "application/json") - .with_body(get_return_data()) - .create(); - - ////////////// END-MOCK ////////////// - - let result = match read_config(String::from(target_config.to_str().unwrap())).await { - Ok(dep) => dep, - Err(err) => { - return Err(err); - } - }; + let result = read_config_deps(Some(target_config.clone()))?; assert_eq!( result[0], - Dependency { + Dependency::Http(HttpDependency { name: "@gearbox-protocol-periphery-v3".to_string(), version: "1.6.1".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); assert_eq!( result[1], - Dependency { + Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "5.0.2".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); let _ = remove_file(target_config); Ok(()) @@ -475,7 +662,7 @@ libs = ["dependencies"] #[tokio::test] // check dependencies as "1.1.1" #[serial] - async fn read_foundry_config_version_v2_ok() -> Result<(), ConfigError> { + async fn read_foundry_config_version_v2_ok() -> Result<()> { let config_contents = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -490,46 +677,26 @@ libs = ["dependencies"] write_to_config(&target_config, config_contents); - ////////////// MOCK ////////////// - // Request a new server from the pool, TODO i tried to move this into a fn but the mock is - // dropped at the end of the function... - let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); - - let _ = server - .mock("GET", mockito::Matcher::Regex(r"^/api/v1/revision-cli.*".to_string())) - .with_status(201) - .with_header("content-type", "application/json") - .with_body(get_return_data()) - .create(); - - ////////////// END-MOCK ////////////// - - let result = match read_config(String::from(target_config.to_str().unwrap())).await { - Ok(dep) => dep, - Err(err) => { - return Err(err); - } - }; + let result = read_config_deps(Some(target_config.clone()))?; assert_eq!( result[0], - Dependency { + Dependency::Http(HttpDependency { name: "@gearbox-protocol-periphery-v3".to_string(), version: "1.6.1".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); assert_eq!( result[1], - Dependency { + Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "5.0.2".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); let _ = remove_file(target_config); Ok(()) @@ -537,7 +704,7 @@ libs = ["dependencies"] #[tokio::test] // check dependencies as "1.1.1" #[serial] - async fn read_soldeer_config_version_v1_ok() -> Result<(), ConfigError> { + async fn read_soldeer_config_version_v1_ok() -> Result<()> { let config_contents = r#" [remappings] enabled = true @@ -550,46 +717,26 @@ enabled = true write_to_config(&target_config, config_contents); - ////////////// MOCK ////////////// - // Request a new server from the pool, TODO i tried to move this into a fn but the mock is - // dropped at the end of the function... - let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); - - let _ = server - .mock("GET", mockito::Matcher::Regex(r"^/api/v1/revision-cli.*".to_string())) - .with_status(201) - .with_header("content-type", "application/json") - .with_body(get_return_data()) - .create(); - - ////////////// END-MOCK ////////////// - - let result = match read_config(String::from(target_config.to_str().unwrap())).await { - Ok(dep) => dep, - Err(err) => { - return Err(err); - } - }; + let result = read_config_deps(Some(target_config.clone()))?; assert_eq!( result[0], - Dependency { + Dependency::Http(HttpDependency { name: "@gearbox-protocol-periphery-v3".to_string(), version: "1.6.1".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); assert_eq!( result[1], - Dependency { + Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "5.0.2".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); let _ = remove_file(target_config); Ok(()) @@ -597,7 +744,7 @@ enabled = true #[tokio::test] // check dependencies as "1.1.1" #[serial] - async fn read_soldeer_config_version_v2_ok() -> Result<(), ConfigError> { + async fn read_soldeer_config_version_v2_ok() -> Result<()> { let config_contents = r#" [remappings] enabled = true @@ -610,46 +757,26 @@ enabled = true write_to_config(&target_config, config_contents); - ////////////// MOCK ////////////// - // Request a new server from the pool, TODO i tried to move this into a fn but the mock is - // dropped at the end of the function... - let mut server = mockito::Server::new_async().await; - env::set_var("base_url", format!("http://{}", server.host_with_port())); - - let _ = server - .mock("GET", mockito::Matcher::Regex(r"^/api/v1/revision-cli.*".to_string())) - .with_status(201) - .with_header("content-type", "application/json") - .with_body(get_return_data()) - .create(); - - ////////////// END-MOCK ////////////// - - let result = match read_config(String::from(target_config.to_str().unwrap())).await { - Ok(dep) => dep, - Err(err) => { - return Err(err); - } - }; + let result = read_config_deps(Some(target_config.clone()))?; assert_eq!( result[0], - Dependency { + Dependency::Http(HttpDependency { name: "@gearbox-protocol-periphery-v3".to_string(), version: "1.6.1".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); assert_eq!( result[1], - Dependency { + Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "5.0.2".to_string(), - url: "https://example_url.com/example_url.zip".to_string(), - hash: String::new() - } + url: None, + checksum: None + }) ); let _ = remove_file(target_config); Ok(()) @@ -657,7 +784,7 @@ enabled = true #[tokio::test] #[serial] - async fn read_malformed_config_incorrect_version_string_fails() -> Result<(), ConfigError> { + async fn read_malformed_config_incorrect_version_string_fails() -> Result<()> { let config_contents = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -671,29 +798,17 @@ libs = ["dependencies"] write_to_config(&target_config, config_contents); - match read_config(String::from(target_config.clone().to_str().unwrap())).await { - Ok(_) => { - assert_eq!("False state", ""); - } - Err(err) => { - assert_eq!( - err, - ConfigError { - cause: format!( - "Could not read the config file {}", - target_config.to_str().unwrap() - ), - } - ) - } - }; + assert!(matches!( + read_config_deps(Some(target_config.clone())), + Err(ConfigError::Parsing(_)) + )); let _ = remove_file(target_config); Ok(()) } #[tokio::test] #[serial] - async fn read_malformed_config_empty_version_string_fails() -> Result<(), ConfigError> { + async fn read_malformed_config_empty_version_string_fails() -> Result<()> { let config_contents = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -707,55 +822,16 @@ libs = ["dependencies"] write_to_config(&target_config, config_contents); - match read_config(String::from(target_config.clone().to_str().unwrap())).await { - Ok(_) => { - assert_eq!("False state", ""); - } - Err(err) => { - assert_eq!( - err, - ConfigError { - cause: "Could not get the config correctly from the config file" - .to_string(), - } - ) - } - }; - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn read_dependency_url_call_fails() -> Result<(), ConfigError> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - match read_config(String::from(target_config.clone().to_str().unwrap())).await { - Ok(_) => { - assert_eq!("False state", ""); - } - Err(err) => { - assert_eq!(err, ConfigError { cause: "Could not get the url".to_string() }) - } - }; + assert!(matches!( + read_config_deps(Some(target_config.clone())), + Err(ConfigError::EmptyVersion(_)) + )); let _ = remove_file(target_config); - Ok(()) } #[test] - fn define_config_file_choses_foundry() -> Result<(), ConfigError> { + fn define_config_file_choses_foundry() -> Result<()> { let config_contents = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -775,7 +851,7 @@ libs = ["dependencies"] #[tokio::test] #[serial] - async fn define_config_file_choses_soldeer() -> Result<(), ConfigError> { + async fn define_config_file_choses_soldeer() -> Result<()> { let config_contents = r#" [dependencies] "#; @@ -789,7 +865,7 @@ libs = ["dependencies"] } #[test] - fn create_new_file_if_not_defined_foundry() -> Result<(), ConfigError> { + fn create_new_file_if_not_defined_foundry() -> Result<()> { let content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -811,7 +887,7 @@ libs = ["dependencies"] } #[test] - fn create_new_file_if_not_defined_soldeer() -> Result<(), ConfigError> { + fn create_new_file_if_not_defined_soldeer() -> Result<()> { let content = r#" [remappings] enabled = true @@ -827,7 +903,7 @@ enabled = true } #[test] - fn add_to_config_foundry_no_custom_url_first_dependency() -> Result<(), ConfigError> { + fn add_to_config_foundry_no_custom_url_first_dependency() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -844,13 +920,13 @@ libs = ["dependencies"] let target_config = define_config(true); write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; - add_to_config(&dependency, false, target_config.to_str().unwrap(), false).unwrap(); + url: None, + checksum: None, + }); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -872,7 +948,7 @@ dep1 = "1.0.0" } #[test] - fn add_to_config_foundry_with_custom_url_first_dependency() -> Result<(), ConfigError> { + fn add_to_config_foundry_with_custom_url_first_dependency() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -890,14 +966,14 @@ libs = ["dependencies"] write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: Some("http://custom_url.com/custom.zip".to_string()), + checksum: None, + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -919,7 +995,7 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn add_to_config_foundry_no_custom_url_second_dependency() -> Result<(), ConfigError> { + fn add_to_config_foundry_no_custom_url_second_dependency() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -938,14 +1014,14 @@ old_dep = "5.1.0-my-version-is-cool" write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: None, + checksum: None, + }); - add_to_config(&dependency, false, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -968,7 +1044,7 @@ dep1 = "1.0.0" } #[test] - fn add_to_config_foundry_with_custom_url_second_dependency() -> Result<(), ConfigError> { + fn add_to_config_foundry_with_custom_url_second_dependency() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -987,14 +1063,14 @@ old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/c write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: Some("http://custom_url.com/custom.zip".to_string()), + checksum: None, + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1017,7 +1093,7 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn add_to_config_foundry_update_dependency_version() -> Result<(), ConfigError> { + fn add_to_config_foundry_update_dependency_version() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1036,14 +1112,14 @@ old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/c write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "old_dep".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: Some("http://custom_url.com/custom.zip".to_string()), + checksum: None, + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1065,7 +1141,7 @@ old_dep = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn add_to_config_foundry_update_dependency_version_no_custom_url() -> Result<(), ConfigError> { + fn add_to_config_foundry_update_dependency_version_no_custom_url() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1084,14 +1160,14 @@ old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/c write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "old_dep".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: None, + checksum: None, + }); - add_to_config(&dependency, false, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1113,7 +1189,7 @@ old_dep = "1.0.0" } #[test] - fn add_to_config_foundry_not_altering_the_existing_contents() -> Result<(), ConfigError> { + fn add_to_config_foundry_not_altering_the_existing_contents() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1132,14 +1208,14 @@ gas_reports = ['*'] write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: None, + checksum: None, + }); - add_to_config(&dependency, false, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1151,10 +1227,10 @@ test = "test" libs = ["dependencies"] gas_reports = ['*'] -# we don't have [dependencies] declared - [dependencies] dep1 = "1.0.0" + +# we don't have [dependencies] declared "#; assert_eq!(read_file_to_string(&target_config), content); @@ -1164,7 +1240,7 @@ dep1 = "1.0.0" } #[test] - fn add_to_config_soldeer_no_custom_url_first_dependency() -> Result<(), ConfigError> { + fn add_to_config_soldeer_no_custom_url_first_dependency() -> Result<()> { let mut content = r#" [remappings] enabled = true @@ -1176,14 +1252,14 @@ enabled = true write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: None, + checksum: None, + }); - add_to_config(&dependency, false, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" [remappings] enabled = true @@ -1199,7 +1275,7 @@ dep1 = "1.0.0" } #[test] - fn add_to_config_soldeer_with_custom_url_first_dependency() -> Result<(), ConfigError> { + fn add_to_config_soldeer_with_custom_url_first_dependency() -> Result<()> { let mut content = r#" [remappings] enabled = true @@ -1211,14 +1287,14 @@ enabled = true write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: Some("http://custom_url.com/custom.zip".to_string()), + checksum: None, + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" [remappings] enabled = true @@ -1234,7 +1310,7 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn add_to_config_foundry_github_with_commit() -> Result<(), ConfigError> { + fn add_to_config_foundry_github_with_commit() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1253,14 +1329,14 @@ gas_reports = ['*'] write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "git@github.com:foundry-rs/forge-std.git".to_string(), - hash: "07263d193d621c4b2b0ce8b4d54af58f6957d97d".to_string(), - }; + git: "git@github.com:foundry-rs/forge-std.git".to_string(), + rev: Some("07263d193d621c4b2b0ce8b4d54af58f6957d97d".to_string()), + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), true).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1272,10 +1348,10 @@ test = "test" libs = ["dependencies"] gas_reports = ['*'] -# we don't have [dependencies] declared - [dependencies] dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev = "07263d193d621c4b2b0ce8b4d54af58f6957d97d" } + +# we don't have [dependencies] declared "#; assert_eq!(read_file_to_string(&target_config), content); @@ -1285,8 +1361,7 @@ dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev } #[test] - fn add_to_config_foundry_github_previous_no_commit_then_with_commit() -> Result<(), ConfigError> - { + fn add_to_config_foundry_github_previous_no_commit_then_with_commit() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1308,14 +1383,14 @@ dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git" } write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "git@github.com:foundry-rs/forge-std.git".to_string(), - hash: "07263d193d621c4b2b0ce8b4d54af58f6957d97d".to_string(), - }; + git: "git@github.com:foundry-rs/forge-std.git".to_string(), + rev: Some("07263d193d621c4b2b0ce8b4d54af58f6957d97d".to_string()), + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), true).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1340,7 +1415,7 @@ dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev } #[test] - fn add_to_config_foundry_github_previous_commit_then_no_commit() -> Result<(), ConfigError> { + fn add_to_config_foundry_github_previous_commit_then_no_commit() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1362,14 +1437,14 @@ dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev write_to_config(&target_config, content); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "dep1".to_string(), version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; + url: Some("http://custom_url.com/custom.zip".to_string()), + checksum: None, + }); - add_to_config(&dependency, true, target_config.to_str().unwrap(), false).unwrap(); + add_to_config(&dependency, &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1394,7 +1469,7 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn remove_from_the_config_single() -> Result<(), ConfigError> { + fn remove_from_the_config_single() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1413,14 +1488,7 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } write_to_config(&target_config, content); - let dependency = Dependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; - - delete_config(&dependency.name, target_config.to_str().unwrap()).unwrap(); + delete_config("dep1", &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1441,7 +1509,7 @@ libs = ["dependencies"] } #[test] - fn remove_from_the_config_multiple() -> Result<(), ConfigError> { + fn remove_from_the_config_multiple() -> Result<()> { let mut content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1462,14 +1530,7 @@ dep2 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } write_to_config(&target_config, content); - let dependency = Dependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: "http://custom_url.com/custom.zip".to_string(), - hash: String::new(), - }; - - delete_config(&dependency.name, target_config.to_str().unwrap()).unwrap(); + delete_config("dep1", &target_config).unwrap(); content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1492,7 +1553,7 @@ dep2 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } } #[test] - fn remove_config_nonexistent_fails() -> Result<(), ConfigError> { + fn remove_config_nonexistent_fails() -> Result<()> { let content = r#" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config @@ -1511,20 +1572,573 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } write_to_config(&target_config, content); - match delete_config(&"dep2".to_string(), target_config.to_str().unwrap()) { - Ok(_) => { - assert_eq!("Invalid State", ""); - } - Err(err) => { - assert_eq!( - err, - ConfigError { - cause: "The dependency dep2 does not exists in the config file".to_string() - } - ) + assert!(matches!( + delete_config("dep2", &target_config), + Err(ConfigError::MissingDependency(_)) + )); + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_all_set() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_generate = true +remappings_prefix = "@" +remappings_regenerate = true +remappings_version = true +remappings_location = "config" +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() + } + }; + let _ = remove_file(target_config); + assert!(sc.remappings_prefix == *"@"); + assert!(sc.remappings_generate); + assert!(sc.remappings_regenerate); + assert!(sc.remappings_version); + assert_eq!(sc.remappings_location, RemappingsLocation::Config); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_generate_remappings() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_generate = true +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() + } + }; + let _ = remove_file(target_config); + assert!(sc.remappings_generate); + assert!(sc.remappings_prefix.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_append_at_in_remappings() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_prefix = "@" +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() + } + }; + let _ = remove_file(target_config); + assert!(sc.remappings_prefix == *"@"); + assert!(sc.remappings_generate); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_reg_remappings() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_regenerate = true +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() + } + }; + let _ = remove_file(target_config); + assert!(sc.remappings_regenerate); + assert!(sc.remappings_generate); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_remappings_version() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_version = true +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() } + }; + let _ = remove_file(target_config); + assert!(sc.remappings_version); + assert!(sc.remappings_generate); + Ok(()) + } + + #[tokio::test] + async fn read_soldeer_configs_remappings_location() -> Result<()> { + let config_contents = r#" +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config +[profile.default] +libs = ["dependencies"] +[dependencies] +"@gearbox-protocol-periphery-v3" = "1.1.1" +[soldeer] +remappings_location = "config" +"#; + let target_config = define_config(false); + + write_to_config(&target_config, config_contents); + + let sc = match read_soldeer_config(Some(target_config.clone())) { + Ok(sc) => sc, + Err(_) => { + assert_eq!("False state", ""); + SoldeerConfig::default() + } + }; + let _ = remove_file(target_config); + assert_eq!(sc.remappings_location, RemappingsLocation::Config); + assert!(sc.remappings_generate); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_with_prefix_and_version_in_config() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[dependencies] +[soldeer] +remappings_prefix = "@" +remappings_version = true +remappings_location = "config" +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = + remappings_foundry(&RemappingsAction::Add(dependency), &target_config, &soldeer_config) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["@dep1-1.0.0/=dependencies/dep1-1.0.0/"] +[dependencies] +[soldeer] +remappings_prefix = "@" +remappings_version = true +remappings_location = "config" +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_no_prefix_and_no_version_in_config() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[dependencies] +[soldeer] +remappings_generate = true +remappings_version = false +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::Add(dependency), + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep1/=dependencies/dep1-1.0.0/"] +[dependencies] +[soldeer] +remappings_generate = true +remappings_version = false +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn generate_remappings_prefix_and_version_in_txt() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[dependencies] +[soldeer] +remappings_generate = true +remappings_prefix = "@" +remappings_version = true +"#; + + let target_config = define_config(true); + let txt = get_current_working_dir().join("remappings.txt"); + let _ = remove_file(&txt); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_txt(&RemappingsAction::Add(dependency), &soldeer_config).await; + + content = "@dep1-1.0.0/=dependencies/dep1-1.0.0/\n"; + + assert_eq!(read_file_to_string(&txt), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn generate_remappings_no_prefix_and_no_version_in_txt() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[dependencies] +[soldeer] +remappings_generate = true +remappings_version = false +"#; + + let target_config = define_config(true); + let txt = get_current_working_dir().join("remappings.txt"); + let _ = remove_file(&txt); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_txt(&RemappingsAction::Add(dependency), &soldeer_config).await; + + content = "dep1/=dependencies/dep1-1.0.0/\n"; + + assert_eq!(read_file_to_string(&txt), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_in_config_only_default_profile() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[profile.local.testing] +ffi = true +[dependencies] +[soldeer] +remappings_generate = true +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::Add(dependency), + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] +[profile.local.testing] +ffi = true +[dependencies] +[soldeer] +remappings_generate = true +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_in_config_all_profiles() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +[profile.local] +remappings = [] +[profile.local.testing] +ffi = true +[dependencies] +[soldeer] +remappings_generate = true +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::Add(dependency), + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] +[profile.local] +remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] +[profile.local.testing] +ffi = true +[dependencies] +[soldeer] +remappings_generate = true +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_in_config_existing() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep2-1.0.0/=dependencies/dep2-1.0.0/"] +[dependencies] +[soldeer] +remappings_generate = true +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + let dependency = Dependency::Http(HttpDependency { + name: "dep1".to_string(), + version: "1.0.0".to_string(), + url: None, + checksum: None, + }); + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::Add(dependency), + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/", "dep2-1.0.0/=dependencies/dep2-1.0.0/"] +[dependencies] +[soldeer] +remappings_generate = true +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn generate_remappings_regenerate() -> Result<()> { + let mut content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["@dep2-custom/=dependencies/dep2-1.0.0/"] +[dependencies] +dep2 = "1.0.0" +[soldeer] +remappings_generate = true +remappings_regenerate = true +"#; + + let target_config = define_config(true); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("config_file", target_config.to_string_lossy().to_string()); } + write_to_config(&target_config, content); + + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::None, + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + + content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["dep2-1.0.0/=dependencies/dep2-1.0.0/"] +[dependencies] +dep2 = "1.0.0" +[soldeer] +remappings_generate = true +remappings_regenerate = true +"#; + + assert_eq!(read_file_to_string(&target_config), content); + + let _ = remove_file(target_config); + Ok(()) + } + + #[tokio::test] + async fn generate_remappings_keep_custom() -> Result<()> { + let content = r#" +[profile.default] +solc = "0.8.26" +libs = ["dependencies"] +remappings = ["@dep2-custom/=dependencies/dep2-1.0.0/"] +[dependencies] +dep2 = "1.0.0" +[soldeer] +remappings_generate = true +"#; + + let target_config = define_config(true); + + write_to_config(&target_config, content); + + let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); + let _ = remappings_foundry( + &RemappingsAction::None, + target_config.to_str().unwrap(), + &soldeer_config, + ) + .await; + assert_eq!(read_file_to_string(&target_config), content); let _ = remove_file(target_config); @@ -1551,9 +2165,11 @@ dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } if !foundry { target = format!("soldeer{}.toml", s); } + get_current_working_dir().join("test").join(target) } + #[allow(unused)] fn get_return_data() -> String { r#" { diff --git a/src/dependency_downloader.rs b/src/dependency_downloader.rs index 657c804..140f7ba 100644 --- a/src/dependency_downloader.rs +++ b/src/dependency_downloader.rs @@ -1,318 +1,255 @@ use crate::{ - config::Dependency, - errors::{DependencyError, DownloadError, UnzippingError}, - utils::{get_download_tunnel, read_file, sha256_digest}, + config::{Dependency, GitDependency, HttpDependency}, + errors::DownloadError, + remote::get_dependency_url_remote, + utils::{read_file, sha256_digest}, DEPENDENCY_DIR, }; -use futures::StreamExt; +use reqwest::IntoUrl; use std::{ - error::Error, fs, - fs::remove_dir_all, io::Cursor, path::{Path, PathBuf}, process::{Command, Stdio}, str, }; -use tokio::{fs::File, io::AsyncWriteExt}; -use yansi::Paint; +use tokio::{fs as tokio_fs, io::AsyncWriteExt, task::JoinSet}; +use yansi::Paint as _; + +pub type Result = std::result::Result; pub async fn download_dependencies( dependencies: &[Dependency], clean: bool, -) -> Result, DownloadError> { +) -> Result> { // clean dependencies folder if flag is true if clean { + // creates the directory clean_dependency_directory(); } - // downloading dependencies to dependencies folder - let hashes: Vec = futures::future::join_all( - dependencies.iter().map(|dep| async { download_dependency(&dep.clone()).await }), - ) - .await - .into_iter() - .collect::, DownloadError>>()?; - Ok(hashes) + // create the dependency directory if it doesn't exist + let dir = DEPENDENCY_DIR.clone(); + if tokio_fs::metadata(&dir).await.is_err() { + tokio_fs::create_dir(&dir) + .await + .map_err(|e| DownloadError::IOError { path: dir, source: e })?; + } + + let mut set = JoinSet::new(); + for dep in dependencies { + set.spawn({ + let dep = dep.clone(); + async move { download_dependency(&dep, true).await } + }); + } + + let mut results = Vec::::new(); + while let Some(res) = set.join_next().await { + results.push(res??); + } + + Ok(results) } // un-zip-ing dependencies to dependencies folder -pub fn unzip_dependencies(dependencies: &[Dependency]) -> Result<(), UnzippingError> { - for dependency in dependencies.iter() { - let via_http = get_download_tunnel(&dependency.url) != "git"; - if via_http { - match unzip_dependency(&dependency.name, &dependency.version) { - Ok(_) => {} - Err(err) => { - return Err(err); - } - } - } - } +pub fn unzip_dependencies(dependencies: &[Dependency]) -> Result<()> { + dependencies + .iter() + .filter_map(|d| match d { + Dependency::Http(dep) => Some(dep), + _ => None, + }) + .try_for_each(unzip_dependency)?; Ok(()) } -pub async fn download_dependency(dependency: &Dependency) -> Result { - let dependency_directory: PathBuf = DEPENDENCY_DIR.clone(); - if !DEPENDENCY_DIR.is_dir() { - fs::create_dir(&dependency_directory).unwrap(); - } +#[derive(Debug, Clone)] +pub struct DownloadResult { + pub hash: String, + pub url: String, +} - let tunnel = get_download_tunnel(&dependency.url); - let hash: String; - if tunnel == "http" { - match download_via_http(dependency, &dependency_directory).await { - Ok(_) => {} - Err(err) => { - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - cause: err.cause, - }); +pub async fn download_dependency( + dependency: &Dependency, + skip_folder_check: bool, +) -> Result { + let dependency_directory: PathBuf = DEPENDENCY_DIR.clone(); + // if we called this method from `download_dependencies` we don't need to check if the folder + // exists, as it was created by the caller + if !skip_folder_check && tokio_fs::metadata(&dependency_directory).await.is_err() { + if let Err(e) = tokio_fs::create_dir(&dependency_directory).await { + // temp fix for race condition until we use tokio fs everywhere + if tokio_fs::metadata(&dependency_directory).await.is_err() { + return Err(DownloadError::IOError { path: dependency_directory, source: e }); } } - hash = sha256_digest(&dependency.name, &dependency.version); - } else if tunnel == "git" { - hash = match download_via_git(dependency, &dependency_directory).await { - Ok(h) => h, - Err(err) => { - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - cause: err.cause, - }); - } - }; - } else { - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - cause: "Download tunnel unknown".to_string(), - }); } + + let res = match dependency { + Dependency::Http(dep) => { + let url = match &dep.url { + Some(url) => url.clone(), + None => get_dependency_url_remote(dependency).await?, + }; + download_via_http(&url, dep, &dependency_directory).await?; + DownloadResult { hash: sha256_digest(dep), url } + } + Dependency::Git(dep) => { + let hash = download_via_git(dep, &dependency_directory).await?; + DownloadResult { hash, url: dep.git.clone() } + } + }; println!( "{}", - Paint::green(&format!("Dependency {}-{} downloaded!", dependency.name, dependency.version)) + format!("Dependency {}-{} downloaded!", dependency.name(), dependency.version()).green() ); - Ok(hash) + Ok(res) } -pub fn unzip_dependency( - dependency_name: &String, - dependency_version: &String, -) -> Result<(), UnzippingError> { - let file_name = format!("{}-{}.zip", dependency_name, dependency_version); - let target_name = format!("{}-{}/", dependency_name, dependency_version); +pub fn unzip_dependency(dependency: &HttpDependency) -> Result<()> { + let file_name = format!("{}-{}.zip", dependency.name, dependency.version); + let target_name = format!("{}-{}/", dependency.name, dependency.version); let current_dir = DEPENDENCY_DIR.join(file_name); let target = DEPENDENCY_DIR.join(target_name); let archive = read_file(current_dir).unwrap(); - match zip_extract::extract(Cursor::new(archive), &target, true) { - Ok(_) => {} - Err(_) => { - return Err(UnzippingError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - }); - } - } - println!( - "{}", - Paint::green(&format!( - "The dependency {}-{} was unzipped!", - dependency_name, dependency_version - )) - ); + zip_extract::extract(Cursor::new(archive), &target, true)?; + println!("{}", format!("The dependency {dependency} was unzipped!").green()); Ok(()) } pub fn clean_dependency_directory() { - if DEPENDENCY_DIR.is_dir() { + if fs::metadata(DEPENDENCY_DIR.clone()).is_ok() { fs::remove_dir_all(DEPENDENCY_DIR.clone()).unwrap(); fs::create_dir(DEPENDENCY_DIR.clone()).unwrap(); } } async fn download_via_git( - dependency: &Dependency, + dependency: &GitDependency, dependency_directory: &Path, -) -> Result { +) -> Result { + println!("{}", format!("Started git download of {dependency}").green()); let target_dir = &format!("{}-{}", dependency.name, dependency.version); let path = dependency_directory.join(target_dir); - let dependency_path = path.as_os_str().to_str().unwrap(); + let path_str = path.to_string_lossy().to_string(); if path.exists() { - let _ = remove_dir_all(&path); + let _ = fs::remove_dir_all(&path); } - let http_url: String = transform_git_to_http(&dependency.url); + let http_url = transform_git_to_http(&dependency.git); let mut git_clone = Command::new("git"); let mut git_checkout = Command::new("git"); let mut git_get_commit = Command::new("git"); let result = git_clone - .args(["clone", http_url.as_str(), dependency_path]) + .args(["clone", &http_url, &path_str]) .env("GIT_TERMINAL_PROMPT", "0") .stdout(Stdio::piped()) .stderr(Stdio::piped()); let status = result.status().unwrap(); - - let mut success = status.success(); let out = result.output().unwrap(); - let mut message = String::new(); - let hash: String; - if !success { - message = format!( - "Could not clone the repository: {}", - str::from_utf8(&out.stderr).unwrap().trim() - ); - } - if !dependency.hash.is_empty() && success { - let result = git_get_commit - .args([ - format!("--work-tree={}", dependency_path), - format!("--git-dir={}", path.join(".git").as_os_str().to_str().unwrap()), - "checkout".to_string(), - dependency.hash.clone(), - ]) - .env("GIT_TERMINAL_PROMPT", "0") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - hash = dependency.hash.clone(); - - let out = result.output().unwrap(); - let status = result.status().unwrap(); - success = status.success(); - - if !success { - message = format!( - "Could not change the revision: {}", - str::from_utf8(&out.stderr).unwrap().trim() - ); - } - } else if success { - let result = git_checkout - .args([ - format!("--work-tree={}", dependency_path), - format!("--git-dir={}", path.join(".git").as_os_str().to_str().unwrap()), - "rev-parse".to_string(), - "--verify".to_string(), - "HEAD".to_string(), - ]) - .env("GIT_TERMINAL_PROMPT", "0") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let out = result.output().unwrap(); - let status = result.status().unwrap(); - success = status.success(); - if !success { - message = format!( - "Could not get the revision hash: {}", - str::from_utf8(&out.stderr).unwrap().trim() - ); - } - - hash = str::from_utf8(&out.stdout).unwrap().trim().to_string(); - // check the commit integrity - if !hash.is_empty() && hash.len() != 40 { - message = "Could not get the revision hash, invalid hash".to_string(); + if !status.success() { + let _ = fs::remove_dir_all(&path); + return Err(DownloadError::GitError( + str::from_utf8(&out.stderr).unwrap().trim().to_string(), + )); + } + + let rev = match dependency.rev.clone() { + Some(rev) => { + let result = git_get_commit + .args([ + format!("--work-tree={}", path_str), + format!("--git-dir={}", path.join(".git").to_string_lossy()), + "checkout".to_string(), + rev.to_string(), + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let out = result.output().unwrap(); + let status = result.status().unwrap(); + + if !status.success() { + let _ = fs::remove_dir_all(&path); + return Err(DownloadError::GitError( + str::from_utf8(&out.stderr).unwrap().trim().to_string(), + )); + } + rev } - } else { - // just abort and return empty hash - hash = String::new(); - } - - if success { - println!( - "{}", - Paint::green(&format!( - "Successfully downloaded {}~{} the dependency via git", - dependency.name.clone(), - dependency.version.clone(), - )) - ); - } else { - let _ = remove_dir_all(&path); - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - cause: format!( - "Dependency {}~{} could not be downloaded via git.\nCause: {}", - dependency.name.clone(), - dependency.version.clone(), - message - ), - }); - } + None => { + let result = git_checkout + .args([ + format!("--work-tree={}", path_str), + format!("--git-dir={}", path.join(".git").to_string_lossy()), + "rev-parse".to_string(), + "--verify".to_string(), + "HEAD".to_string(), + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let out = result.output().unwrap(); + let status = result.status().unwrap(); + if !status.success() { + let _ = fs::remove_dir_all(&path); + return Err(DownloadError::GitError( + str::from_utf8(&out.stderr).unwrap().trim().to_string(), + )); + } - Ok(hash.to_string()) + let hash = str::from_utf8(&out.stdout).unwrap().trim().to_string(); + // check the commit hash + if !hash.is_empty() && hash.len() != 40 { + let _ = fs::remove_dir_all(&path); + return Err(DownloadError::GitError(format!("invalid revision hash: {hash}"))); + } + hash + } + }; + println!( + "{}", + format!("Successfully downloaded {} the dependency via git", dependency,).green() + ); + Ok(rev) } async fn download_via_http( - dependency: &Dependency, + url: impl IntoUrl, + dependency: &HttpDependency, dependency_directory: &Path, -) -> Result<(), DownloadError> { +) -> Result<()> { + println!("{}", format!("Started HTTP download of {dependency}").green()); let zip_to_download = &format!("{}-{}.zip", dependency.name, dependency.version); - let mut stream = match reqwest::get(&dependency.url).await { - Ok(res) => { - if res.status() != 200 { - return Err(DownloadError { - name: dependency.name.clone().to_string(), - version: dependency.url.clone().to_string(), - cause: format!( - "Dependency {}~{} could not be downloaded via http.\nStatus: {}", - dependency.name.clone(), - dependency.version.clone(), - res.status() - ), - }); - } - res.bytes_stream() - } - Err(err) => { - return Err(DownloadError { - name: dependency.name.clone().to_string(), - version: dependency.url.clone().to_string(), - cause: format!("Unknown error: {:?}", err.source().unwrap()), - }); - } - }; + let resp = reqwest::get(url).await?; + let mut resp = resp.error_for_status()?; - let mut file = File::create(&dependency_directory.join(zip_to_download)).await.unwrap(); + let file_path = dependency_directory.join(zip_to_download); + let mut file = tokio_fs::File::create(&file_path) + .await + .map_err(|e| DownloadError::IOError { path: file_path.clone(), source: e })?; - while let Some(chunk_result) = stream.next().await { - match file.write_all(&chunk_result.unwrap()).await { - Ok(_) => {} - Err(err) => { - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - cause: format!("Unknown error: {:?}", err.source().unwrap()), - }); - } - } + while let Some(mut chunk) = resp.chunk().await? { + file.write_all_buf(&mut chunk) + .await + .map_err(|e| DownloadError::IOError { path: file_path.clone(), source: e })?; } - - match file.flush().await { - Ok(_) => {} - Err(err) => { - return Err(DownloadError { - name: dependency.name.to_string(), - version: dependency.url.to_string(), - cause: format!("Unknown error: {:?}", err.source().unwrap()), - }); - } - }; + // make sure we finished writing the file + file.flush().await.map_err(|e| DownloadError::IOError { path: file_path, source: e })?; Ok(()) } -pub fn delete_dependency_files(dependency: &Dependency) -> Result<(), DependencyError> { - let _ = - remove_dir_all(DEPENDENCY_DIR.join(format!("{}-{}", dependency.name, dependency.version))); +pub fn delete_dependency_files(dependency: &Dependency) -> Result<()> { + let path = DEPENDENCY_DIR.join(format!("{}-{}", dependency.name(), dependency.version())); + fs::remove_dir_all(&path).map_err(|e| DownloadError::IOError { path, source: e })?; Ok(()) } @@ -332,7 +269,10 @@ fn transform_git_to_http(url: &str) -> String { #[allow(clippy::vec_init_then_push)] mod tests { use super::*; - use crate::janitor::healthcheck_dependency; + use crate::{ + janitor::healthcheck_dependency, + utils::{get_url_type, UrlType}, + }; use serial_test::serial; use std::{fs::metadata, path::Path}; @@ -340,20 +280,20 @@ mod tests { #[serial] async fn download_dependencies_http_one_success() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); + let results = download_dependencies(&dependencies, false).await.unwrap(); let path_zip = - DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name, &dependency.version)); + DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name(), &dependency.version())); assert!(path_zip.exists()); - assert!(hashes.len() == 1); - assert!(!hashes[0].is_empty()); - clean_dependency_directory() + assert!(results.len() == 1); + assert!(!results[0].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] @@ -361,20 +301,21 @@ mod tests { async fn download_dependencies_git_one_success() { clean_dependency_directory(); let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@github.com:transmissions11/solmate.git".to_string(), - hash: String::new(), - }; + git: "git@github.com:transmissions11/solmate.git".to_string(), + rev: None, + }); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name, &dependency.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let path_dir = + DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); assert!(path_dir.exists()); assert!(path_dir.join("src").join("auth").join("Owned.sol").exists()); - assert!(hashes.len() == 1); - assert!(!hashes[0].is_empty()); - clean_dependency_directory() + assert!(results.len() == 1); + assert!(!results[0].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] @@ -382,20 +323,21 @@ mod tests { async fn download_dependencies_gitlab_giturl_one_success() { clean_dependency_directory(); let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@gitlab.com:mario4582928/Mario.git".to_string(), - hash: String::new(), - }; + git: "git@gitlab.com:mario4582928/Mario.git".to_string(), + rev: None, + }); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name, &dependency.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let path_dir = + DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); assert!(path_dir.exists()); assert!(path_dir.join("JustATest3.md").exists()); - assert!(hashes.len() == 1); - assert_eq!(hashes[0], "22868f426bd4dd0e682b5ec5f9bd55507664240c"); // this is the last commit, hash == commit - clean_dependency_directory() + assert!(results.len() == 1); + assert_eq!(results[0].hash, "22868f426bd4dd0e682b5ec5f9bd55507664240c"); // this is the last commit, hash == commit + clean_dependency_directory(); } #[tokio::test] @@ -403,27 +345,28 @@ mod tests { async fn download_dependency_gitlab_giturl_with_a_specific_revision() { clean_dependency_directory(); let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@gitlab.com:mario4582928/Mario.git".to_string(), - hash: "7a0663eaf7488732f39550be655bad6694974cb3".to_string(), - }; + git: "git@gitlab.com:mario4582928/Mario.git".to_string(), + rev: Some("7a0663eaf7488732f39550be655bad6694974cb3".to_string()), + }); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name, &dependency.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let path_dir = + DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); assert!(path_dir.exists()); assert!(path_dir.join("README.md").exists()); - assert!(hashes.len() == 1); - assert_eq!(hashes[0], "7a0663eaf7488732f39550be655bad6694974cb3"); // this is the last commit, hash == commit + assert!(results.len() == 1); + assert_eq!(results[0].hash, "7a0663eaf7488732f39550be655bad6694974cb3"); // this is the last commit, hash == commit // at this revision, this file should exists let test_right_revision = DEPENDENCY_DIR - .join(format!("{}-{}", &dependency.name, &dependency.version)) + .join(format!("{}-{}", &dependency.name(), &dependency.version())) .join("JustATest2.md"); assert!(test_right_revision.exists()); - clean_dependency_directory() + clean_dependency_directory(); } #[tokio::test] @@ -431,164 +374,193 @@ mod tests { async fn download_dependencies_gitlab_httpurl_one_success() { clean_dependency_directory(); let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://gitlab.com/mario4582928/Mario.git".to_string(), - hash: String::new(), - }; + git: "https://gitlab.com/mario4582928/Mario.git".to_string(), + rev: None, + }); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name, &dependency.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let path_dir = + DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); assert!(path_dir.exists()); assert!(path_dir.join("README.md").exists()); - assert!(hashes.len() == 1); - assert_eq!(hashes[0], "22868f426bd4dd0e682b5ec5f9bd55507664240c"); // this is the last commit, hash == commit - clean_dependency_directory() + assert!(results.len() == 1); + assert_eq!(results[0].hash, "22868f426bd4dd0e682b5ec5f9bd55507664240c"); // this is the last commit, hash == commit + clean_dependency_directory(); } #[tokio::test] #[serial] async fn download_dependencies_http_two_success() { let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency { + let dependency_one = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); dependencies.push(dependency_one.clone()); - let dependency_two = Dependency { + let dependency_two = Dependency::Http(HttpDependency { name: "@uniswap-v2-core".to_string(), version: "1.0.0-beta.4".to_string(), - url: "https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string(), - hash: String::new() - }; + url: Some("https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string()), + checksum: None + }); dependencies.push(dependency_two.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let mut path_zip = DEPENDENCY_DIR - .join(format!("{}-{}.zip", &dependency_one.name, &dependency_one.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let mut path_zip = DEPENDENCY_DIR.join(format!( + "{}-{}.zip", + &dependency_one.name(), + &dependency_one.version() + )); assert!(path_zip.exists()); - path_zip = DEPENDENCY_DIR - .join(format!("{}-{}.zip", &dependency_two.name, &dependency_two.version)); + path_zip = DEPENDENCY_DIR.join(format!( + "{}-{}.zip", + &dependency_two.name(), + &dependency_two.version() + )); assert!(path_zip.exists()); - assert!(hashes.len() == 2); - assert!(!hashes[0].is_empty()); - assert!(!hashes[1].is_empty()); - clean_dependency_directory() + assert!(results.len() == 2); + assert!(!results[0].hash.is_empty()); + assert!(!results[1].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] #[serial] async fn download_dependencies_git_two_success() { let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency { + let dependency_one = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@github.com:transmissions11/solmate.git".to_string(), - hash: String::new(), - }; + git: "git@github.com:transmissions11/solmate.git".to_string(), + rev: None, + }); dependencies.push(dependency_one.clone()); - let dependency_two = Dependency { + let dependency_two = Dependency::Git(GitDependency { name: "@uniswap-v2-core".to_string(), version: "1.0.0-beta.4".to_string(), - url: "https://gitlab.com/mario4582928/Mario.git".to_string(), - hash: String::new(), - }; + git: "https://gitlab.com/mario4582928/Mario.git".to_string(), + rev: None, + }); dependencies.push(dependency_two.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); - let mut path_dir = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency_one.name, &dependency_one.version)); - let mut path_dir_two = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency_two.name, &dependency_two.version)); + let results = download_dependencies(&dependencies, false).await.unwrap(); + let mut path_dir = DEPENDENCY_DIR.join(format!( + "{}-{}", + &dependency_one.name(), + &dependency_one.version() + )); + let mut path_dir_two = DEPENDENCY_DIR.join(format!( + "{}-{}", + &dependency_two.name(), + &dependency_two.version() + )); assert!(path_dir.exists()); assert!(path_dir_two.exists()); - path_dir = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency_one.name, &dependency_one.version)); - path_dir_two = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency_two.name, &dependency_two.version)); + path_dir = DEPENDENCY_DIR.join(format!( + "{}-{}", + &dependency_one.name(), + &dependency_one.version() + )); + path_dir_two = DEPENDENCY_DIR.join(format!( + "{}-{}", + &dependency_two.name(), + &dependency_two.version() + )); assert!(path_dir.exists()); assert!(path_dir_two.exists()); - assert!(hashes.len() == 2); - assert!(!hashes[0].is_empty()); - assert!(!hashes[1].is_empty()); - clean_dependency_directory() + assert!(results.len() == 2); + assert!(!results[0].hash.is_empty()); + assert!(!results[1].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] #[serial] async fn download_dependency_should_replace_existing_zip() { let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency { + let dependency_one = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "download-dep-v1".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); dependencies.push(dependency_one.clone()); download_dependencies(&dependencies, false).await.unwrap(); - let path_zip = DEPENDENCY_DIR - .join(format!("{}-{}.zip", &dependency_one.name, &dependency_one.version)); + let path_zip = DEPENDENCY_DIR.join(format!( + "{}-{}.zip", + &dependency_one.name(), + &dependency_one.version() + )); let size_of_one = fs::metadata(Path::new(&path_zip)).unwrap().len(); - let dependency_two = Dependency { + let dependency_two = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "download-dep-v1".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string(), - hash: String::new()}; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), + checksum: None + }); dependencies = Vec::new(); dependencies.push(dependency_two.clone()); - let hashes = download_dependencies(&dependencies, false).await.unwrap(); + let results = download_dependencies(&dependencies, false).await.unwrap(); let size_of_two = fs::metadata(Path::new(&path_zip)).unwrap().len(); assert!(size_of_two > size_of_one); - assert!(hashes.len() == 1); - assert!(!hashes[0].is_empty()); - clean_dependency_directory() + assert!(results.len() == 1); + assert!(!results[0].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] #[serial] async fn download_dependencies_one_with_clean_success() { let mut dependencies: Vec = Vec::new(); - let dependency_old = Dependency { + let dependency_old = Dependency::Http(HttpDependency { name: "@uniswap-v2-core".to_string(), version: "1.0.0-beta.4".to_string(), - url: "https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string(), - hash: String::new()}; + url: Some("https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string()), + checksum: None + }); dependencies.push(dependency_old.clone()); download_dependencies(&dependencies, false).await.unwrap(); // making sure the dependency exists so we can check the deletion - let path_zip_old = DEPENDENCY_DIR - .join(format!("{}-{}.zip", &dependency_old.name, &dependency_old.version)); + let path_zip_old = DEPENDENCY_DIR.join(format!( + "{}-{}.zip", + &dependency_old.name(), + &dependency_old.version() + )); assert!(path_zip_old.exists()); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new()}; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); dependencies = Vec::new(); dependencies.push(dependency.clone()); - let hashes = download_dependencies(&dependencies, true).await.unwrap(); + let results = download_dependencies(&dependencies, true).await.unwrap(); let path_zip = - DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name, &dependency.version)); + DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name(), &dependency.version())); assert!(!path_zip_old.exists()); assert!(path_zip.exists()); - assert!(hashes.len() == 1); - assert!(!hashes[0].is_empty()); - clean_dependency_directory() + assert!(results.len() == 1); + assert!(!results[0].hash.is_empty()); + clean_dependency_directory(); } #[tokio::test] @@ -596,11 +568,12 @@ mod tests { async fn download_dependencies_http_one_fail() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~.zip".to_string(), - hash: String::new()}; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~.zip".to_string()), + checksum: None + }); dependencies.push(dependency.clone()); match download_dependencies(&dependencies, false).await { @@ -608,10 +581,10 @@ mod tests { assert_eq!("Invalid state", ""); } Err(err) => { - assert_eq!(err.cause, "Dependency @openzeppelin-contracts~2.3.0 could not be downloaded via http.\nStatus: 404 Not Found"); + assert_eq!(err.to_string(), "error downloading dependency: HTTP status client error (404 Not Found) for url (https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~.zip)"); } } - clean_dependency_directory() + clean_dependency_directory(); } #[tokio::test] @@ -619,12 +592,12 @@ mod tests { async fn download_dependencies_git_one_fail() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@github.com:transmissions11/solmate-wrong.git".to_string(), - hash: String::new(), - }; + git: "git@github.com:transmissions11/solmate-wrong.git".to_string(), + rev: None, + }); dependencies.push(dependency.clone()); match download_dependencies(&dependencies, false).await { @@ -634,24 +607,25 @@ mod tests { Err(err) => { // we assert this as the message contains various absolute paths that can not be // hardcoded here - assert!(err.cause.contains("Cloning into")); + assert!(err.to_string().contains("Cloning into")); } } - clean_dependency_directory() + clean_dependency_directory(); } #[tokio::test] #[serial] async fn unzip_dependency_success() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); dependencies.push(dependency.clone()); download_dependencies(&dependencies, false).await.unwrap(); - let path = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name, &dependency.version)); + let path = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); match unzip_dependencies(&dependencies) { Ok(_) => { assert!(path.exists()); @@ -669,13 +643,15 @@ mod tests { #[serial] async fn unzip_non_zip_file_error() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://freetestdata.com/wp-content/uploads/2022/02/Free_Test_Data_117KB_JPG.jpg" - .to_string(), - hash: String::new(), - }; + url: Some( + "https://freetestdata.com/wp-content/uploads/2022/02/Free_Test_Data_117KB_JPG.jpg" + .to_string(), + ), + checksum: None, + }); dependencies.push(dependency.clone()); download_dependencies(&dependencies, false).await.unwrap(); match unzip_dependencies(&dependencies) { @@ -684,13 +660,7 @@ mod tests { assert_eq!("Wrong State", ""); } Err(err) => { - assert_eq!( - err, - UnzippingError { - name: dependency.name.to_string(), - version: dependency.version.to_string(), - } - ); + assert!(matches!(err, DownloadError::UnzipError(_))); } } clean_dependency_directory(); @@ -700,14 +670,14 @@ mod tests { #[serial] async fn download_unzip_check_integrity() { let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "3.3.0-custom-test".to_string(), - url: "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip".to_string(), - hash: String::new() - }); + url: Some("https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip".to_string()), + checksum: None, + })); download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(&dependencies[0].name, &dependencies[0].version).unwrap(); + unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); healthcheck_dependency(&dependencies[0]).unwrap(); assert!(DEPENDENCY_DIR .join("@openzeppelin-contracts-3.3.0-custom-test") @@ -715,27 +685,25 @@ mod tests { .join("ERC20") .join("ERC20.sol") .exists()); - clean_dependency_directory() + clean_dependency_directory(); } #[test] fn get_download_tunnel_http() { assert_eq!( - get_download_tunnel( - "https://github.com/foundry-rs/forge-std/archive/refs/tags/v1.9.1.zip" - ), - "http" + get_url_type("https://github.com/foundry-rs/forge-std/archive/refs/tags/v1.9.1.zip"), + UrlType::Http ); } #[test] fn get_download_tunnel_git_giturl() { - assert_eq!(get_download_tunnel("git@github.com:foundry-rs/forge-std.git"), "git"); + assert_eq!(get_url_type("git@github.com:foundry-rs/forge-std.git"), UrlType::Git); } #[test] fn get_download_tunnel_git_githttp() { - assert_eq!(get_download_tunnel("https://github.com/foundry-rs/forge-std.git"), "git"); + assert_eq!(get_url_type("https://github.com/foundry-rs/forge-std.git"), UrlType::Git); } #[test] @@ -775,12 +743,12 @@ mod tests { async fn remove_one_dependency() { let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Git(GitDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "git@github.com:transmissions11/solmate.git".to_string(), - hash: String::new(), - }; + git: "git@github.com:transmissions11/solmate.git".to_string(), + rev: None, + }); dependencies.push(dependency.clone()); match download_dependencies(&dependencies, false).await { @@ -791,7 +759,7 @@ mod tests { } let _ = delete_dependency_files(&dependency); assert!(!DEPENDENCY_DIR - .join(format!("{}~{}", dependency.name, dependency.version)) + .join(format!("{}~{}", dependency.name(), dependency.version())) .exists()); } } diff --git a/src/errors.rs b/src/errors.rs index e8b8cd9..4e3311d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,151 +1,176 @@ -use std::fmt; +use std::{ + io, + path::{PathBuf, StripPrefixError}, +}; +use thiserror::Error; -#[derive(Debug, Clone, PartialEq)] -pub struct SoldeerError { - pub message: String, -} +#[derive(Error, Debug)] +pub enum SoldeerError { + #[error("error during login: {0}")] + AuthError(#[from] AuthError), -impl fmt::Display for SoldeerError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.message) - } -} + #[error("error during config operation: {0}")] + ConfigError(#[from] ConfigError), -#[derive(Debug, Clone, PartialEq)] -pub struct MissingDependencies { - pub name: String, - pub version: String, -} + #[error("error during downloading ({dep}): {source}")] + DownloadError { dep: String, source: DownloadError }, -impl MissingDependencies { - pub fn new(name: &str, version: &str) -> MissingDependencies { - MissingDependencies { name: name.to_string(), version: version.to_string() } - } -} + #[error("error during janitor operation: {0}")] + JanitorError(#[from] JanitorError), -#[derive(Debug, Clone, PartialEq)] -pub struct UnzippingError { - pub name: String, - pub version: String, -} + #[error("error during lockfile operation: {0}")] + LockError(#[from] LockError), -impl UnzippingError { - pub fn new(name: &str, version: &str) -> UnzippingError { - UnzippingError { name: name.to_string(), version: version.to_string() } - } + #[error("error during publishing: {0}")] + PublishError(#[from] PublishError), } -#[derive(Debug, Clone, PartialEq)] -pub struct IncorrectDependency { - pub name: String, - pub version: String, -} +#[derive(Error, Debug)] +pub enum AuthError { + #[error("login error: invalid email")] + InvalidEmail, -impl IncorrectDependency { - pub fn new(name: &str, version: &str) -> IncorrectDependency { - IncorrectDependency { name: name.to_string(), version: version.to_string() } - } -} + #[error("login error: invalid email or password")] + InvalidCredentials, -#[derive(Debug, Clone, PartialEq)] -pub struct LockError { - pub cause: String, -} + #[error("missing token, you are not connected")] + MissingToken, -impl fmt::Display for LockError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "lock failed") - } -} + #[error("error during IO operation for the security file: {0}")] + IOError(#[from] io::Error), -#[derive(Debug, Clone, PartialEq)] -pub struct DownloadError { - pub name: String, - pub version: String, - pub cause: String, + #[error("http error during login: {0}")] + HttpError(#[from] reqwest::Error), } -impl fmt::Display for DownloadError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "download failed for {}~{}", &self.name, &self.version) - } -} +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("config file is not valid: {0}")] + Parsing(#[from] toml_edit::TomlError), -impl DownloadError { - pub fn new(name: &str, version: &str, cause: &str) -> DownloadError { - DownloadError { - name: name.to_string(), - version: version.to_string(), - cause: cause.to_string(), - } - } -} + #[error("config file is missing the `[dependencies]` section")] + MissingDependencies, -#[derive(Debug, Clone, PartialEq)] -pub struct ProjectNotFound { - pub name: String, - pub cause: String, -} + #[error("invalid user input: {source}")] + PromptError { source: io::Error }, -impl ProjectNotFound { - pub fn new(name: &str, cause: &str) -> ProjectNotFound { - ProjectNotFound { name: name.to_string(), cause: cause.to_string() } - } -} + #[error("invalid prompt option")] + InvalidPromptOption, -#[derive(Debug, Clone, PartialEq)] -pub struct PushError { - pub name: String, - pub version: String, - pub cause: String, -} + #[error("error writing to config file: {0}")] + FileWriteError(#[from] io::Error), -impl PushError { - pub fn new(name: &str, version: &str, cause: &str) -> PushError { - PushError { name: name.to_string(), version: version.to_string(), cause: cause.to_string() } - } -} + #[error("error writing to remappings file: {0}")] + RemappingsError(io::Error), -#[derive(Debug, Clone, PartialEq)] -pub struct LoginError { - pub cause: String, -} + #[error("empty `version` field in {0}")] + EmptyVersion(String), -impl LoginError { - pub fn new(cause: &str) -> LoginError { - LoginError { cause: cause.to_string() } - } -} -#[derive(Debug, Clone, PartialEq)] -pub struct ConfigError { - pub cause: String, + #[error("missing `{field}` field in {dep}")] + MissingField { field: String, dep: String }, + + #[error("invalid `{field}` field in {dep}")] + InvalidField { field: String, dep: String }, + + #[error("dependency {0} is not valid")] + InvalidDependency(String), + + #[error("dependency {0} was not found")] + MissingDependency(String), + + #[error("error parsing config file: {0}")] + DeserializeError(#[from] toml_edit::de::Error), } -impl ConfigError { - pub fn new(cause: &str) -> ConfigError { - ConfigError { cause: cause.to_string() } - } +#[derive(Error, Debug)] +pub enum DownloadError { + #[error("error downloading dependency: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("error extracting dependency: {0}")] + UnzipError(#[from] zip_extract::ZipExtractError), + + #[error("error during git operation: {0}")] + GitError(String), + + #[error("error during IO operation for {path:?}: {source}")] + IOError { path: PathBuf, source: io::Error }, + + #[error("Project {0} not found, please check the dependency name (project name) or create a new project on https://soldeer.xyz")] + ProjectNotFound(String), + + #[error("Could not get the dependency URL for {0}")] + URLNotFound(String), + + #[error("Could not get the last forge dependency")] + ForgeStdError, + + #[error("error during async operation: {0}")] + AsyncError(#[from] tokio::task::JoinError), } -#[derive(Debug, Clone, PartialEq)] -pub struct DependencyError { - pub name: String, - pub version: String, - pub cause: String, +#[derive(Error, Debug)] +pub enum JanitorError { + #[error("missing dependency {0}")] + MissingDependency(String), + + #[error("error during IO operation for {path:?}: {source}")] + IOError { path: PathBuf, source: io::Error }, + + #[error("error during lockfile operation: {0}")] + LockError(LockError), // TODO: derive from LockError } -impl fmt::Display for DependencyError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "dependency operation failed for {}~{}", &self.name, &self.version) - } +#[derive(Error, Debug)] +pub enum LockError { + #[error("soldeer.lock is missing")] + Missing, + + #[error("dependency {0} is already installed")] + DependencyInstalled(String), + + #[error("IO error for soldeer.lock: {0}")] + IOError(#[from] io::Error), + + #[error("error generating soldeer.lock contents: {0}")] + SerializeError(#[from] toml_edit::ser::Error), } -impl DependencyError { - pub fn new(name: &str, version: &str, cause: &str) -> DependencyError { - DependencyError { - name: name.to_string(), - version: version.to_string(), - cause: cause.to_string(), - } - } +#[derive(Error, Debug)] +pub enum PublishError { + #[error("no files to publish")] + NoFiles, + + #[error("error during zipping: {0}")] + ZipError(#[from] zip::result::ZipError), + + #[error("error during IO operation for {path:?}: {source}")] + IOError { path: PathBuf, source: io::Error }, + + #[error("error while computing the relative path: {0}")] + RelativePathError(#[from] StripPrefixError), + + #[error("auth error: {0}")] + AuthError(#[from] AuthError), + + #[error("error during publishing: {0}")] + DownloadError(#[from] DownloadError), + + #[error("Project not found. Make sure you send the right dependency name. The dependency name is the project name you created on https://soldeer.xyz")] + ProjectNotFound, + + #[error("dependency already exists")] + AlreadyExists, + + #[error("the package is too big (over 50 MB)")] + PayloadTooLarge, + + #[error("http error during publishing: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("invalid package name, only alphanumeric characters, `-` and `@` are allowed")] + InvalidName, + + #[error("unknown http error")] + UnknownError, } diff --git a/src/janitor.rs b/src/janitor.rs index 1614a60..8ed3654 100644 --- a/src/janitor.rs +++ b/src/janitor.rs @@ -1,73 +1,40 @@ -use crate::{ - config::Dependency, errors::MissingDependencies, lock::remove_lock, utils::get_download_tunnel, - DEPENDENCY_DIR, -}; -use std::fs::{metadata, remove_dir_all, remove_file}; +use crate::{config::Dependency, errors::JanitorError, lock::remove_lock, DEPENDENCY_DIR}; +use std::fs; + +pub type Result = std::result::Result; // Health-check dependencies before we clean them, this one checks if they were unzipped -pub fn healthcheck_dependencies(dependencies: &[Dependency]) -> Result<(), MissingDependencies> { - for dependency in dependencies.iter() { - match healthcheck_dependency(dependency) { - Ok(_) => {} - Err(err) => { - return Err(err); - } - } - } +pub fn healthcheck_dependencies(dependencies: &[Dependency]) -> Result<()> { + dependencies.iter().try_for_each(healthcheck_dependency)?; Ok(()) } // Cleanup zips after the download -pub fn cleanup_after(dependencies: &[Dependency]) -> Result<(), MissingDependencies> { - for dependency in dependencies.iter() { - let via_git: bool = get_download_tunnel(&dependency.url) == "git"; - match cleanup_dependency(dependency, CleanupParams { full: false, via_git }) { - Ok(_) => {} - Err(err) => { - println!("returning error {:?}", err); - return Err(err); - } - } - } +pub fn cleanup_after(dependencies: &[Dependency]) -> Result<()> { + dependencies.iter().try_for_each(|d| cleanup_dependency(d, false))?; Ok(()) } -pub fn healthcheck_dependency(dependency: &Dependency) -> Result<(), MissingDependencies> { - let file_name: String = format!("{}-{}", dependency.name, dependency.version); +pub fn healthcheck_dependency(dependency: &Dependency) -> Result<()> { + let file_name: String = format!("{}-{}", dependency.name(), dependency.version()); let new_path = DEPENDENCY_DIR.join(file_name); - match metadata(new_path) { + match fs::metadata(new_path) { Ok(_) => Ok(()), - Err(_) => Err(MissingDependencies::new(&dependency.name, &dependency.version)), + Err(_) => Err(JanitorError::MissingDependency(dependency.to_string())), } } -#[derive(Debug, Clone, Default)] -pub struct CleanupParams { - pub full: bool, - pub via_git: bool, -} - -pub fn cleanup_dependency( - dependency: &Dependency, - params: CleanupParams, -) -> Result<(), MissingDependencies> { - let file_name: String = format!("{}-{}.zip", dependency.name, dependency.version); +pub fn cleanup_dependency(dependency: &Dependency, full: bool) -> Result<()> { + let file_name: String = format!("{}-{}.zip", dependency.name(), dependency.version()); let new_path: std::path::PathBuf = DEPENDENCY_DIR.clone().join(file_name); - if !params.via_git { - match remove_file(new_path) { - Ok(_) => {} - Err(_) => { - return Err(MissingDependencies::new(&dependency.name, &dependency.version)); - } - }; + if let Dependency::Http(_) = dependency { + fs::remove_file(&new_path) + .map_err(|e| JanitorError::IOError { path: new_path, source: e })?; } - if params.full { - let dir = DEPENDENCY_DIR.join(&dependency.name); - remove_dir_all(dir).unwrap(); - match remove_lock(dependency) { - Ok(_) => {} - Err(_) => return Err(MissingDependencies::new(&dependency.name, &dependency.version)), - } + if full { + let dir = DEPENDENCY_DIR.join(dependency.name()); + fs::remove_dir_all(&dir).map_err(|e| JanitorError::IOError { path: dir, source: e })?; + remove_lock(dependency).map_err(JanitorError::LockError)?; } Ok(()) } @@ -76,8 +43,11 @@ pub fn cleanup_dependency( #[allow(clippy::vec_init_then_push)] mod tests { use super::*; - use crate::dependency_downloader::{ - clean_dependency_directory, download_dependencies, unzip_dependency, + use crate::{ + config::HttpDependency, + dependency_downloader::{ + clean_dependency_directory, download_dependencies, unzip_dependency, + }, }; use serial_test::serial; @@ -90,12 +60,12 @@ mod tests { #[tokio::test] async fn healthcheck_dependency_not_found() { - let _ = healthcheck_dependency(&Dependency { + let _ = healthcheck_dependency(&Dependency::Http(HttpDependency { name: "test".to_string(), version: "1.0.0".to_string(), - url: String::new(), - hash: String::new(), - }) + url: None, + checksum: None, + })) .unwrap_err(); } @@ -105,13 +75,13 @@ mod tests { let _cleanup = CleanupDependency; let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new()}); + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None})); download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(&dependencies[0].name, &dependencies[0].version).unwrap(); + unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); healthcheck_dependency(&dependencies[0]).unwrap(); } @@ -121,14 +91,14 @@ mod tests { let _cleanup = CleanupDependency; let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() }); + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None })); download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(&dependencies[0].name, &dependencies[0].version).unwrap(); - cleanup_dependency(&dependencies[0], CleanupParams::default()).unwrap(); + unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); + cleanup_dependency(&dependencies[0], false).unwrap(); } #[test] @@ -137,12 +107,12 @@ mod tests { let _cleanup = CleanupDependency; let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "v-cleanup-nonexisting".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new()}); - cleanup_dependency(&dependencies[0], CleanupParams::default()).unwrap_err(); + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None})); + cleanup_dependency(&dependencies[0], false).unwrap_err(); } #[tokio::test] @@ -151,20 +121,20 @@ mod tests { let _cleanup = CleanupDependency; let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new()}); - dependencies.push(Dependency { + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None})); + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.4.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string(), - hash: String::new() }); + url:Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), + checksum: None })); download_dependencies(&dependencies, false).await.unwrap(); - let _ = unzip_dependency(&dependencies[0].name, &dependencies[0].version); - let result: Result<(), MissingDependencies> = cleanup_after(&dependencies); + let _ = unzip_dependency(dependencies[0].as_http().unwrap()); + let result: Result<()> = cleanup_after(&dependencies); assert!(result.is_ok()); clean_dependency_directory(); } @@ -175,26 +145,26 @@ mod tests { let _cleanup = CleanupDependency; let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency { + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "cleanup-after-one-existing".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new()}); + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None})); download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(&dependencies[0].name, &dependencies[0].version).unwrap(); - dependencies.push(Dependency { + unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); + dependencies.push(Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "cleanup-after-one-existing-2".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string(), - hash: String::new()}); + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), + checksum: None})); match cleanup_after(&dependencies) { Ok(_) => { assert_eq!("Invalid State", ""); } Err(error) => { - assert!(error.name == "@openzeppelin-contracts"); - assert!(error.version == "cleanup-after-one-existing-2"); + println!("{error}"); + assert!(matches!(error, JanitorError::IOError { path: _, source: _ })); } } } diff --git a/src/lib.rs b/src/lib.rs index 20abd7e..007ac55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,29 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] use crate::{ auth::login, - commands::Subcommands, - config::{delete_config, get_foundry_setup, read_config, remappings, Dependency}, + config::{delete_config, read_config_deps, remappings_txt, Dependency}, dependency_downloader::{ delete_dependency_files, download_dependencies, unzip_dependencies, unzip_dependency, }, - errors::SoldeerError, janitor::{cleanup_after, healthcheck_dependencies}, lock::{lock_check, remove_lock, write_lock}, utils::{check_dotfiles_recursive, get_current_working_dir, prompt_user_for_confirmation}, versioning::push_version, }; -use config::{add_to_config, define_config_file}; +pub use crate::{commands::Subcommands, errors::SoldeerError}; +use config::{ + add_to_config, get_config_path, read_soldeer_config, remappings_foundry, GitDependency, + HttpDependency, RemappingsAction, RemappingsLocation, +}; use dependency_downloader::download_dependency; -use janitor::{cleanup_dependency, CleanupParams}; +use janitor::cleanup_dependency; use lock::LockWriteMode; use once_cell::sync::Lazy; -use regex::Regex; -use remote::{get_dependency_url_remote, get_latest_forge_std_dependency}; +use remote::get_latest_forge_std_dependency; use std::{env, path::PathBuf}; -use utils::get_download_tunnel; -use yansi::Paint; +use utils::{get_url_type, UrlType}; +use versioning::validate_name; +use yansi::Paint as _; mod auth; pub mod commands; @@ -42,125 +44,72 @@ pub static SOLDEER_CONFIG_FILE: Lazy = pub static FOUNDRY_CONFIG_FILE: Lazy = Lazy::new(|| get_current_working_dir().join("foundry.toml")); -#[derive(Debug)] -pub struct FOUNDRY { - remappings: bool, -} - #[tokio::main] pub async fn run(command: Subcommands) -> Result<(), SoldeerError> { match command { Subcommands::Init(init) => { - Paint::green("🦌 Running Soldeer init 🦌\n"); - Paint::green("Initializes a new Soldeer project in foundry\n"); - - if init.clean.is_some() && init.clean.unwrap() { - match config::remove_forge_lib() { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); - } - } - } + println!("{}", "🦌 Running Soldeer init 🦌".green()); + println!("{}", "Initializes a new Soldeer project in foundry".green()); - let mut dependency: Dependency = match get_latest_forge_std_dependency().await { - Ok(dep) => dep, - Err(err) => { - return Err(SoldeerError { - message: format!( - "Error downloading a dependency {}~{}", - err.name, err.version - ), - }); - } - }; - match install_dependency(&mut dependency, false, false).await { - Ok(_) => {} - Err(err) => return Err(err), + if init.clean { + config::remove_forge_lib()?; } + + let dependency: Dependency = get_latest_forge_std_dependency().await.map_err(|e| { + SoldeerError::DownloadError { dep: "forge-std".to_string(), source: e } + })?; + install_dependency(dependency, true).await?; } Subcommands::Install(install) => { - if install.dependency.is_none() { - return update().await; - } - Paint::green("🦌 Running Soldeer install 🦌\n"); - let dependency = install.dependency.unwrap(); - if !dependency.contains('~') { - return Err(SoldeerError { - message: format!( - "Dependency {} does not specify a version.\nThe format should be [DEPENDENCY]~[VERSION]", - dependency - ), - }); - } - let dependency_name: String = - dependency.split('~').collect::>()[0].to_string(); - let dependency_version: String = - dependency.split('~').collect::>()[1].to_string(); - let dependency_url: String; - - let mut custom_url = false; - let mut via_git = false; - - if install.remote_url.is_some() { - custom_url = true; - - let remote_url = install.remote_url.unwrap(); - via_git = get_download_tunnel(&remote_url) == "git"; - dependency_url = remote_url.clone(); - } else { - dependency_url = - match get_dependency_url_remote(&dependency_name, &dependency_version).await { - Ok(url) => url, - Err(err) => { - return Err(SoldeerError { - message: format!( - "Error downloading a dependency {}~{}. Cause {}", - err.name, err.version, err.cause - ), - }); - } - }; - } - - // retrieve the commit in case it's sent when using git - let mut hash = String::new(); - if via_git && install.rev.is_some() { - hash = install.rev.unwrap(); - } else if !via_git && install.rev.is_some() { - return Err(SoldeerError { - message: format!("Error unknown param {}", install.rev.unwrap()), - }); - } + let regenerate_remappings = install.regenerate_remappings; + let Some(dependency) = install.dependency else { + return update(regenerate_remappings).await; // TODO: instead, check which + // dependencies do + // not match the + // integrity checksum and install those + }; - let mut dependency = Dependency { - name: dependency_name.clone(), - version: dependency_version.clone(), - url: dependency_url.clone(), - hash, + println!("{}", "🦌 Running Soldeer install 🦌".green()); + let (dependency_name, dependency_version) = + dependency.split_once('~').expect("dependency string should have name and version"); + + let dep = match install.remote_url { + Some(url) => match get_url_type(&url) { + UrlType::Git => Dependency::Git(GitDependency { + name: dependency_name.to_string(), + version: dependency_version.to_string(), + git: url, + rev: install.rev, + }), + UrlType::Http => Dependency::Http(HttpDependency { + name: dependency_name.to_string(), + version: dependency_version.to_string(), + url: Some(url), + checksum: None, + }), + }, + None => Dependency::Http(HttpDependency { + name: dependency_name.to_string(), + version: dependency_version.to_string(), + url: None, + checksum: None, + }), }; - match install_dependency(&mut dependency, via_git, custom_url).await { - Ok(_) => {} - Err(err) => return Err(err), - } + install_dependency(dep, regenerate_remappings).await?; } - Subcommands::Update(_) => { - return update().await; + Subcommands::Update(update_args) => { + let regenerate_remappings = update_args.regenerate_remappings; + return update(regenerate_remappings).await; } Subcommands::Login(_) => { - Paint::green("🦌 Running Soldeer login 🦌\n"); - match login().await { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); - } - } + println!("{}", "🦌 Running Soldeer login 🦌".green()); + login().await?; } Subcommands::Push(push) => { let path = push.path.unwrap_or(get_current_working_dir()); - let dry_run = push.dry_run.is_some() && push.dry_run.unwrap(); - let skip_warnings = push.skip_warnings.unwrap_or(false); + let dry_run = push.dry_run; + let skip_warnings = push.skip_warnings; // Check for sensitive files or directories if !dry_run && @@ -168,282 +117,190 @@ pub async fn run(command: Subcommands) -> Result<(), SoldeerError> { check_dotfiles_recursive(&path) && !prompt_user_for_confirmation() { - Paint::yellow("Push operation aborted by the user."); + println!("{}", "Push operation aborted by the user.".yellow()); return Ok(()); } if dry_run { println!( - "{}", - Paint::green("🦌 Running Soldeer push with dry-run, a zip file will be available for inspection 🦌\n") - ); + "{}", + "🦌 Running Soldeer push with dry-run, a zip file will be available for inspection 🦌".green() + ); } else { - Paint::green("🦌 Running Soldeer push 🦌\n"); + println!("{}", "🦌 Running Soldeer push 🦌".green()); } if skip_warnings { - println!( - "{}", - Paint::yellow("Warning: Skipping sensitive file checks as requested.") - ); + println!("{}", "Warning: Skipping sensitive file checks as requested.".yellow()); } - let dependency_name: String = - push.dependency.split('~').collect::>()[0].to_string(); - let dependency_version: String = - push.dependency.split('~').collect::>()[1].to_string(); - let regex = Regex::new(r"^[@|a-z0-9][a-z0-9-]*[a-z0-9]$").unwrap(); + let (dependency_name, dependency_version) = push + .dependency + .split_once('~') + .expect("dependency string should have name and version"); - if !regex.is_match(&dependency_name) { - return Err(SoldeerError{message:format!("Dependency name {} is not valid, you can use only alphanumeric characters `-` and `@`", &dependency_name)}); - } - match push_version(&dependency_name, &dependency_version, path, dry_run).await { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!( - "Dependency {}~{} could not be pushed.\nCause: {}", - dependency_name, dependency_version, err.cause - ), - }); - } - } + validate_name(dependency_name)?; + + push_version(dependency_name, dependency_version, path, dry_run).await?; } Subcommands::Uninstall(uninstall) => { // define the config file - let config_file: String = match define_config_file() { - Ok(file) => file, - Err(_) => { - return Err(SoldeerError { - message: "Could not remove the dependency from the config file".to_string(), - }); - } - }; + let path = get_config_path()?; // delete from the config file and return the dependency - let dependency = match delete_config(&uninstall.dependency, &config_file) { - Ok(d) => d, - Err(err) => { - return Err(SoldeerError { message: err.cause }); - } - }; + let dependency = delete_config(&uninstall.dependency, &path)?; // deleting the files - let _ = delete_dependency_files(&dependency).is_ok(); + delete_dependency_files(&dependency).map_err(|e| SoldeerError::DownloadError { + dep: dependency.to_string(), + source: e, + })?; // removing the dependency from the lock file - match remove_lock(&dependency) { - Ok(d) => d, - Err(err) => { - return Err(SoldeerError { message: err.cause }); + remove_lock(&dependency)?; + + let config = read_soldeer_config(Some(path.clone()))?; + + if config.remappings_generate { + if path.to_string_lossy().contains("foundry.toml") { + match config.remappings_location { + RemappingsLocation::Txt => { + remappings_txt(&RemappingsAction::Remove(dependency), &config).await? + } + RemappingsLocation::Config => { + remappings_foundry( + &RemappingsAction::Remove(dependency), + &path, + &config, + ) + .await? + } + } + } else { + remappings_txt(&RemappingsAction::Remove(dependency), &config).await?; } - }; + } } - Subcommands::VersionDryRun(_) => { + Subcommands::Version(_) => { const VERSION: &str = env!("CARGO_PKG_VERSION"); - Paint::cyan(&format!("Current Soldeer {}", VERSION)); + println!("{}", format!("Current Soldeer {}", VERSION).cyan()); } } Ok(()) } async fn install_dependency( - dependency: &mut Dependency, - via_git: bool, - custom_url: bool, + mut dependency: Dependency, + regenerate_remappings: bool, ) -> Result<(), SoldeerError> { - match lock_check(dependency, true) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); - } - } + lock_check(&dependency, true)?; - dependency.hash = match download_dependency(dependency).await { - Ok(h) => h, - Err(err) => { - return Err(SoldeerError { - message: format!( - "Error downloading a dependency {}~{}. Cause: {}", - err.name, err.version, err.cause - ), - }); + let config_file = match get_config_path() { + Ok(file) => file, + Err(e) => { + cleanup_dependency(&dependency, true)?; + return Err(e.into()); } }; - - match write_lock(&[dependency.clone()], LockWriteMode::Append) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: format!("Error writing the lock: {}", err.cause) }); - } - } - - if !via_git { - match unzip_dependency(&dependency.name, &dependency.version) { - Ok(_) => {} - Err(err_unzip) => { - match janitor::cleanup_dependency( - dependency, - CleanupParams { full: true, via_git: false }, - ) { - Ok(_) => {} - Err(err_cleanup) => { - return Err(SoldeerError { - message: format!( - "Error cleaning up dependency {}~{}", - err_cleanup.name, err_cleanup.version - ), - }) - } - } - return Err(SoldeerError { - message: format!( - "Error downloading a dependency {}~{}", - err_unzip.name, err_unzip.version - ), - }); - } + add_to_config(&dependency, &config_file)?; + + let result = download_dependency(&dependency, false) + .await + .map_err(|e| SoldeerError::DownloadError { dep: dependency.to_string(), source: e })?; + match dependency { + Dependency::Http(ref mut dep) => { + dep.checksum = Some(result.hash); + dep.url = Some(result.url); } + Dependency::Git(ref mut dep) => dep.rev = Some(result.hash), } - let config_file: String = match define_config_file() { - Ok(file) => file, - - Err(_) => match cleanup_dependency(dependency, CleanupParams { full: true, via_git }) { - Ok(_) => { - return Err(SoldeerError { - message: "Could not define the config file".to_string(), - }); - } - Err(_) => { - return Err(SoldeerError { - message: "Could not delete dependency artifacts".to_string(), - }); - } - }, - }; + write_lock(&[dependency.clone()], LockWriteMode::Append)?; - match add_to_config(dependency, custom_url, &config_file, via_git) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); + if let Dependency::Http(dep) = &dependency { + if let Err(e) = unzip_dependency(dep) { + cleanup_dependency(&dependency, true)?; + return Err(SoldeerError::DownloadError { dep: dependency.to_string(), source: e }); } } - match janitor::healthcheck_dependency(dependency) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!("Error health-checking dependency {}~{}", err.name, err.version), - }); - } + let mut config = read_soldeer_config(Some(config_file.clone()))?; + if regenerate_remappings { + config.remappings_regenerate = regenerate_remappings; } - match janitor::cleanup_dependency(dependency, CleanupParams { full: false, via_git }) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!("Error cleaning up dependency {}~{}", err.name, err.version), - }); - } - } - // check the foundry setup, in case we have a foundry.toml, then the foundry.toml will be used - // for `dependencies` - let f_setup_vec: Vec = match get_foundry_setup() { - Ok(setup) => setup, - Err(err) => return Err(SoldeerError { message: err.cause }), - }; - let foundry_setup: FOUNDRY = FOUNDRY { remappings: f_setup_vec[0] }; + janitor::healthcheck_dependency(&dependency)?; - if foundry_setup.remappings { - match remappings().await { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); + janitor::cleanup_dependency(&dependency, false)?; + + if config.remappings_generate { + if config_file.to_string_lossy().contains("foundry.toml") { + match config.remappings_location { + RemappingsLocation::Txt => { + remappings_txt(&RemappingsAction::Add(dependency), &config).await? + } + RemappingsLocation::Config => { + remappings_foundry(&RemappingsAction::Add(dependency), &config_file, &config) + .await? + } } + } else { + remappings_txt(&RemappingsAction::Add(dependency), &config).await?; } } + Ok(()) } -async fn update() -> Result<(), SoldeerError> { - Paint::green("🦌 Running Soldeer update 🦌\n"); +async fn update(regenerate_remappings: bool) -> Result<(), SoldeerError> { + println!("{}", "🦌 Running Soldeer update 🦌".green()); - let mut dependencies: Vec = match read_config(String::new()).await { - Ok(dep) => dep, - Err(err) => return Err(SoldeerError { message: err.cause }), - }; + let config_file = get_config_path()?; + let mut config = read_soldeer_config(Some(config_file.clone()))?; + if regenerate_remappings { + config.remappings_regenerate = regenerate_remappings; + } - let hashes = match download_dependencies(&dependencies, true).await { - Ok(h) => h, - Err(err) => { - return Err(SoldeerError { - message: format!( - "Error downloading a dependency {}~{}. Cause: {}", - err.name, err.version, err.cause - ), - }) - } - }; + let mut dependencies: Vec = read_config_deps(None)?; - for (index, dependency) in dependencies.iter_mut().enumerate() { - dependency.hash.clone_from(&hashes[index]); - } + let results = download_dependencies(&dependencies, true) + .await + .map_err(|e| SoldeerError::DownloadError { dep: String::new(), source: e })?; - match unzip_dependencies(&dependencies) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!("Error unzipping dependency {}~{}", err.name, err.version), - }); + dependencies.iter_mut().zip(results.into_iter()).for_each(|(dependency, result)| { + match dependency { + Dependency::Http(ref mut dep) => { + dep.checksum = Some(result.hash); + dep.url = Some(result.url); + } + Dependency::Git(ref mut dep) => dep.rev = Some(result.hash), } - } + }); - match healthcheck_dependencies(&dependencies) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!("Error health-checking dependencies {}~{}", err.name, err.version), - }); - } - } + unzip_dependencies(&dependencies) + .map_err(|e| SoldeerError::DownloadError { dep: String::new(), source: e })?; - match write_lock(&dependencies, LockWriteMode::Replace) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: format!("Error writing the lock: {}", err.cause) }); - } - } + healthcheck_dependencies(&dependencies)?; - match cleanup_after(&dependencies) { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { - message: format!("Error cleanup dependencies {}~{}", err.name, err.version), - }); - } - } + write_lock(&dependencies, LockWriteMode::Replace)?; - // check the foundry setup, in case we have a foundry.toml, then the foundry.toml will be used - // for `dependencies` - let f_setup_vec: Vec = match get_foundry_setup() { - Ok(f_setup) => f_setup, - Err(err) => { - return Err(SoldeerError { message: err.cause }); - } - }; - let foundry_setup: FOUNDRY = FOUNDRY { remappings: f_setup_vec[0] }; + cleanup_after(&dependencies)?; - if foundry_setup.remappings { - match remappings().await { - Ok(_) => {} - Err(err) => { - return Err(SoldeerError { message: err.cause }); + if config.remappings_generate { + if config_file.to_string_lossy().contains("foundry.toml") { + match config.remappings_location { + RemappingsLocation::Txt => remappings_txt(&RemappingsAction::None, &config).await?, + RemappingsLocation::Config => { + remappings_foundry(&RemappingsAction::None, &config_file, &config).await? + } } + } else { + remappings_txt(&RemappingsAction::None, &config).await?; } } + Ok(()) } @@ -487,10 +344,17 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = - Subcommands::Install(Install { dependency: None, remote_url: None, rev: None }); + let command = Subcommands::Install(Install { + dependency: None, + remote_url: None, + rev: None, + regenerate_remappings: false, + }); match run(command) { Ok(_) => {} @@ -531,10 +395,17 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = - Subcommands::Install(Install { dependency: None, remote_url: None, rev: None }); + let command = Subcommands::Install(Install { + dependency: None, + remote_url: None, + rev: None, + regenerate_remappings: false, + }); match run(command) { Ok(_) => {} @@ -573,9 +444,12 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = Subcommands::Update(Update {}); + let command = Subcommands::Update(Update { regenerate_remappings: false }); match run(command) { Ok(_) => {} @@ -616,9 +490,12 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = Subcommands::Update(Update {}); + let command = Subcommands::Update(Update { regenerate_remappings: false }); match run(command) { Ok(_) => {} @@ -671,17 +548,24 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = - Subcommands::Install(Install { dependency: None, remote_url: None, rev: None }); + let command = Subcommands::Install(Install { + dependency: None, + remote_url: None, + rev: None, + regenerate_remappings: false, + }); match run(command) { Ok(_) => {} Err(err) => { clean_test_env(target_config.clone()); // can not generalize as diff systems return various dns errors - assert!(err.message.contains("Error downloading a dependency will-fail~1")) + assert!(err.to_string().contains("error sending request for url")) } } @@ -711,8 +595,8 @@ libs = ["dependencies"] let command = Subcommands::Push(Push { dependency: "@test~1.1".to_string(), path: Some(path_dependency.clone()), - dry_run: Some(true), - skip_warnings: None, + dry_run: true, + skip_warnings: false, }); match run(command) { @@ -749,8 +633,8 @@ libs = ["dependencies"] let command = Subcommands::Push(Push { dependency: "@test~1.1".to_string(), path: Some(test_dir.clone()), - dry_run: None, - skip_warnings: None, + dry_run: false, + skip_warnings: false, }); match run(command) { @@ -789,8 +673,8 @@ libs = ["dependencies"] let command = Subcommands::Push(Push { dependency: "@test~1.1".to_string(), path: Some(test_dir.clone()), - dry_run: None, - skip_warnings: Some(true), + dry_run: false, + skip_warnings: true, }); match run(command) { @@ -801,7 +685,7 @@ libs = ["dependencies"] clean_test_env(PathBuf::default()); // Check if the error is due to not being logged in - if e.message.contains("You are not logged in") { + if e.to_string().contains("you are not connected") { println!( "Test skipped: User not logged in. This test requires a logged-in state." ); @@ -853,12 +737,16 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } let command = Subcommands::Install(Install { dependency: Some("forge-std~1.9.1".to_string()), remote_url: Option::None, rev: None, + regenerate_remappings: false, }); match run(command) { @@ -904,12 +792,16 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } let command = Subcommands::Install(Install { dependency: Some("forge-std~1.9.1".to_string()), remote_url: Some("https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip".to_string()), rev: None, + regenerate_remappings: false }); match run(command) { @@ -955,12 +847,16 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } let command = Subcommands::Install(Install { dependency: Some("forge-std~1.9.1".to_string()), remote_url: Some("https://github.com/foundry-rs/forge-std.git".to_string()), rev: None, + regenerate_remappings: false, }); match run(command) { @@ -1006,12 +902,16 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } let command = Subcommands::Install(Install { dependency: Some("forge-std~1.9.1".to_string()), remote_url: Some("git@github.com:foundry-rs/forge-std.git".to_string()), rev: None, + regenerate_remappings: false, }); match run(command) { @@ -1057,12 +957,16 @@ libs = ["dependencies"] write_to_config(&target_config, content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } let command = Subcommands::Install(Install { dependency: Some("forge-std~1.9.1".to_string()), remote_url: Some("git@github.com:foundry-rs/forge-std.git".to_string()), rev: Some("3778c3cb8e4244cb5a1c3ef3ce1c71a3683e324a".to_string()), + regenerate_remappings: false, }); match run(command) { @@ -1092,9 +996,12 @@ libs = ["dependencies"] let content = String::new(); write_to_config(&target_config, &content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = Subcommands::Init(Init { clean: None }); + let command = Subcommands::Init(Init { clean: false }); match run(command) { Ok(_) => {} @@ -1141,9 +1048,12 @@ libs = ["dependencies"] let content = String::new(); write_to_config(&target_config, &content); - env::set_var("base_url", "https://api.soldeer.xyz"); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("base_url", "https://api.soldeer.xyz"); + } - let command = Subcommands::Init(Init { clean: Some(true) }); + let command = Subcommands::Init(Init { clean: true }); match run(command) { Ok(_) => {} @@ -1194,7 +1104,10 @@ libs = ["dependencies"] } let path = env::current_dir().unwrap().join("test").join(target); - env::set_var("config_file", path.clone().to_str().unwrap()); + unsafe { + // became unsafe in Rust 1.80 + env::set_var("config_file", path.to_string_lossy().to_string()); + } path } diff --git a/src/lock.rs b/src/lock.rs index 097a22b..9c3ec0f 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -5,11 +5,10 @@ use crate::{ LOCK_FILE, }; use serde_derive::{Deserialize, Serialize}; -use std::{ - fs::{self, remove_file}, - path::PathBuf, -}; -use yansi::Paint; +use std::{fs, path::PathBuf}; +use yansi::Paint as _; + +pub type Result = std::result::Result; // Top level struct to hold the TOML data. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] @@ -20,39 +19,40 @@ pub struct LockEntry { checksum: String, } -impl From<&Dependency> for LockEntry { - fn from(value: &Dependency) -> Self { +impl LockEntry { + #[must_use] + pub fn new( + name: impl Into, + version: impl Into, + source: impl Into, + checksum: impl Into, + ) -> Self { LockEntry { - name: value.name.clone(), - version: value.version.clone(), - source: value.url.clone(), - checksum: value.hash.clone(), + name: name.into(), + version: version.into(), + source: source.into(), + checksum: checksum.into(), } } } -pub fn lock_check(dependency: &Dependency, allow_missing_lockfile: bool) -> Result<(), LockError> { +pub fn lock_check(dependency: &Dependency, allow_missing_lockfile: bool) -> Result<()> { let lock_entries = match read_lock() { Ok(entries) => entries, - Err(_) => { + Err(e) => { if allow_missing_lockfile { return Ok(()); } - return Err(LockError { cause: "Lock does not exists".to_string() }); + return Err(e); } }; let is_locked = lock_entries.iter().any(|lock_entry| { - lock_entry.name == dependency.name && lock_entry.version == dependency.version + lock_entry.name == dependency.name() && lock_entry.version == dependency.version() }); if is_locked { - return Err(LockError { - cause: format!( - "Dependency {}-{} is already installed", - dependency.name, dependency.version - ), - }); + return Err(LockError::DependencyInstalled(dependency.to_string())); } Ok(()) } @@ -63,7 +63,7 @@ pub enum LockWriteMode { Append, } -pub fn write_lock(dependencies: &[Dependency], mode: LockWriteMode) -> Result<(), LockError> { +pub fn write_lock(dependencies: &[Dependency], mode: LockWriteMode) -> Result<()> { let lock_file: PathBuf = if cfg!(test) { get_current_working_dir().join("test").join("soldeer.lock") } else { @@ -71,38 +71,37 @@ pub fn write_lock(dependencies: &[Dependency], mode: LockWriteMode) -> Result<() }; if mode == LockWriteMode::Replace && lock_file.exists() { - remove_file(&lock_file) - .map_err(|_| LockError { cause: "Could not clean lock file".to_string() })?; + fs::remove_file(&lock_file)?; } if !lock_file.exists() { - fs::File::create(&lock_file) - .map_err(|_| LockError { cause: "Could not create lock file".to_string() })?; + fs::File::create(&lock_file)?; } let mut entries = read_lock()?; for dep in dependencies { - let entry: LockEntry = dep.into(); + let entry = match dep { + Dependency::Http(dep) => LockEntry::new( + &dep.name, + &dep.version, + dep.url.as_ref().unwrap(), + dep.checksum.as_ref().unwrap(), + ), + Dependency::Git(dep) => { + LockEntry::new(&dep.name, &dep.version, &dep.git, dep.rev.as_ref().unwrap()) + } + }; // check for entry already existing match entries.iter().position(|e| e.name == entry.name && e.version == entry.version) { Some(pos) => { - println!( - "{}", - Paint::green(&format!( - "Updating {}~{} in the lock file.", - dep.name, dep.version - )) - ); + println!("{}", format!("Updating {dep} in the lock file.").green()); // replace the entry with the new data entries[pos] = entry; } None => { println!( "{}", - Paint::green(&format!( - "Writing {}~{} to the lock file.", - dep.name, dep.version - )) + format!("Writing {}~{} to the lock file.", entry.name, entry.version).green() ); entries.push(entry); } @@ -113,20 +112,18 @@ pub fn write_lock(dependencies: &[Dependency], mode: LockWriteMode) -> Result<() if entries.is_empty() { // remove lock file if there are no deps left - let _ = remove_file(&lock_file); + let _ = fs::remove_file(&lock_file); return Ok(()); } - let file_contents = toml::to_string(&LockType { dependencies: entries }) - .map_err(|_| LockError { cause: "Could not serialize lock file".to_string() })?; + let file_contents = toml_edit::ser::to_string_pretty(&LockType { dependencies: entries })?; // replace contents of lockfile with new contents - fs::write(lock_file, file_contents) - .map_err(|_| LockError { cause: "Could not write to the lock file".to_string() })?; + fs::write(lock_file, file_contents)?; Ok(()) } -pub fn remove_lock(dependency: &Dependency) -> Result<(), LockError> { +pub fn remove_lock(dependency: &Dependency) -> Result<()> { let lock_file: PathBuf = if cfg!(test) { get_current_working_dir().join("test").join("soldeer.lock") } else { @@ -135,21 +132,19 @@ pub fn remove_lock(dependency: &Dependency) -> Result<(), LockError> { let entries: Vec<_> = read_lock()? .into_iter() - .filter(|e| e.name != dependency.name || e.version != dependency.version) + .filter(|e| e.name != dependency.name() || e.version != dependency.version()) .collect(); if entries.is_empty() { // remove lock file if there are no deps left - let _ = remove_file(&lock_file); + let _ = fs::remove_file(&lock_file); return Ok(()); } - let file_contents = toml::to_string(&LockType { dependencies: entries }) - .map_err(|_| LockError { cause: "Could not serialize lock file".to_string() })?; + let file_contents = toml_edit::ser::to_string_pretty(&LockType { dependencies: entries })?; // replace contents of lockfile with new contents - fs::write(lock_file, file_contents) - .map_err(|_| LockError { cause: "Could not write to the lock file".to_string() })?; + fs::write(lock_file, file_contents)?; Ok(()) } @@ -160,7 +155,7 @@ struct LockType { dependencies: Vec, } -fn read_lock() -> Result, LockError> { +fn read_lock() -> Result> { let lock_file: PathBuf = if cfg!(test) { get_current_working_dir().join("test").join("soldeer.lock") } else { @@ -168,20 +163,22 @@ fn read_lock() -> Result, LockError> { }; if !lock_file.exists() { - return Err(LockError { cause: "Lock does not exists".to_string() }); + return Err(LockError::Missing); } - let contents = read_file_to_string(lock_file); // parse file contents - let data: LockType = toml::from_str(&contents).unwrap_or_default(); + let data: LockType = toml_edit::de::from_str(&contents).unwrap_or_default(); Ok(data.dependencies) } #[cfg(test)] mod tests { use super::*; - use crate::{config::Dependency, utils::read_file_to_string}; + use crate::{ + config::{Dependency, HttpDependency}, + utils::read_file_to_string, + }; use serial_test::serial; use std::{fs::File, io::Write}; @@ -215,16 +212,15 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" #[serial] fn lock_file_not_present_test() { let lock_file = check_lock_file(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); + + assert!(matches!(lock_check(&dependency, false), Err(LockError::Missing))); - assert!( - lock_check(&dependency, false).is_err_and(|e| { e.cause == "Lock does not exists" }) - ); assert!(!lock_file.exists()); } @@ -232,33 +228,30 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" #[serial] fn check_lock_all_locked_test() { initialize(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.3.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string(), - hash: String::new(), - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), + checksum: None + }); - assert!(lock_check(&dependency, true).is_err_and(|e| { - e.cause == "Dependency @openzeppelin-contracts-2.3.0 is already installed" - })); + assert!(matches!(lock_check(&dependency, false), Err(LockError::DependencyInstalled(_)))); } #[test] #[serial] fn write_clean_lock_test() { let lock_file = check_lock_file(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.5.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); let dependencies = vec![dependency.clone()]; write_lock(&dependencies, LockWriteMode::Append).unwrap(); - assert!(lock_check(&dependency, true).is_err_and(|e| { - e.cause == "Dependency @openzeppelin-contracts-2.5.0 is already installed" - })); + assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); + let contents = read_file_to_string(lock_file); assert_eq!( @@ -270,9 +263,7 @@ source = "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@o checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" "# ); - assert!(lock_check(&dependency, true).is_err_and(|e| { - e.cause == "Dependency @openzeppelin-contracts-2.5.0 is already installed" - })); + assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); } #[test] @@ -281,12 +272,12 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" let lock_file = check_lock_file(); initialize(); let mut dependencies: Vec = Vec::new(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts-2".to_string(), version: "2.6.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.6.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.6.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); dependencies.push(dependency.clone()); write_lock(&dependencies, LockWriteMode::Append).unwrap(); let contents = read_file_to_string(lock_file); @@ -313,21 +304,19 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" "# ); - assert!(lock_check(&dependency, true).is_err_and(|e| { - e.cause == "Dependency @openzeppelin-contracts-2-2.6.0 is already installed" - })); + assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); } #[test] #[serial] fn remove_lock_single_success() { let lock_file = check_lock_file(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.5.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); let dependencies = vec![dependency.clone()]; write_lock(&dependencies, LockWriteMode::Append).unwrap(); @@ -344,18 +333,18 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" #[serial] fn remove_lock_multiple_success() { let lock_file = check_lock_file(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.5.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; - let dependency2= Dependency { + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); + let dependency2 = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts2".to_string(), version: "2.5.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; + url: Some( "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); let dependencies = vec![dependency.clone(), dependency2.clone()]; write_lock(&dependencies, LockWriteMode::Append).unwrap(); @@ -382,22 +371,22 @@ checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" #[serial] fn remove_lock_one_fails() { let lock_file = check_lock_file(); - let dependency = Dependency { + let dependency = Dependency::Http(HttpDependency { name: "@openzeppelin-contracts".to_string(), version: "2.5.0".to_string(), - url: "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string(), - hash: "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() - }; + url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), + checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) + }); let dependencies = vec![dependency.clone()]; write_lock(&dependencies, LockWriteMode::Append).unwrap(); - match remove_lock(&Dependency { + match remove_lock(&Dependency::Http(HttpDependency { name: "non-existent".to_string(), - version: dependency.version.clone(), - url: String::new(), - hash: String::new(), - }) { + version: dependency.version().to_string(), + url: None, + checksum: None, + })) { Ok(_) => {} Err(_) => { assert_eq!("Invalid State", ""); diff --git a/src/main.rs b/src/main.rs index 5e88963..1171ded 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,13 @@ use clap::Parser; use soldeer::commands::Args; -use yansi::Paint; +use yansi::Paint as _; fn main() { let args = Args::parse(); match soldeer::run(args.command) { Ok(_) => {} Err(err) => { - eprintln!("{}", Paint::red(&err.message)) + eprintln!("{}", err.to_string().red()) } } } diff --git a/src/remote.rs b/src/remote.rs index 74d35d8..1fd5af7 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,21 +1,19 @@ use crate::{ - config::Dependency, - errors::{DownloadError, ProjectNotFound}, + config::{Dependency, HttpDependency}, + dependency_downloader::Result, + errors::DownloadError, utils::get_base_url, }; use chrono::{DateTime, Utc}; use reqwest::Client; use serde_derive::{Deserialize, Serialize}; -pub async fn get_dependency_url_remote( - dependency_name: &String, - dependency_version: &String, -) -> Result { +pub async fn get_dependency_url_remote(dependency: &Dependency) -> Result { let url = format!( "{}/api/v1/revision-cli?project_name={}&revision={}", get_base_url(), - dependency_name, - dependency_version + dependency.name(), + dependency.version() ); let req = Client::new().get(url); @@ -25,24 +23,17 @@ pub async fn get_dependency_url_remote( let revision = serde_json::from_str::(&response_text); if let Ok(revision) = revision { if revision.data.is_empty() { - return Err(DownloadError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: "Could not get the dependency URL".to_string(), - }); + return Err(DownloadError::URLNotFound(dependency.to_string())); } return Ok(revision.data[0].clone().url); } } } - Err(DownloadError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: "Could not get the dependency URL".to_string(), - }) + Err(DownloadError::URLNotFound(dependency.to_string())) } + //TODO clean this up and do error handling -pub async fn get_project_id(dependency_name: &String) -> Result { +pub async fn get_project_id(dependency_name: &str) -> Result { let url = format!("{}/api/v1/project?project_name={}", get_base_url(), dependency_name); let req = Client::new().get(url); let get_project_response = req.send().await; @@ -58,19 +49,15 @@ pub async fn get_project_id(dependency_name: &String) -> Result { - return Err(ProjectNotFound { - name: dependency_name.to_string(), - cause: "Error from the server or check the internet connection." - .to_string(), - }); + return Err(DownloadError::ProjectNotFound(dependency_name.to_string())); } } } } - Err(ProjectNotFound{name: dependency_name.to_string(), cause:"Project not found, please check the dependency name (project name) or create a new project on https://soldeer.xyz".to_string()}) + Err(DownloadError::ProjectNotFound(dependency_name.to_string())) } -pub async fn get_latest_forge_std_dependency() -> Result { +pub async fn get_latest_forge_std_dependency() -> Result { let dependency_name = "forge-std"; let url = format!( "{}/api/v1/revision?project_name={}&offset=0&limit=1", @@ -84,26 +71,18 @@ pub async fn get_latest_forge_std_dependency() -> Result(&response_text); if let Ok(revision) = revision { if revision.data.is_empty() { - return Err(DownloadError { - name: dependency_name.to_string(), - version: "".to_string(), - cause: "Could not get the last forge dependency".to_string(), - }); + return Err(DownloadError::ForgeStdError); } - return Ok(Dependency { + return Ok(Dependency::Http(HttpDependency { name: dependency_name.to_string(), version: revision.data[0].clone().version, - url: revision.data[0].clone().url, - hash: "".to_string(), - }); + url: Some(revision.data[0].clone().url), + checksum: None, + })); } } } - Err(DownloadError { - name: dependency_name.to_string(), - version: "".to_string(), - cause: "Could not get the last forge dependency".to_string(), - }) + Err(DownloadError::ForgeStdError) } #[allow(non_snake_case)] diff --git a/src/utils.rs b/src/utils.rs index a33368e..90f35da 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,23 @@ +use once_cell::sync::Lazy; use regex::Regex; use simple_home_dir::home_dir; use std::{ env, fs::{self, File}, - io::{BufRead, BufReader, Read, Write}, + io::{BufReader, Read, Write}, path::{Path, PathBuf}, }; -use yansi::Paint; +use yansi::Paint as _; + +use crate::config::HttpDependency; + +static GIT_SSH_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^(?:git@github\.com|git@gitlab)").expect("git ssh regex should compile") +}); +static GIT_HTTPS_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^(?:https://github\.com|https://gitlab\.com).*\.git$") + .expect("git https regex should compile") +}); // get the current working directory pub fn get_current_working_dir() -> PathBuf { @@ -35,74 +46,36 @@ pub fn read_file(path: impl AsRef) -> Result, std::io::Error> { Ok(buffer) } -pub fn define_security_file_location() -> String { +/// Get the location where the token file is stored or read from +/// +/// The token file is stored in the home directory of the user, or in the current working directory +/// if the home cannot be found, in a hidden folder called `.soldeer`. The token file is called +/// `.soldeer_login`. +/// +/// For reading (e.g. when pushing to the registry), the path can be overridden by +/// setting the `SOLDEER_LOGIN_FILE` environment variable. +/// For login, the custom path will only be used if the file already exists. +pub fn define_security_file_location() -> Result { let custom_security_file = if cfg!(test) { - return "./test_save_jwt".to_string(); + return Ok(PathBuf::from("./test_save_jwt")); } else { - option_env!("SOLDEER_LOGIN_FILE") + env::var("SOLDEER_LOGIN_FILE").ok() }; if let Some(file) = custom_security_file { - if !file.is_empty() && Path::new(file).exists() { - return file.to_string(); + if !file.is_empty() && Path::new(&file).exists() { + return Ok(file.into()); } } - let home = home_dir(); - match home { - Some(_) => {} - None => { - println!( - "{}", - Paint::red( - "HOME(linux) or %UserProfile%(Windows) path variable is not set, we can not determine the user's home directory. Please define this environment variable or define a custom path for the login file using the SOLDEER_LOGIN_FILE environment variable.", - ) - ); - } - } - let security_directory = home.unwrap().join(".soldeer"); + // if home dir cannot be found, use the current working directory + let dir = home_dir().unwrap_or_else(get_current_working_dir); + let security_directory = dir.join(".soldeer"); if !security_directory.exists() { - fs::create_dir(&security_directory).unwrap(); - } - let security_file = &security_directory.join(".soldeer_login"); - String::from(security_file.to_str().unwrap()) -} - -pub fn remove_empty_lines(filename: &str) { - let file: File = File::open(filename).unwrap(); - - let reader: BufReader = BufReader::new(file); - let mut new_content: String = String::new(); - let lines: Vec<_> = reader.lines().collect(); - let total: usize = lines.len(); - for (index, line) in lines.into_iter().enumerate() { - let line: &String = line.as_ref().unwrap(); - // Making sure the line contains something - if line.len() > 2 { - if index == total - 1 { - new_content.push_str(&line.to_string()); - } else { - new_content.push_str(&format!("{}\n", line)); - } - } - } - - // Removing the annoying new lines at the end and beginning of the file - new_content = String::from(new_content.trim_end_matches('\n')); - new_content = String::from(new_content.trim_start_matches('\n')); - let mut file: std::fs::File = fs::OpenOptions::new() - .write(true) - .truncate(true) - .append(false) - .open(Path::new(filename)) - .unwrap(); - - match write!(file, "{}", &new_content) { - Ok(_) => {} - Err(e) => { - eprintln!("Couldn't write to file: {}", e); - } + fs::create_dir(&security_directory)?; } + let security_file = security_directory.join(".soldeer_login"); + Ok(security_file) } pub fn get_base_url() -> String { @@ -114,48 +87,43 @@ pub fn get_base_url() -> String { } // Function to check for the presence of sensitive files or directories -pub fn check_dotfiles(path: &Path) -> bool { - if let Ok(entries) = fs::read_dir(path) { - for entry in entries.flatten() { - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - if file_name_str.starts_with('.') { - return true; - } - } +pub fn check_dotfiles(path: impl AsRef) -> bool { + if !path.as_ref().is_dir() { + return false; } - false + fs::read_dir(path) + .unwrap() + .map_while(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with('.')) } // Function to recursively check for sensitive files or directories in a given path -pub fn check_dotfiles_recursive(path: &Path) -> bool { - if check_dotfiles(path) { +pub fn check_dotfiles_recursive(path: impl AsRef) -> bool { + if check_dotfiles(&path) { return true; } - if path.is_dir() { - for entry in fs::read_dir(path).unwrap() { - let entry = entry.unwrap(); - let entry_path = entry.path(); - if check_dotfiles_recursive(&entry_path) { - return true; - } - } + if path.as_ref().is_dir() { + return fs::read_dir(path) + .unwrap() + .map_while(Result::ok) + .any(|entry| check_dotfiles(entry.path())); } - false } // Function to prompt the user for confirmation pub fn prompt_user_for_confirmation() -> bool { - println!("{}", Paint::yellow( - "You are about to include some sensitive files in this version. Are you sure you want to continue?" - )); - println!("{}", Paint::cyan( - "If you are not sure what sensitive files, you can run the dry-run command to check what will be pushed." - )); - - print!("{}", Paint::green("Do you want to continue? (y/n): ")); + println!( + "{}", + "You are about to include some sensitive files in this version. Are you sure you want to continue?".yellow() + ); + println!( + "{}", + "If you are not sure what sensitive files, you can run the dry-run command to check what will be pushed.".cyan() + ); + + print!("{}", "Do you want to continue? (y/n): ".green()); std::io::stdout().flush().unwrap(); let mut input = String::new(); @@ -164,31 +132,31 @@ pub fn prompt_user_for_confirmation() -> bool { input == "y" || input == "yes" } -pub fn get_download_tunnel(dependency_url: &str) -> String { - let pattern1 = r"^(git@github\.com|git@gitlab)"; - let pattern2 = r"^(https://github\.com|https://gitlab\.com)"; - let re1 = Regex::new(pattern1).unwrap(); - let re2 = Regex::new(pattern2).unwrap(); - if re1.is_match(dependency_url) || - (re2.is_match(dependency_url) && dependency_url.ends_with(".git")) - { - return "git".to_string(); +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum UrlType { + Git, + Http, +} + +pub fn get_url_type(dependency_url: &str) -> UrlType { + if GIT_SSH_REGEX.is_match(dependency_url) || GIT_HTTPS_REGEX.is_match(dependency_url) { + return UrlType::Git; } - "http".to_string() + UrlType::Http } #[cfg(not(test))] -pub fn sha256_digest(dependency_name: &str, dependency_version: &str) -> String { +pub fn sha256_digest(dependency: &HttpDependency) -> String { use crate::DEPENDENCY_DIR; let bytes = std::fs::read( - DEPENDENCY_DIR.join(format!("{}-{}.zip", dependency_name, dependency_version)), + DEPENDENCY_DIR.join(format!("{}-{}.zip", dependency.name, dependency.version)), ) .unwrap(); // Vec sha256::digest(bytes) } #[cfg(test)] -pub fn sha256_digest(_dependency_name: &str, _dependency_version: &str) -> String { +pub fn sha256_digest(_dependency: &HttpDependency) -> String { "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string() } diff --git a/src/versioning.rs b/src/versioning.rs index a4033b6..ea9dce6 100644 --- a/src/versioning.rs +++ b/src/versioning.rs @@ -1,9 +1,10 @@ use crate::{ auth::get_token, - errors::PushError, + errors::{AuthError, PublishError}, remote::get_project_id, utils::{get_base_url, get_current_working_dir, read_file, read_file_to_string}, }; +use regex::Regex; use reqwest::{ header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, multipart::{Form, Part}, @@ -15,37 +16,27 @@ use std::{ path::{Path, PathBuf}, }; use walkdir::WalkDir; -use yansi::Paint; +use yansi::Paint as _; use yash_fnmatch::{without_escape, Pattern}; use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; -#[derive(Clone, Debug)] -struct FilePair { - name: String, - path: String, -} +pub type Result = std::result::Result; pub async fn push_version( - dependency_name: &String, - dependency_version: &String, + dependency_name: &str, + dependency_version: &str, root_directory_path: PathBuf, dry_run: bool, -) -> Result<(), PushError> { +) -> Result<()> { let file_name = root_directory_path.file_name().expect("path should have a last component"); println!( "{}", - Paint::green(&format!("Pushing a dependency {}-{}:", dependency_name, dependency_version)) + format!("Pushing a dependency {}-{}:", dependency_name, dependency_version).green() ); - let files_to_copy: Vec = filter_files_to_copy(&root_directory_path); + let files_to_copy: Vec = filter_files_to_copy(&root_directory_path); - let zip_archive = match zip_file( - dependency_name, - dependency_version, - &root_directory_path, - &files_to_copy, - file_name, - ) { + let zip_archive = match zip_file(&root_directory_path, &files_to_copy, file_name) { Ok(zip) => zip, Err(err) => { return Err(err); @@ -69,88 +60,61 @@ pub async fn push_version( Ok(()) } +pub fn validate_name(name: &str) -> Result<()> { + let regex = Regex::new(r"^[@|a-z0-9][a-z0-9-]*[a-z0-9]$").unwrap(); + if !regex.is_match(name) { + return Err(PublishError::InvalidName); + } + Ok(()) +} + fn zip_file( - dependency_name: &String, - dependency_version: &String, root_directory_path: &Path, - files_to_copy: &Vec, + files_to_copy: &Vec, file_name: impl Into, -) -> Result { - let root_dir_as_string = root_directory_path.to_str().unwrap(); +) -> Result { let mut file_name: PathBuf = file_name.into(); file_name.set_extension("zip"); let zip_file_path = root_directory_path.join(file_name); - let file = File::create(zip_file_path.to_str().unwrap()).unwrap(); + let file = File::create(&zip_file_path).unwrap(); let mut zip = ZipWriter::new(file); let options = SimpleFileOptions::default().compression_method(CompressionMethod::DEFLATE); if files_to_copy.is_empty() { - return Err(PushError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: "No files to push".to_string(), - }); + return Err(PublishError::NoFiles); } for file_path in files_to_copy { - let file_to_copy = File::open(file_path.path.clone()).unwrap(); - let file_to_copy_name = file_path.name.clone(); - let path = Path::new(&file_path.path); + let file_to_copy = File::open(file_path.clone()) + .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; + let path = Path::new(&file_path); let mut buffer = Vec::new(); // This is the relative path, we basically get the relative path to the target folder that // we want to push and zip that as a name so we won't screw up the file/dir // hierarchy in the zip file. - let relative_file_path = file_path.path.to_string().replace(root_dir_as_string, ""); + let relative_file_path = file_path.strip_prefix(root_directory_path)?; // Write file or directory explicitly // Some unzip tools unzip files with directory paths correctly, some do not! if path.is_file() { - match zip.start_file(relative_file_path, options) { - Ok(_) => {} - Err(err) => { - return Err(PushError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: format!("Zipping failed. Could not start to zip: {}", err), - }); - } - } - match io::copy(&mut file_to_copy.take(u64::MAX), &mut buffer) { - Ok(_) => {} - Err(err) => { - return Err(PushError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: format!( - "Zipping failed, could not read file {} because of the error {}", - file_to_copy_name, err - ), - }); - } - } - match zip.write_all(&buffer) { - Ok(_) => {} - Err(err) => { - return Err(PushError { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - cause: format!("Zipping failed. Could not write to zip: {}", err), - }); - } - } - } else if !path.as_os_str().is_empty() { - let _ = zip.add_directory(&file_path.path, options); + zip.start_file(relative_file_path.to_string_lossy(), options)?; + io::copy(&mut file_to_copy.take(u64::MAX), &mut buffer) + .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; + zip.write_all(&buffer) + .map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?; + } else if path.is_dir() { + let _ = zip.add_directory(file_path.to_string_lossy(), options); } } let _ = zip.finish(); Ok(zip_file_path) } -fn filter_files_to_copy(root_directory_path: &Path) -> Vec { +fn filter_files_to_copy(root_directory_path: &Path) -> Vec { let ignore_files: Vec = read_ignore_file(); let root_directory: &str = &(root_directory_path.to_str().unwrap().to_owned() + "/"); - let mut files_to_copy: Vec = Vec::new(); + let mut files_to_copy: Vec = Vec::new(); for entry in WalkDir::new(root_directory).into_iter().filter_map(|e| e.ok()) { let is_dir = entry.path().is_dir(); let file_path: String = entry.path().to_str().unwrap().to_string(); @@ -171,15 +135,7 @@ fn filter_files_to_copy(root_directory_path: &Path) -> Vec { continue; } - files_to_copy.push(FilePair { - name: entry - .path() - .file_name() - .expect("path should have a last component") - .to_string_lossy() - .into_owned(), - path: entry.path().to_str().unwrap().to_string(), - }); + files_to_copy.push(entry.path().to_path_buf()); } files_to_copy } @@ -221,19 +177,10 @@ fn escape_lines(lines: Vec<&str>) -> Vec { async fn push_to_repo( zip_file: &Path, - dependency_name: &String, - dependency_version: &String, -) -> Result<(), PushError> { - let token = match get_token() { - Ok(result) => result, - Err(err) => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: err.cause, - }); - } - }; + dependency_name: &str, + dependency_version: &str, +) -> Result<()> { + let token = get_token()?; let client = Client::new(); let url = format!("{}/api/v1/revision/upload", get_base_url()); @@ -257,20 +204,11 @@ async fn push_to_repo( // set the mime as app zip part = part.mime_str("application/zip").expect("Could not set mime type"); - let project_id = match get_project_id(dependency_name).await { - Ok(id) => id, - Err(err) => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: err.cause, - }); - } - }; + let project_id = get_project_id(dependency_name).await?; let form = Form::new() .text("project_id", project_id) - .text("revision", dependency_version.clone()) + .text("revision", dependency_version.to_string()) .part("zip_name", part); headers.insert( @@ -282,44 +220,19 @@ async fn push_to_repo( let response = res.await.unwrap(); match response.status() { - StatusCode::OK => println!("{}", Paint::green("Success!")), - StatusCode::NO_CONTENT => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: "Project not found. Make sure you send the right dependency name.\nThe dependency name is the project name you created on https://soldeer.xyz".to_string(), - }); - } - StatusCode::ALREADY_REPORTED => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: "Dependency already exists".to_string(), - }); - } - StatusCode::UNAUTHORIZED => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: "Unauthorized. Please login".to_string(), - }); - } - StatusCode::PAYLOAD_TOO_LARGE => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: "The package is too big, it has over 50 MB".to_string(), - }); + StatusCode::OK => { + println!("{}", "Success!".green()); + Ok(()) } - _ => { - return Err(PushError { - name: (&dependency_name).to_string(), - version: (&dependency_version).to_string(), - cause: format!("The server returned an unexpected error {:?}", response.status()), - }); + StatusCode::NO_CONTENT => Err(PublishError::ProjectNotFound), + StatusCode::ALREADY_REPORTED => Err(PublishError::AlreadyExists), + StatusCode::UNAUTHORIZED => Err(PublishError::AuthError(AuthError::InvalidCredentials)), + StatusCode::PAYLOAD_TOO_LARGE => Err(PublishError::PayloadTooLarge), + s if s.is_server_error() || s.is_client_error() => { + Err(PublishError::HttpError(response.error_for_status().unwrap_err())) } + _ => Err(PublishError::UnknownError), } - Ok(()) } #[cfg(test)] @@ -424,7 +337,7 @@ mod tests { let result = filter_files_to_copy(&target_dir); assert_eq!(filtered_files.len(), result.len()); let file = Path::new(&filtered_files[0]); - assert_eq!(file.file_name().unwrap().to_string_lossy(), result[0].name); + assert_eq!(file, result[0]); let _ = remove_file(gitignore); let _ = remove_dir_all(target_dir); @@ -466,16 +379,12 @@ mod tests { // --- --- --- --- zip <= ignored // --- --- --- --- toml <= ignored - let random_dir = PathBuf::from(create_random_directory(&target_dir, "".to_string())); - let broadcast_dir = - PathBuf::from(create_random_directory(&target_dir, "broadcast".to_string())); + let random_dir = create_random_directory(&target_dir, "".to_string()); + let broadcast_dir = create_random_directory(&target_dir, "broadcast".to_string()); - let the_31337_dir = - PathBuf::from(create_random_directory(&broadcast_dir, "31337".to_string())); - let random_dir_in_broadcast = - PathBuf::from(create_random_directory(&broadcast_dir, "".to_string())); - let dry_run_dir = - PathBuf::from(create_random_directory(&random_dir_in_broadcast, "dry_run".to_string())); + let the_31337_dir = create_random_directory(&broadcast_dir, "31337".to_string()); + let random_dir_in_broadcast = create_random_directory(&broadcast_dir, "".to_string()); + let dry_run_dir = create_random_directory(&random_dir_in_broadcast, "dry_run".to_string()); ignored_files.push(create_random_file(&random_dir, "toml".to_string())); filtered_files.push(create_random_file(&random_dir, "zip".to_string())); @@ -504,11 +413,11 @@ mod tests { // for each result we just just to see if a file (not a dir) is in the filtered results for res in result { - if PathBuf::from(&res.path).is_dir() { + if PathBuf::from(&res).is_dir() { continue; } - assert!(filtered_files.contains(&res.path)); + assert!(filtered_files.contains(&res)); } let _ = remove_file(gitignore); @@ -534,25 +443,19 @@ mod tests { // --- random_file_1.txt let random_dir_1 = create_random_directory(&target_dir, "".to_string()); let random_dir_2 = create_random_directory(Path::new(&random_dir_1), "".to_string()); - let random_file_1 = create_random_file(&target_dir, ".txt".to_string()); - let random_file_2 = create_random_file(Path::new(&random_dir_1), ".txt".to_string()); - let random_file_3 = create_random_file(Path::new(&random_dir_2), ".txt".to_string()); - - let dep_name = "test_dep".to_string(); - let dep_version = "1.1".to_string(); - let files_to_copy: Vec = vec![ - FilePair { name: "random_file_1".to_string(), path: random_file_1.clone() }, - FilePair { name: "random_file_1".to_string(), path: random_file_3.clone() }, - FilePair { name: "random_file_1".to_string(), path: random_file_2.clone() }, - ]; - let result = - match zip_file(&dep_name, &dep_version, &target_dir, &files_to_copy, "test_zip") { - Ok(r) => r, - Err(_) => { - assert_eq!("Invalid State", ""); - return; - } - }; + let random_file_1 = create_random_file(&target_dir, "txt".to_string()); + let random_file_2 = create_random_file(Path::new(&random_dir_1), "txt".to_string()); + let random_file_3 = create_random_file(Path::new(&random_dir_2), "txt".to_string()); + + let files_to_copy: Vec = + vec![random_file_1.clone(), random_file_3.clone(), random_file_2.clone()]; + let result = match zip_file(&target_dir, &files_to_copy, "test_zip") { + Ok(r) => r, + Err(_) => { + assert_eq!("Invalid State", ""); + return; + } + }; // unzipping for checks let archive = read_file(result).unwrap(); @@ -563,9 +466,13 @@ mod tests { } } - let random_file_1_unzipped = random_file_1.replace("test_zip", "test_unzip"); - let random_file_2_unzipped = random_file_2.replace("test_zip", "test_unzip"); - let random_file_3_unzipped = random_file_3.replace("test_zip", "test_unzip"); + let mut random_file_1_unzipped = target_dir_unzip.clone(); + random_file_1_unzipped.push(random_file_1.strip_prefix(&target_dir).unwrap()); + let mut random_file_2_unzipped = target_dir_unzip.clone(); + random_file_2_unzipped.push(random_file_2.strip_prefix(&target_dir).unwrap()); + let mut random_file_3_unzipped = target_dir_unzip.clone(); + random_file_3_unzipped.push(random_file_3.strip_prefix(&target_dir).unwrap()); + println!("{random_file_3_unzipped:?}"); assert!(Path::new(&random_file_1_unzipped).exists()); assert!(Path::new(&random_file_2_unzipped).exists()); @@ -595,7 +502,7 @@ mod tests { } } - fn create_random_file(target_dir: &Path, extension: String) -> String { + fn create_random_file(target_dir: &Path, extension: String) -> PathBuf { let s: String = rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); let target = target_dir.join(format!("random{}.{}", s, extension)); @@ -604,20 +511,20 @@ mod tests { if let Err(e) = write!(file, "this is a test file") { eprintln!("Couldn't write to the config file: {}", e); } - String::from(target.to_str().unwrap()) + target } - fn create_random_directory(target_dir: &Path, name: String) -> String { + fn create_random_directory(target_dir: &Path, name: String) -> PathBuf { let s: String = rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); if name.is_empty() { let target = target_dir.join(format!("random{}", s)); let _ = create_dir_all(&target); - return String::from(target.to_str().unwrap()); + target } else { let target = target_dir.join(name); let _ = create_dir_all(&target); - return String::from(target.to_str().unwrap()); + target } } } diff --git a/tests/ci/foundry.rs b/tests/ci/foundry.rs index 4a05d7d..b4406b2 100644 --- a/tests/ci/foundry.rs +++ b/tests/ci/foundry.rs @@ -1,7 +1,7 @@ +use clap::Parser as _; use serial_test::serial; use soldeer::{ - commands::{Install, Subcommands}, - errors::SoldeerError, + commands::{Args, Install, Subcommands}, DEPENDENCY_DIR, LOCK_FILE, }; use std::{ @@ -24,6 +24,7 @@ fn soldeer_install_valid_dependency() { dependency: Some("forge-std~1.8.2".to_string()), remote_url: None, rev: None, + regenerate_remappings: false, }); match soldeer::run(command) { @@ -92,15 +93,27 @@ contract TestSoldeer is Test { env::current_dir().unwrap().join("dependencies").join("forge-std-1.8.2"), test_project.join("dependencies").join("forge-std-1.8.2"), ); + let foundry_content = r#" - let _ = fs::copy( - env::current_dir().unwrap().join("foundry.toml"), - test_project.join("foundry.toml"), - ); +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config + +[profile.default] +script = "script" +solc = "0.8.26" +src = "src" +test = "test" +libs = ["dependencies"] + +[dependencies] +forge-std = "1.8.2" + +"#; - let _ = fs::copy( - env::current_dir().unwrap().join("remappings.txt"), + let _ = fs::write(test_project.join("foundry.toml"), foundry_content); + + let _ = fs::write( test_project.join("remappings.txt"), + "@forge-std-1.8.2=dependencies/forge-std-1.8.2", ); let output = Command::new("forge") @@ -112,7 +125,7 @@ contract TestSoldeer is Test { let passed = String::from_utf8(output.stdout).unwrap().contains("[PASS]"); if !passed { - println!("This will fail with: {:?}", String::from_utf8(output.stderr).unwrap()); + eprintln!("This failed with: {:?}", String::from_utf8(output.stderr).unwrap()); } assert!(passed); clean_test_env(&test_project); @@ -121,25 +134,7 @@ contract TestSoldeer is Test { #[test] #[serial] fn soldeer_install_invalid_dependency() { - let command = Subcommands::Install(Install { - dependency: Some("forge-std".to_string()), - remote_url: None, - rev: None, - }); - - match soldeer::run(command) { - Ok(_) => { - assert_eq!("Invalid State", "") - } - Err(err) => { - assert_eq!( - err, - SoldeerError{ - message: "Dependency forge-std does not specify a version.\nThe format should be [DEPENDENCY]~[VERSION]".to_string() - } - ); - } - } + assert!(Args::try_parse_from(["soldeer", "install", "forge-std"]).is_err()); let path_dependency = DEPENDENCY_DIR.join("forge-std"); let path_zip = DEPENDENCY_DIR.join("forge-std.zip");