stellar_scaffold_cli/commands/generate/contract/
mod.rs1use clap::Parser;
2use flate2::read::GzDecoder;
3use reqwest;
4use serde::Deserialize;
5use std::{fs, path::Path};
6use tar::Archive;
7
8#[derive(Deserialize)]
9struct Release {
10 tag_name: String,
11}
12
13#[derive(Parser, Debug)]
14pub struct Cmd {
15 #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
17 pub from: Option<String>,
18
19 #[arg(long, conflicts_with_all = ["from", "from_wizard"])]
21 pub ls: bool,
22
23 #[arg(long, conflicts_with_all = ["from", "ls"])]
25 pub from_wizard: bool,
26
27 #[arg(short, long)]
29 pub output: Option<String>,
30}
31
32#[derive(thiserror::Error, Debug)]
33pub enum Error {
34 #[error(transparent)]
35 Io(#[from] std::io::Error),
36 #[error(transparent)]
37 Reqwest(#[from] reqwest::Error),
38 #[error(transparent)]
39 CargoToml(#[from] cargo_toml::Error),
40 #[error(transparent)]
41 TomlDeserialize(#[from] toml::de::Error),
42 #[error(transparent)]
43 TomlSerialize(#[from] toml::ser::Error),
44 #[error("Git command failed: {0}")]
45 GitCloneFailed(String),
46 #[error("Example '{0}' not found in OpenZeppelin stellar-contracts")]
47 ExampleNotFound(String),
48 #[error("Failed to open browser: {0}")]
49 BrowserFailed(String),
50 #[error("No action specified. Use --from, --ls, or --from-wizard")]
51 NoActionSpecified,
52}
53
54impl Cmd {
55 pub async fn run(&self) -> Result<(), Error> {
56 match (&self.from, self.ls, self.from_wizard) {
57 (Some(example_name), _, _) => self.clone_example(example_name).await,
58 (_, true, _) => self.list_examples().await,
59 (_, _, true) => open_wizard(),
60 _ => Err(Error::NoActionSpecified),
61 }
62 }
63
64 async fn clone_example(&self, example_name: &str) -> Result<(), Error> {
65 eprintln!("š Downloading example '{example_name}'...");
66
67 let dest_path = self
68 .output
69 .clone()
70 .unwrap_or_else(|| format!("contracts/{example_name}"));
71
72 let repo_cache_path = self.ensure_cache_updated().await?;
73
74 let example_source_path = repo_cache_path.join(format!("examples/{example_name}"));
76 if !example_source_path.exists() {
77 return Err(Error::ExampleNotFound(example_name.to_string()));
78 }
79
80 fs::create_dir_all(&dest_path)?;
82 Self::copy_directory_contents(&example_source_path, Path::new(&dest_path))?;
83
84 let Release { tag_name } = Self::fetch_latest_release().await?;
86
87 let workspace_cargo_path = Path::new("Cargo.toml");
89 if workspace_cargo_path.exists() {
90 Self::update_workspace_dependencies(
91 workspace_cargo_path,
92 &example_source_path,
93 &tag_name,
94 )?;
95 } else {
96 eprintln!("ā ļø Warning: No workspace Cargo.toml found in current directory.");
97 eprintln!(" You'll need to manually add required dependencies to your workspace.");
98 }
99
100 eprintln!("ā
Successfully downloaded example '{example_name}' to {dest_path}");
101 eprintln!("š” You may need to modify your environments.toml to add constructor arguments!");
102 Ok(())
103 }
104
105 fn update_workspace_dependencies(
106 workspace_path: &Path,
107 example_path: &Path,
108 tag: &str,
109 ) -> Result<(), Error> {
110 let example_cargo_content = fs::read_to_string(example_path.join("Cargo.toml"))?;
111 let deps = Self::extract_stellar_dependencies(&example_cargo_content)?;
112 if deps.is_empty() {
113 return Ok(());
114 }
115
116 let mut manifest = cargo_toml::Manifest::from_path(workspace_path)?;
118
119 if manifest.workspace.is_none() {
121 let workspace_toml = r"
123[workspace]
124members = []
125
126[workspace.dependencies]
127";
128 let workspace: cargo_toml::Workspace<toml::Value> = toml::from_str(workspace_toml)?;
129 manifest.workspace = Some(workspace);
130 }
131 let workspace = manifest.workspace.as_mut().unwrap();
132
133 let mut workspace_deps = workspace.dependencies.clone();
134
135 let mut added_deps = Vec::new();
136 let mut updated_deps = Vec::new();
137
138 for dep in deps {
139 let git_dep = cargo_toml::DependencyDetail {
140 git: Some("https://github.com/OpenZeppelin/stellar-contracts".to_string()),
141 tag: Some(tag.to_string()),
142 ..Default::default()
143 };
144
145 if let Some(existing_dep) = workspace_deps.clone().get(&dep) {
146 if let cargo_toml::Dependency::Detailed(detail) = existing_dep {
148 if let Some(existing_tag) = &detail.tag {
149 if existing_tag != tag {
150 workspace_deps.insert(
151 dep.clone(),
152 cargo_toml::Dependency::Detailed(Box::new(git_dep)),
153 );
154 updated_deps.push((dep, existing_tag.clone()));
155 }
156 }
157 }
158 } else {
159 workspace_deps.insert(
160 dep.clone(),
161 cargo_toml::Dependency::Detailed(Box::new(git_dep)),
162 );
163 added_deps.push(dep);
164 }
165 }
166
167 if !added_deps.is_empty() || !updated_deps.is_empty() {
168 workspace.dependencies = workspace_deps;
169 let toml_string = toml::to_string_pretty(&manifest)?;
171 fs::write(workspace_path, toml_string)?;
172
173 if !added_deps.is_empty() {
174 eprintln!("š¦ Added the following dependencies to workspace:");
175 for dep in added_deps {
176 eprintln!(" ⢠{dep}");
177 }
178 }
179
180 if !updated_deps.is_empty() {
181 eprintln!("š Updated the following dependencies:");
182 for (dep, old_tag) in updated_deps {
183 eprintln!(" ⢠{dep}: {old_tag} -> {tag}");
184 }
185 }
186 }
187
188 Ok(())
189 }
190
191 fn extract_stellar_dependencies(cargo_toml_content: &str) -> Result<Vec<String>, Error> {
192 let manifest: cargo_toml::Manifest = toml::from_str(cargo_toml_content)?;
193
194 Ok(manifest
195 .dependencies
196 .iter()
197 .filter(|(dep_name, _)| dep_name.starts_with("stellar-"))
198 .filter_map(|(dep_name, dep_detail)| match dep_detail {
199 cargo_toml::Dependency::Detailed(detail)
200 if !(detail.inherited || detail.git.is_some()) =>
201 {
202 None
203 }
204 _ => Some(dep_name.clone()),
205 })
206 .collect())
207 }
208
209 async fn list_examples(&self) -> Result<(), Error> {
210 eprintln!("š Fetching available contract examples...");
211
212 let repo_cache_path = self.ensure_cache_updated().await?;
213 let examples_path = repo_cache_path.join("examples");
214
215 let mut examples: Vec<String> = if examples_path.exists() {
216 fs::read_dir(examples_path)?
217 .filter_map(std::result::Result::ok)
218 .filter(|entry| entry.path().is_dir())
219 .filter_map(|entry| {
220 entry
221 .file_name()
222 .to_str()
223 .map(std::string::ToString::to_string)
224 })
225 .collect()
226 } else {
227 Vec::new()
228 };
229
230 examples.sort();
231
232 eprintln!("\nš¦ Available contract examples:");
233 eprintln!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
234
235 for example in &examples {
236 eprintln!(" š {example}");
237 }
238
239 eprintln!("\nš” Usage:");
240 eprintln!(" stellar-scaffold contract generate --from <example-name>");
241 eprintln!(" Example: stellar-scaffold contract generate --from nft-royalties");
242
243 Ok(())
244 }
245
246 async fn fetch_latest_release() -> Result<Release, Error> {
247 Self::fetch_latest_release_from_url(
248 "https://api.github.com/repos/OpenZeppelin/stellar-contracts/releases/latest",
249 )
250 .await
251 }
252
253 async fn fetch_latest_release_from_url(url: &str) -> Result<Release, Error> {
254 let client = reqwest::Client::new();
255 let response = client
256 .get(url)
257 .header("User-Agent", "stellar-scaffold-cli")
258 .send()
259 .await?;
260
261 if !response.status().is_success() {
262 return Err(Error::Reqwest(response.error_for_status().unwrap_err()));
263 }
264
265 let release: Release = response.json().await?;
266 Ok(release)
267 }
268
269 async fn cache_repository(
270 repo_cache_path: &Path,
271 cache_ref_file: &Path,
272 tag_name: &str,
273 ) -> Result<(), Error> {
274 fs::create_dir_all(repo_cache_path)?;
275
276 Self::download_and_extract_tag(repo_cache_path, tag_name).await?;
278
279 if repo_cache_path.read_dir()?.next().is_none() {
280 return Err(Error::GitCloneFailed(format!(
281 "Failed to download repository release {tag_name} to cache"
282 )));
283 }
284
285 fs::write(cache_ref_file, tag_name)?;
286 Ok(())
287 }
288
289 async fn download_and_extract_tag(dest_path: &Path, tag_name: &str) -> Result<(), Error> {
290 let url =
291 format!("https://github.com/OpenZeppelin/stellar-contracts/archive/{tag_name}.tar.gz",);
292
293 let client = reqwest::Client::new();
295 let response = client
296 .get(&url)
297 .header("User-Agent", "stellar-scaffold-cli")
298 .send()
299 .await?;
300
301 if !response.status().is_success() {
302 return Err(Error::GitCloneFailed(format!(
303 "Failed to download release {tag_name}: HTTP {}",
304 response.status()
305 )));
306 }
307
308 let bytes = response.bytes().await?;
310
311 let dest_path = dest_path.to_path_buf();
313 tokio::task::spawn_blocking(move || {
314 let tar = GzDecoder::new(std::io::Cursor::new(bytes));
315 let mut archive = Archive::new(tar);
316
317 for entry in archive.entries()? {
318 let mut entry = entry?;
319 let path = entry.path()?;
320
321 let stripped_path = path.components().skip(1).collect::<std::path::PathBuf>();
323
324 if stripped_path.as_os_str().is_empty() {
325 continue;
326 }
327
328 let dest_file_path = dest_path.join(&stripped_path);
329
330 if entry.header().entry_type().is_dir() {
331 std::fs::create_dir_all(&dest_file_path)?;
332 } else {
333 if let Some(parent) = dest_file_path.parent() {
334 std::fs::create_dir_all(parent)?;
335 }
336 entry.unpack(&dest_file_path)?;
337 }
338 }
339
340 Ok::<(), std::io::Error>(())
341 })
342 .await
343 .map_err(|e| {
344 Error::Io(std::io::Error::new(
345 std::io::ErrorKind::Other,
346 e.to_string(),
347 ))
348 })?
349 .map_err(Error::Io)?;
350
351 Ok(())
352 }
353
354 async fn ensure_cache_updated(&self) -> Result<std::path::PathBuf, Error> {
355 let cache_dir = dirs::cache_dir().ok_or_else(|| {
356 Error::Io(std::io::Error::new(
357 std::io::ErrorKind::NotFound,
358 "Cache directory not found",
359 ))
360 })?;
361
362 let base_cache_path = cache_dir.join("stellar-scaffold-cli/openzeppelin-stellar-contracts");
363
364 let Release { tag_name } = Self::fetch_latest_release().await?;
366 let repo_cache_path = base_cache_path.join(&tag_name);
367 let cache_ref_file = repo_cache_path.join(".release_ref");
368
369 let should_update_cache = if repo_cache_path.exists() {
370 if let Ok(cached_tag) = fs::read_to_string(&cache_ref_file) {
371 if cached_tag.trim() == tag_name {
372 eprintln!("š Using cached repository (release {tag_name})...");
373 false
374 } else {
375 eprintln!("š New release available ({tag_name}). Updating cache...");
376 true
377 }
378 } else {
379 eprintln!("š Cache metadata missing. Updating...");
380 true
381 }
382 } else {
383 eprintln!("š Cache not found. Downloading release {tag_name}...");
384 true
385 };
386
387 if should_update_cache {
388 if repo_cache_path.exists() {
389 fs::remove_dir_all(&repo_cache_path)?;
390 }
391 Self::cache_repository(&repo_cache_path, &cache_ref_file, &tag_name).await?;
392 }
393
394 Ok(repo_cache_path)
395 }
396
397 fn copy_directory_contents(source: &Path, dest: &Path) -> Result<(), Error> {
398 let copy_options = fs_extra::dir::CopyOptions::new()
399 .overwrite(true)
400 .content_only(true);
401
402 fs_extra::dir::copy(source, dest, ©_options)
403 .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
404
405 Ok(())
406 }
407}
408
409fn open_wizard() -> Result<(), Error> {
410 eprintln!("š§ Opening OpenZeppelin Contract Wizard...");
411
412 let url = "https://wizard.openzeppelin.com/stellar";
413
414 webbrowser::open(url)
415 .map_err(|e| Error::BrowserFailed(format!("Failed to open browser: {e}")))?;
416
417 eprintln!("ā
Opened Contract Wizard in your default browser");
418 eprintln!("\nš Instructions:");
419 eprintln!(" 1. Configure your contract in the wizard");
420 eprintln!(" 2. Click 'Download' to get your contract files");
421 eprintln!(" 3. Extract the downloaded ZIP file");
422 eprintln!(" 4. Move the contract folder to your contracts/ directory");
423 eprintln!(" 5. Add the contract to your workspace Cargo.toml if needed");
424 eprintln!(
425 " 6. You may need to modify your environments.toml file to add constructor arguments"
426 );
427 eprintln!(
428 "\nš” The wizard will generate a complete Soroban contract with your selected features!"
429 );
430
431 Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use mockito::{mock, server_url};
438
439 fn create_test_cmd(from: Option<String>, ls: bool, from_wizard: bool) -> Cmd {
440 Cmd {
441 from,
442 ls,
443 from_wizard,
444 output: None,
445 }
446 }
447
448 #[tokio::test]
449 async fn test_ls_command() {
450 let cmd = create_test_cmd(None, true, false);
451
452 let _m = mock(
453 "GET",
454 "/repos/OpenZeppelin/stellar-contracts/contents/examples",
455 )
456 .with_status(200)
457 .with_header("content-type", "application/json")
458 .with_body(r#"[{"name": "example1", "type": "dir"}, {"name": "example2", "type": "dir"}]"#)
459 .create();
460
461 let result = cmd.run().await;
462 assert!(result.is_ok());
463 }
464
465 #[tokio::test]
466 async fn test_fetch_latest_release() {
467 let _m = mock(
468 "GET",
469 "/repos/OpenZeppelin/stellar-contracts/releases/latest",
470 )
471 .with_status(200)
472 .with_header("content-type", "application/json")
473 .with_body(
474 r#"{
475 "tag_name": "v1.2.3",
476 "name": "Release v1.2.3",
477 "published_at": "2024-01-15T10:30:00Z"
478 }"#,
479 )
480 .create();
481
482 let mock_url = format!(
483 "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
484 server_url()
485 );
486 let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
487
488 assert!(result.is_ok());
489 let release = result.unwrap();
490 assert_eq!(release.tag_name, "v1.2.3");
491 }
492
493 #[tokio::test]
494 async fn test_fetch_latest_release_error() {
495 let _m = mock(
496 "GET",
497 "/repos/OpenZeppelin/stellar-contracts/releases/latest",
498 )
499 .with_status(404)
500 .with_header("content-type", "application/json")
501 .with_body(r#"{"message": "Not Found"}"#)
502 .create();
503
504 let mock_url = format!(
505 "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
506 server_url()
507 );
508 let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
509
510 assert!(result.is_err());
511 }
512
513 #[tokio::test]
514 async fn test_no_action_specified() {
515 let cmd = create_test_cmd(None, false, false);
516 let result = cmd.run().await;
517 assert!(matches!(result, Err(Error::NoActionSpecified)));
518 }
519}