diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index f5ef2b1b0..666b8c456 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -115,6 +115,10 @@ openssl = { version = "=0.10.55", features = ["vendored"] } [build-dependencies] crate-git-revision = "0.0.4" +serde.workspace = true +thiserror.workspace = true +ureq = { version = "2.9.1", features = ["json"] } + [dev-dependencies] assert_cmd = "2.0.4" diff --git a/cmd/soroban-cli/build.rs b/cmd/soroban-cli/build.rs index b6e6dd92a..82e17a105 100644 --- a/cmd/soroban-cli/build.rs +++ b/cmd/soroban-cli/build.rs @@ -1,3 +1,124 @@ fn main() { crate_git_revision::init(); + build_helper::set_example_contracts(); +} + +mod build_helper { + use std::{ + fs::{metadata, File, Metadata}, + io::{self, Write}, + path::{Path, PathBuf}, + }; + + const GITHUB_API_URL: &str = + "https://api.github.com/repos/stellar/soroban-examples/git/trees/main?recursive=1"; + + pub fn set_example_contracts() { + let example_contracts = get_example_contracts().unwrap(); + let w = &mut std::io::stdout(); + set_example_contracts_env_var(w, &example_contracts).unwrap(); + } + + #[derive(serde::Deserialize, Debug)] + struct RepoPath { + path: String, + #[serde(rename = "type")] + type_field: String, + } + + #[derive(serde::Deserialize, Debug)] + struct ReqBody { + tree: Vec, + } + + #[derive(thiserror::Error, Debug)] + pub enum Error { + #[error("Failed to complete get request")] + UreqError(#[from] Box), + + #[error("Io error: {0}")] + IoError(#[from] std::io::Error), + } + + fn get_example_contracts() -> Result { + if file_exists(&cached_example_contracts_file_path()) { + let example_contracts = std::fs::read_to_string(cached_example_contracts_file_path())?; + return Ok(example_contracts); + } + + Ok(fetch_and_cache_example_contracts()) + } + + fn fetch_and_cache_example_contracts() -> String { + let example_contracts = fetch_example_contracts().unwrap().join(","); + let cached_example_contracts = target_dir().join("example_contracts.txt"); + + if let Err(err) = write_cache(&cached_example_contracts, &example_contracts) { + eprintln!("Error writing cache: {err}"); + } + + example_contracts + } + + fn fetch_example_contracts() -> Result, Error> { + let body: ReqBody = ureq::get(GITHUB_API_URL) + .call() + .map_err(Box::new)? + .into_json()?; + let mut valid_examples = Vec::new(); + for item in body.tree { + if item.type_field == "blob" + || item.path.starts_with('.') + || item.path.contains('/') + || item.path == "hello_world" + { + continue; + } + + valid_examples.push(item.path); + } + + Ok(valid_examples) + } + + fn set_example_contracts_env_var( + w: &mut impl std::io::Write, + example_contracts: &str, + ) -> std::io::Result<()> { + writeln!(w, "cargo:rustc-env=EXAMPLE_CONTRACTS={example_contracts}")?; + Ok(()) + } + + fn cached_example_contracts_file_path() -> PathBuf { + target_dir().join("example_contracts.txt") + } + + fn target_dir() -> PathBuf { + project_root().join("target") + } + + fn project_root() -> PathBuf { + Path::new(&env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() + } + + fn write_cache(cache_file_path: &Path, data: &str) -> io::Result<()> { + // Create or open the cache file + let mut file = File::create(cache_file_path)?; + + // Write the data to the cache file + file.write_all(data.as_bytes())?; + + Ok(()) + } + + fn file_exists(file_path: &Path) -> bool { + metadata(file_path) + .as_ref() + .map(Metadata::is_file) + .unwrap_or(false) + } } diff --git a/cmd/soroban-cli/src/commands/contract/init.rs b/cmd/soroban-cli/src/commands/contract/init.rs index e0f300db9..e429561ba 100644 --- a/cmd/soroban-cli/src/commands/contract/init.rs +++ b/cmd/soroban-cli/src/commands/contract/init.rs @@ -1,7 +1,10 @@ use std::{ env, ffi::OsStr, - fs::{copy, create_dir_all, metadata, read_dir, read_to_string, write, File, OpenOptions}, + fs::{ + copy, create_dir_all, metadata, read_dir, read_to_string, write, File, Metadata, + OpenOptions, + }, io::{self, Read, Write}, num::NonZeroU32, path::Path, @@ -17,10 +20,12 @@ use gix::{clone, create, open, progress, remote}; use rust_embed::RustEmbed; use serde_json::{from_str, json, to_string_pretty, Error as JsonError, Value as JsonValue}; use toml_edit::{Document, Formatted, InlineTable, Item, TomlError, Value as TomlValue}; -use ureq::{get, Error as UreqError}; +use ureq::get; const SOROBAN_EXAMPLES_URL: &str = "https://github.com/stellar/soroban-examples.git"; const GITHUB_URL: &str = "https://github.com"; +const WITH_EXAMPLE_LONG_HELP_TEXT: &str = + "An optional flag to specify Soroban example contracts to include. A hello-world contract will be included by default."; #[derive(Clone, Debug, ValueEnum, PartialEq)] pub enum FrontendTemplate { @@ -33,7 +38,7 @@ pub enum FrontendTemplate { pub struct Cmd { pub project_path: String, - #[arg(short, long, num_args = 1.., value_parser=possible_example_values(), long_help=with_example_help())] + #[arg(short, long, num_args = 1.., value_parser=possible_example_values(), long_help=WITH_EXAMPLE_LONG_HELP_TEXT)] pub with_example: Vec, #[arg( @@ -46,46 +51,11 @@ pub struct Cmd { } fn possible_example_values() -> ValueParser { - let parser = PossibleValuesParser::new( - [ - "account", - "alloc", - "atomic_multiswap", - "atomic_swap", - "auth", - "cross_contract", - "custom_types", - "deep_contract_auth", - "deployer", - "errors", - "eth_abi", - "events", - "fuzzing", - "increment", - "liquidity_pool", - "logging", - "mint-lock", - "simple_account", - "single_offer", - "timelock", - "token", - "upgradeable_contract", - "workspace", - ] - .iter() - .map(PossibleValue::new), - ); + let example_contracts = env!("EXAMPLE_CONTRACTS").split(',').collect::>(); + let parser = PossibleValuesParser::new(example_contracts.iter().map(PossibleValue::new)); parser.into() } -fn with_example_help() -> String { - if check_internet_connection() { - "An optional flag to specify Soroban example contracts to include. A hello-world contract will be included by default.".to_owned() - } else { - "⚠️ Failed to fetch additional example contracts from soroban-examples repo. You can continue with initializing - the default hello_world contract will still be included".to_owned() - } -} - #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Io error: {0}")] @@ -105,9 +75,6 @@ pub enum Error { #[error("Failed to parse toml file: {0}")] TomlParseError(#[from] TomlError), - #[error("Failed to complete get request")] - UreqError(#[from] Box), - #[error("Failed to parse package.json file: {0}")] JsonParseError(#[from] JsonError), @@ -184,7 +151,7 @@ fn copy_template_files(project_path: &Path) -> Result<(), Error> { for item in TemplateFiles::iter() { let mut to = project_path.join(item.as_ref()); - if file_exists(&to.to_string_lossy()) { + if file_exists(&to) { println!( "ℹ️ Skipped creating {} as it already exists", &to.to_string_lossy() @@ -250,7 +217,7 @@ fn copy_contents(from: &Path, to: &Path) -> Result<(), Error> { })?; copy_contents(&path, &new_path)?; } else { - if file_exists(&new_path.to_string_lossy()) { + if file_exists(&new_path) { if new_path.to_string_lossy().contains(".gitignore") { append_contents(&path, &new_path)?; } @@ -280,12 +247,11 @@ fn copy_contents(from: &Path, to: &Path) -> Result<(), Error> { Ok(()) } -fn file_exists(file_path: &str) -> bool { - if let Ok(metadata) = metadata(file_path) { - metadata.is_file() - } else { - false - } +fn file_exists(file_path: &Path) -> bool { + metadata(file_path) + .as_ref() + .map(Metadata::is_file) + .unwrap_or(false) } fn include_example_contracts(contracts: &[String]) -> bool {