1use cargo_toml::Dependency::Simple;
2use cargo_toml::Inheritable::{Inherited, Set};
3use cargo_toml::{
4 Dependency, DepsSet, InheritedDependencyDetail, Manifest, Product, Publish, Workspace,
5};
6use clap::Parser;
7use flate2::read::GzDecoder;
8use reqwest;
9use serde::Deserialize;
10use serde_json::Value;
11use std::collections::HashSet;
12use std::num::ParseIntError;
13use std::path::PathBuf;
14use std::process::Command;
15use std::{fs, path::Path};
16use stellar_cli::commands::global;
17use stellar_cli::print::Print;
18use tar::Archive;
19use toml::Value::Table;
20
21const SOROBAN_EXAMPLES_REPO: &str = "https://github.com/stellar/soroban-examples";
22const STELLAR_PREFIX: &str = "stellar/";
23const OZ_EXAMPLES_REPO: &str = "https://github.com/OpenZeppelin/stellar-contracts/examples";
24const OZ_PREFIX: &str = "oz/";
25const LATEST_SUPPORTED_OZ_RELEASE: &str = "v0.7.0-rc.1";
26
27#[derive(Deserialize)]
28struct Release {
29 tag_name: String,
30}
31
32#[derive(Parser, Debug)]
33pub struct Cmd {
34 #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
36 pub from: Option<String>,
37
38 #[arg(long, conflicts_with_all = ["from", "from_wizard"])]
40 pub ls: bool,
41
42 #[arg(long, conflicts_with_all = ["from", "ls"])]
44 pub from_wizard: bool,
45
46 #[arg(short, long)]
48 pub output: Option<PathBuf>,
49
50 #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
52 pub force: bool,
53}
54
55#[derive(thiserror::Error, Debug)]
56pub enum Error {
57 #[error(transparent)]
58 Io(#[from] std::io::Error),
59 #[error(transparent)]
60 Reqwest(#[from] reqwest::Error),
61 #[error(transparent)]
62 CargoToml(#[from] cargo_toml::Error),
63 #[error(transparent)]
64 TomlDeserialize(#[from] toml::de::Error),
65 #[error(transparent)]
66 TomlSerialize(#[from] toml::ser::Error),
67 #[error("Git command failed: {0}")]
68 GitCloneFailed(String),
69 #[error("Example '{0}' not found")]
70 ExampleNotFound(String),
71 #[error("Example '{0}' not found in OpenZeppelin stellar-contracts")]
72 OzExampleNotFound(String),
73 #[error("Example '{0}' not found in Stellar soroban-examples")]
74 StellarExampleNotFound(String),
75 #[error(
76 "Invalid Cargo toml file for soroban-example {0}: missing [package] or [dependencies] sections"
77 )]
78 InvalidCargoToml(String),
79 #[error(
80 "Invalid workspace toml file in the root of the current directory: missing {0} section\nPlease make sure to run this command from the root of a Scaffold project."
81 )]
82 InvalidWorkspaceCargoToml(String),
83 #[error("Failed to open browser: {0}")]
84 BrowserFailed(String),
85 #[error("No action specified. Use --from, --ls, or --from-wizard")]
86 NoActionSpecified,
87 #[error("Destination path {0} already exists. Use --force to overwrite it")]
88 PathExists(PathBuf),
89 #[error("Failed to update examples cache")]
90 UpdateExamplesCache,
91 #[error("Failed to fetch workspace Cargo.toml")]
92 CargoError,
93 #[error(
94 "Dependency version mismatch for {0}: example version {1} doesn't match manifest version {2}"
95 )]
96 DependencyVersionMismatch(String, u32, u32),
97 #[error("Missing workspace package")]
98 MissingWorkspacePackage,
99}
100
101impl Cmd {
102 pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
103 match (&self.from, self.ls, self.from_wizard) {
104 (Some(example_name), _, _) => self.clone_example(example_name, global_args).await,
105 (_, true, _) => self.list_examples(global_args).await,
106 (_, _, true) => open_wizard(global_args),
107 _ => Err(Error::NoActionSpecified),
108 }
109 }
110
111 async fn clone_example(
112 &self,
113 example_name: &str,
114 global_args: &global::Args,
115 ) -> Result<(), Error> {
116 let printer = Print::new(global_args.quiet);
117
118 printer.infoln(format!("Downloading example '{example_name}'..."));
119
120 let examples_info = self.ensure_cache_updated(&printer).await?;
121
122 if example_name.starts_with(OZ_PREFIX) {
123 let (_, example_name) = example_name.split_at(3);
124 let dest_path = self.output_dir(example_name);
125 Self::generate_oz_example(
126 example_name,
127 &examples_info.oz_examples_path,
128 &examples_info.oz_version_tag,
129 &dest_path,
130 global_args,
131 &printer,
132 )
133 } else if example_name.starts_with(STELLAR_PREFIX) {
134 let (_, example_name) = example_name.split_at(8);
135 let dest_path = self.output_dir(example_name);
136 self.generate_soroban_example(
137 example_name,
138 &examples_info.soroban_examples_path,
139 &dest_path,
140 &printer,
141 )
142 } else {
143 Err(Error::ExampleNotFound(example_name.to_owned()))
144 }
145 }
146
147 fn generate_oz_example(
148 example_name: &str,
149 repo_cache_path: &Path,
150 tag_name: &str,
151 dest_path: &Path,
152 global_args: &global::Args,
153 printer: &Print,
154 ) -> Result<(), Error> {
155 let example_source_path = repo_cache_path.join(format!("examples/{example_name}"));
157 if !example_source_path.exists() {
158 return Err(Error::OzExampleNotFound(example_name.to_string()));
159 }
160
161 fs::create_dir_all(dest_path)?;
163 Self::copy_directory_contents(&example_source_path, Path::new(&dest_path))?;
164
165 let workspace_cargo_path =
167 Self::get_workspace_root(&example_source_path.join("Cargo.toml"));
168 if let Ok(workspace_cargo_path) = workspace_cargo_path {
169 Self::update_workspace_dependencies(
170 &workspace_cargo_path,
171 &example_source_path,
172 tag_name,
173 global_args,
174 )?;
175 } else {
176 printer.warnln("Warning: No workspace Cargo.toml found in current directory.");
177 printer
178 .println(" You'll need to manually add required dependencies to your workspace.");
179 }
180
181 printer.checkln(format!(
182 "Successfully downloaded example '{example_name}' to {}",
183 dest_path.display()
184 ));
185 printer
186 .infoln("You may need to modify your environments.toml to add constructor arguments!");
187 Ok(())
188 }
189
190 fn generate_soroban_example(
191 &self,
192 example_name: &str,
193 repo_cache_path: &Path,
194 dest_path: &Path,
195 printer: &Print,
196 ) -> Result<(), Error> {
197 let example_source_path = repo_cache_path.join(example_name);
199 if !example_source_path.exists() {
200 return Err(Error::StellarExampleNotFound(example_name.to_string()));
201 }
202 if dest_path.exists() {
203 if self.force {
204 printer.warnln(format!(
205 "Overwriting existing directory {}...",
206 dest_path.display()
207 ));
208 fs::remove_dir_all(dest_path)?;
209 } else {
210 return Err(Error::PathExists(dest_path.to_owned()));
211 }
212 }
213
214 fs::create_dir_all(dest_path)?;
216 Self::copy_directory_contents(&example_source_path, Path::new(&dest_path))?;
217
218 match fs::remove_file(dest_path.join("Cargo.lock")) {
219 Ok(..) => {}
220 Err(e) => {
221 if e.kind() != std::io::ErrorKind::NotFound {
222 printer.errorln(format!("Failed to remove Cargo.lock: {e}"));
223 }
224 }
225 }
226 match fs::remove_file(dest_path.join("Makefile")) {
227 Ok(..) => {}
228 Err(e) => {
229 if e.kind() != std::io::ErrorKind::NotFound {
230 printer.errorln(format!("Failed to remove Makefile: {e}"));
231 }
232 }
233 }
234
235 let example_toml_path = dest_path.join("Cargo.toml");
236
237 let workspace_cargo_path = Self::get_workspace_root(&example_toml_path);
238 let Ok(workspace_cargo_path) = workspace_cargo_path else {
239 printer.warnln("Warning: No workspace Cargo.toml found in current directory.");
240 printer.println("You'll need to manually add contracts to your workspace.");
241 return Ok(());
242 };
243
244 self.write_new_manifest(
245 &workspace_cargo_path,
246 &example_toml_path,
247 example_name,
248 printer,
249 )?;
250
251 printer.checkln(format!(
252 "Successfully downloaded example '{example_name}' to {}",
253 dest_path.display()
254 ));
255 printer
256 .infoln("You may need to modify your environments.toml to add constructor arguments!");
257 Ok(())
258 }
259
260 fn write_new_manifest(
261 &self,
262 workspace_toml_path: &Path,
263 example_toml_path: &Path,
264 example_name: &str,
265 printer: &Print,
266 ) -> Result<(), Error> {
267 let workspace_manifest = Manifest::from_path(workspace_toml_path)?;
268 let Some(workspace) = workspace_manifest.workspace.as_ref() else {
269 return Err(Error::InvalidWorkspaceCargoToml(
270 "[workspace.package]".to_string(),
271 ));
272 };
273 let Some(workspace_package) = &workspace.package else {
274 return Err(Error::MissingWorkspacePackage);
275 };
276
277 let manifest = cargo_toml::Manifest::from_path(example_toml_path)?;
279
280 let package = manifest
281 .package
282 .ok_or(Error::InvalidCargoToml(example_name.to_string()))?;
283 let name = package.name;
284
285 let mut new_manifest = cargo_toml::Manifest::from_str(
286 format!(
287 "[package]
288 name = \"{name}\""
289 )
290 .as_str(),
291 )?;
292
293 let mut new_package = new_manifest.package.unwrap();
295 new_package.description = package.description;
296 if workspace_package.version.is_some() {
297 new_package.version = Inherited;
298 } else {
299 new_package.version = package.version;
300 }
301 if workspace_package.edition.is_none() {
302 return Err(Error::InvalidWorkspaceCargoToml(
303 "[workspace.package.edition]".to_string(),
304 ));
305 }
306 new_package.edition = Inherited;
307 if workspace_package.license.is_some() {
308 new_package.license = Some(Inherited);
309 }
310 if workspace_package.repository.is_some() {
311 new_package.repository = Some(Inherited);
312 }
313 new_package.publish = Set(Publish::Flag(false));
314
315 let mut table = toml::Table::new();
316 table.insert("cargo_inherit".to_string(), toml::Value::Boolean(true));
317 new_package.metadata = Some(Table(table));
318
319 let lib = Product {
321 crate_type: vec!["cdylib".to_string()],
322 doctest: false,
323 ..Default::default()
324 };
325
326 new_manifest.lib = Some(lib);
327
328 let mut dependencies = manifest.dependencies;
333 let mut new_workspace_dependencies = workspace.dependencies.clone();
334 self.inherit_dependencies(printer, &mut new_workspace_dependencies, &mut dependencies)?;
335 new_manifest.dependencies = dependencies;
336
337 let mut dev_dependencies = manifest.dev_dependencies;
338 self.inherit_dependencies(
339 printer,
340 &mut new_workspace_dependencies,
341 &mut dev_dependencies,
342 )?;
343 new_manifest.dev_dependencies = dev_dependencies;
344
345 new_manifest.package = Some(new_package);
346
347 let toml_string = toml::to_string_pretty(&new_manifest)?;
348 fs::write(example_toml_path, toml_string)?;
349
350 let new_workspace = Workspace {
351 dependencies: new_workspace_dependencies,
352 ..workspace.clone()
353 };
354 let new_workspace_manifest = Manifest {
355 workspace: Some(new_workspace),
356 ..workspace_manifest
357 };
358 let toml_string = toml::to_string_pretty(&new_workspace_manifest)?;
359 fs::write(workspace_toml_path, toml_string)?;
360
361 Ok(())
362 }
363
364 fn inherit_dependencies(
365 &self,
366 printer: &Print,
367 workspace_dependencies: &mut DepsSet,
368 dependencies: &mut DepsSet,
369 ) -> Result<(), Error> {
370 let mut new_dependencies = vec![];
371 for (dependency_name, example_dep) in dependencies.iter() {
372 if let Some(manifest_dep) = workspace_dependencies.get(dependency_name) {
377 if let Some(example_major) = Self::try_get_major_version(example_dep)
378 && let Some(manifest_major) = Self::try_get_major_version(manifest_dep)
379 {
380 if example_major != manifest_major {
382 if self.force {
383 printer.warnln(format!("Example {dependency_name} dependency version doesn't match manifest version (example might not compile)"));
384 } else {
385 return Err(Error::DependencyVersionMismatch(
386 dependency_name.clone(),
387 example_major,
388 manifest_major,
389 ));
390 }
391 }
392 } else {
393 printer.warnln(format!("Workspace or an example Cargo.toml's {dependency_name} dependency version couldn't be parsed, skipping example version validation (if there's a mismatch it might not compile)"));
394 }
395 } else {
396 workspace_dependencies.insert(dependency_name.clone(), example_dep.clone());
397
398 printer.infoln(format!(
399 "Updating workspace Cargo.toml with new dependency {dependency_name}."
400 ));
401 }
402
403 let mut optional = false;
404 let mut features = vec![];
405
406 if let Dependency::Detailed(detail) = example_dep {
408 optional = detail.optional;
409 features.clone_from(&detail.features);
410 }
411
412 new_dependencies.push((
413 dependency_name.clone(),
414 Dependency::Inherited(InheritedDependencyDetail {
415 workspace: true,
416 optional,
417 features,
418 }),
419 ));
420 }
421 dependencies.extend(new_dependencies);
422 Ok(())
423 }
424
425 fn try_get_major_version(dependency: &Dependency) -> Option<u32> {
426 match dependency {
427 Simple(version) => {
428 if let Some(Ok(example_version)) = Self::manifest_version_to_major(version) {
429 return Some(example_version);
430 }
431 }
432 Dependency::Inherited(_) => {}
433 Dependency::Detailed(detail) => {
434 if let Some(version) = &detail.version
435 && let Some(Ok(example_version)) = Self::manifest_version_to_major(version)
436 {
437 return Some(example_version);
438 }
439 }
440 }
441 None
442 }
443
444 fn manifest_version_to_major(manifest_dep: &str) -> Option<Result<u32, ParseIntError>> {
445 manifest_dep
446 .split('.')
447 .next()
448 .map(|s| s.chars().filter(char::is_ascii_digit).collect::<String>())
449 .map(|s| s.parse::<u32>())
450 }
451
452 fn update_workspace_dependencies(
453 workspace_path: &Path,
454 example_path: &Path,
455 tag: &str,
456 global_args: &global::Args,
457 ) -> Result<(), Error> {
458 let printer = Print::new(global_args.quiet);
459
460 let example_cargo_content = fs::read_to_string(example_path.join("Cargo.toml"))?;
461 let deps = Self::extract_stellar_dependencies(&example_cargo_content)?;
462 if deps.is_empty() {
463 return Ok(());
464 }
465
466 let mut manifest = cargo_toml::Manifest::from_path(workspace_path)?;
468
469 if manifest.workspace.is_none() {
471 let workspace_toml = r"
473[workspace]
474members = []
475
476[workspace.dependencies]
477";
478 let workspace: cargo_toml::Workspace<toml::Value> = toml::from_str(workspace_toml)?;
479 manifest.workspace = Some(workspace);
480 }
481 let workspace = manifest.workspace.as_mut().unwrap();
482
483 let mut workspace_deps = workspace.dependencies.clone();
484
485 let mut added_deps = Vec::new();
486 let mut updated_deps = Vec::new();
487
488 for dep in deps {
489 let git_dep = cargo_toml::DependencyDetail {
490 git: Some("https://github.com/OpenZeppelin/stellar-contracts".to_string()),
491 tag: Some(tag.to_string()),
492 ..Default::default()
493 };
494
495 if let Some(existing_dep) = workspace_deps.clone().get(&dep) {
496 if let cargo_toml::Dependency::Detailed(detail) = existing_dep
498 && let Some(existing_tag) = &detail.tag
499 && existing_tag != tag
500 {
501 workspace_deps.insert(
502 dep.clone(),
503 cargo_toml::Dependency::Detailed(Box::new(git_dep)),
504 );
505 updated_deps.push((dep, existing_tag.clone()));
506 }
507 } else {
508 workspace_deps.insert(
509 dep.clone(),
510 cargo_toml::Dependency::Detailed(Box::new(git_dep)),
511 );
512 added_deps.push(dep);
513 }
514 }
515
516 if !added_deps.is_empty() || !updated_deps.is_empty() {
517 workspace.dependencies = workspace_deps;
518 let toml_string = toml::to_string_pretty(&manifest)?;
520 fs::write(workspace_path, toml_string)?;
521
522 if !added_deps.is_empty() {
523 printer.infoln("Added the following dependencies to workspace:");
524 for dep in added_deps {
525 printer.println(format!(" • {dep}"));
526 }
527 }
528
529 if !updated_deps.is_empty() {
530 printer.infoln("Updated the following dependencies:");
531 for (dep, old_tag) in updated_deps {
532 printer.println(format!(" • {dep}: {old_tag} -> {tag}"));
533 }
534 }
535 }
536
537 Ok(())
538 }
539
540 fn extract_stellar_dependencies(cargo_toml_content: &str) -> Result<Vec<String>, Error> {
541 let manifest: cargo_toml::Manifest = toml::from_str(cargo_toml_content)?;
542
543 Ok(manifest
544 .dependencies
545 .iter()
546 .filter(|(dep_name, _)| dep_name.starts_with("stellar-"))
547 .filter_map(|(dep_name, dep_detail)| match dep_detail {
548 cargo_toml::Dependency::Detailed(detail)
549 if !(detail.inherited || detail.git.is_some()) =>
550 {
551 None
552 }
553 _ => Some(dep_name.clone()),
554 })
555 .collect())
556 }
557
558 fn examples_list(examples_path: PathBuf) -> Result<Vec<String>, Error> {
559 let mut examples: Vec<String> = if examples_path.exists() {
560 fs::read_dir(examples_path)?
561 .filter_map(std::result::Result::ok)
562 .filter(|entry| entry.path().is_dir())
563 .filter_map(|entry| {
564 entry
565 .file_name()
566 .to_str()
567 .map(std::string::ToString::to_string)
568 })
569 .collect()
570 } else {
571 Vec::new()
572 };
573
574 examples.sort();
575
576 Ok(examples)
577 }
578
579 async fn list_examples(&self, global_args: &global::Args) -> Result<(), Error> {
580 let printer = Print::new(global_args.quiet);
581
582 let examples_info = self.ensure_cache_updated(&printer).await?;
583
584 printer.infoln("Fetching available contract examples...");
585
586 let oz_examples_path = examples_info.oz_examples_path.join("examples");
587
588 let oz_examples = Self::examples_list(oz_examples_path)?;
589 let soroban_examples = Self::examples_list(examples_info.soroban_examples_path)?;
590
591 printer.println("\nAvailable contract examples:");
592 printer.println("────────────────────────────────");
593 printer.println(format!("From {SOROBAN_EXAMPLES_REPO}:"));
594
595 for example in &soroban_examples {
596 printer.println(format!(" 📁 {STELLAR_PREFIX}{example}"));
597 }
598
599 printer.println("────────────────────────────────");
600 printer.println(format!("From {OZ_EXAMPLES_REPO}"));
601
602 for example in &oz_examples {
603 printer.println(format!(" 📁 {OZ_PREFIX}{example}"));
604 }
605
606 printer.println("\nUsage:");
607 printer.println(" stellar-scaffold contract generate --from <example-name>");
608 printer.println(
609 " Example (soroban-examples): stellar-scaffold contract generate --from stellar/hello-world",
610 );
611 printer.println(" Example (OpenZeppelin examples): stellar-scaffold contract generate --from oz/nft-royalties");
612
613 Ok(())
614 }
615
616 async fn fetch_latest_oz_release() -> Result<Release, Error> {
617 Self::fetch_latest_release_from_url(&format!(
618 "https://api.github.com/repos/OpenZeppelin/stellar-contracts/releases/tags/{LATEST_SUPPORTED_OZ_RELEASE}",
619 ))
620 .await
621 }
622
623 async fn fetch_latest_soroban_examples_release() -> Result<Release, Error> {
624 Self::fetch_latest_release_from_url(
625 "https://api.github.com/repos/stellar/soroban-examples/releases/latest",
626 )
627 .await
628 }
629
630 async fn fetch_latest_release_from_url(url: &str) -> Result<Release, Error> {
631 let client = reqwest::Client::new();
632 let response = client
633 .get(url)
634 .header("User-Agent", "stellar-scaffold-cli")
635 .send()
636 .await?;
637
638 if !response.status().is_success() {
639 return Err(Error::Reqwest(response.error_for_status().unwrap_err()));
640 }
641
642 let release: Release = response.json().await?;
643 Ok(release)
644 }
645
646 async fn cache_oz_repository(repo_cache_path: &Path, tag_name: &str) -> Result<(), Error> {
647 Self::cache_repository("OpenZeppelin/stellar-contracts", repo_cache_path, tag_name).await
648 }
649
650 async fn cache_soroban_examples_repository(
651 repo_cache_path: &Path,
652 tag_name: &str,
653 ) -> Result<(), Error> {
654 Self::cache_repository("stellar/soroban-examples", repo_cache_path, tag_name).await
655 }
656
657 fn filter_soroban_examples_repository(repo_cache_path: &Path) -> Result<(), Error> {
658 let ignore_list = HashSet::from(["workspace", "atomic_multiswap"]);
660 let rd = repo_cache_path.read_dir()?;
661 for path in rd {
662 let path = path?.path();
663 if !path.is_dir() {
664 fs::remove_file(path)?;
665 } else if path.is_dir() {
666 if let Some(path_file_name) = path.file_name()
668 && let Some(path_file_name) = path_file_name.to_str()
669 && ignore_list.contains(path_file_name)
670 {
671 fs::remove_dir_all(path)?;
672 continue;
673 }
674
675 if path.starts_with(".") {
677 fs::remove_dir_all(path)?;
678 } else {
679 let rd = path.read_dir()?;
681 let mut is_simple_example = false;
682 for entry in rd {
683 let entry = entry?;
684 if entry.path().is_file() && entry.file_name() == "Cargo.toml" {
685 is_simple_example = true;
686 }
687 }
688 if !is_simple_example {
689 fs::remove_dir_all(path)?;
690 }
691 }
692 }
693 }
694 Ok(())
695 }
696
697 async fn cache_repository(
698 repo: &str,
699 repo_cache_path: &Path,
700 tag_name: &str,
701 ) -> Result<(), Error> {
702 Self::download_and_extract_tag(repo, repo_cache_path, tag_name).await?;
704
705 if repo_cache_path.read_dir()?.next().is_none() {
706 return Err(Error::GitCloneFailed(format!(
707 "Failed to download repository release {tag_name} to cache"
708 )));
709 }
710
711 Ok(())
712 }
713
714 async fn download_and_extract_tag(
715 repo: &str,
716 dest_path: &Path,
717 tag_name: &str,
718 ) -> Result<(), Error> {
719 let url = format!("https://github.com/{repo}/archive/{tag_name}.tar.gz",);
720
721 let client = reqwest::Client::new();
723 let response = client
724 .get(&url)
725 .header("User-Agent", "stellar-scaffold-cli")
726 .send()
727 .await?;
728
729 if !response.status().is_success() {
730 return Err(Error::GitCloneFailed(format!(
731 "Failed to download release {tag_name} from {url}: HTTP {}",
732 response.status()
733 )));
734 }
735
736 let bytes = response.bytes().await?;
738
739 fs::create_dir_all(dest_path)?;
740
741 let dest_path = dest_path.to_path_buf();
743 tokio::task::spawn_blocking(move || {
744 let tar = GzDecoder::new(std::io::Cursor::new(bytes));
745 let mut archive = Archive::new(tar);
746
747 for entry in archive.entries()? {
748 let mut entry = entry?;
749 let path = entry.path()?;
750
751 let stripped_path = path.components().skip(1).collect::<std::path::PathBuf>();
753
754 if stripped_path.as_os_str().is_empty() {
755 continue;
756 }
757
758 let dest_file_path = dest_path.join(&stripped_path);
759
760 if entry.header().entry_type().is_dir() {
761 std::fs::create_dir_all(&dest_file_path)?;
762 } else {
763 if let Some(parent) = dest_file_path.parent() {
764 std::fs::create_dir_all(parent)?;
765 }
766 entry.unpack(&dest_file_path)?;
767 }
768 }
769
770 Ok::<(), std::io::Error>(())
771 })
772 .await
773 .map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?
774 .map_err(Error::Io)?;
775
776 Ok(())
777 }
778
779 async fn ensure_cache_updated(&self, printer: &Print) -> Result<ExamplesInfo, Error> {
780 let cache_dir = dirs::cache_dir().ok_or_else(|| {
781 Error::Io(std::io::Error::new(
782 std::io::ErrorKind::NotFound,
783 "Cache directory not found",
784 ))
785 })?;
786
787 let cli_cache_path = cache_dir.join("stellar-scaffold-cli");
788
789 let oz_cache_path = cli_cache_path.join("openzeppelin-stellar-contracts");
790 let soroban_examples_cache_path = cli_cache_path.join("soroban_examples");
791
792 Self::update_cache(&oz_cache_path, &soroban_examples_cache_path)
793 .await
794 .or_else(|e| {
795 printer.warnln(format!("Failed to update examples cache: {e}"));
796 Self::get_latest_known_examples(&oz_cache_path, &soroban_examples_cache_path)
797 })
798 }
799
800 async fn update_cache(
801 oz_cache_path: &Path,
802 soroban_examples_cache_path: &Path,
803 ) -> Result<ExamplesInfo, Error> {
804 let Release { tag_name } = Self::fetch_latest_oz_release().await?;
806 let oz_repo_cache_path = oz_cache_path.join(&tag_name);
807 if !oz_repo_cache_path.exists() {
808 Self::cache_oz_repository(&oz_repo_cache_path, &tag_name).await?;
809 }
810 let oz_tag_name = tag_name;
811
812 let Release { tag_name } = Self::fetch_latest_soroban_examples_release().await?;
813 let soroban_examples_cache_path = soroban_examples_cache_path.join(&tag_name);
814 if !soroban_examples_cache_path.exists() {
815 Self::cache_soroban_examples_repository(&soroban_examples_cache_path, &tag_name)
816 .await?;
817 Self::filter_soroban_examples_repository(&soroban_examples_cache_path)?;
818 }
819
820 Ok(ExamplesInfo {
821 oz_examples_path: oz_repo_cache_path,
822 oz_version_tag: oz_tag_name,
823 soroban_examples_path: soroban_examples_cache_path,
824 soroban_version_tag: tag_name,
825 })
826 }
827
828 fn get_latest_known_examples(
829 oz_cache_path: &Path,
830 soroban_examples_cache_path: &Path,
831 ) -> Result<ExamplesInfo, Error> {
832 if oz_cache_path.exists() && soroban_examples_cache_path.exists() {
833 let oz_tag_name = Self::get_latest_known_tag(oz_cache_path)?;
834 let soroban_examples_tag_name =
835 Self::get_latest_known_tag(soroban_examples_cache_path)?;
836
837 let oz_repo_cache_path = oz_cache_path.join(&oz_tag_name);
838 let soroban_examples_cache_path =
839 soroban_examples_cache_path.join(&soroban_examples_tag_name);
840
841 Ok(ExamplesInfo {
842 oz_examples_path: oz_repo_cache_path,
843 oz_version_tag: oz_tag_name,
844 soroban_examples_path: soroban_examples_cache_path,
845 soroban_version_tag: soroban_examples_tag_name,
846 })
847 } else {
848 Err(Error::UpdateExamplesCache)
849 }
850 }
851
852 fn get_latest_known_tag(example_cache_path: &Path) -> Result<String, Error> {
853 let rd = example_cache_path.read_dir()?;
854 let max_tag = rd
855 .filter_map(Result::ok)
856 .filter(|x| x.path().is_dir())
857 .filter_map(|x| x.file_name().to_str().map(ToString::to_string))
858 .max();
859 max_tag.ok_or(Error::UpdateExamplesCache)
860 }
861
862 fn copy_directory_contents(source: &Path, dest: &Path) -> Result<(), Error> {
863 let copy_options = fs_extra::dir::CopyOptions::new()
864 .overwrite(true)
865 .content_only(true);
866
867 fs_extra::dir::copy(source, dest, ©_options)
868 .map_err(|e| Error::Io(std::io::Error::other(e)))?;
869
870 Ok(())
871 }
872
873 fn get_workspace_root(path: &Path) -> Result<PathBuf, Error> {
874 let output = Command::new("cargo")
875 .arg("locate-project")
876 .arg("--workspace")
877 .arg("--message-format")
878 .arg("json")
879 .arg("--manifest-path")
880 .arg(path)
881 .output()?;
882
883 if !output.status.success() {
884 return Err(Error::CargoError);
885 }
886
887 let json_str = String::from_utf8(output.stdout).map_err(|_| Error::CargoError)?;
888 let parsed_json: Value = serde_json::from_str(&json_str).map_err(|_| Error::CargoError)?;
889
890 let workspace_root_str = parsed_json["root"].as_str().ok_or(Error::CargoError)?;
891
892 Ok(PathBuf::from(workspace_root_str))
893 }
894
895 fn output_dir(&self, example_name: &str) -> PathBuf {
896 PathBuf::from("contracts").join(
897 self.output
898 .as_deref()
899 .unwrap_or_else(|| Path::new(example_name)),
900 )
901 }
902}
903
904struct ExamplesInfo {
905 oz_examples_path: PathBuf,
906 oz_version_tag: String,
907 soroban_examples_path: PathBuf,
908 #[allow(dead_code)] soroban_version_tag: String,
910}
911
912fn open_wizard(global_args: &global::Args) -> Result<(), Error> {
913 let printer = Print::new(global_args.quiet);
914
915 printer.infoln("Opening OpenZeppelin Contract Wizard...");
916
917 let url = "https://wizard.openzeppelin.com/stellar";
918
919 webbrowser::open(url)
920 .map_err(|e| Error::BrowserFailed(format!("Failed to open browser: {e}")))?;
921
922 printer.checkln("Opened Contract Wizard in your default browser");
923 printer.println("\nInstructions:");
924 printer.println(" 1. Configure your contract in the wizard");
925 printer.println(" 2. Click 'Download' to get your contract files");
926 printer.println(" 3. Extract the downloaded ZIP file");
927 printer.println(" 4. Move the contract folder to your contracts/ directory");
928 printer.println(" 5. Add the contract to your workspace Cargo.toml if needed");
929 printer.println(
930 " 6. You may need to modify your environments.toml file to add constructor arguments",
931 );
932 printer.infoln(
933 "The wizard will generate a complete Soroban contract with your selected features!",
934 );
935
936 Ok(())
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use mockito::{mock, server_url};
943
944 fn create_test_cmd(from: Option<String>, ls: bool, from_wizard: bool) -> Cmd {
945 Cmd {
946 from,
947 ls,
948 from_wizard,
949 output: None,
950 force: false,
951 }
952 }
953
954 #[tokio::test]
955 #[ignore = "requires additional setup beyond HTTP mock"]
956 async fn test_ls_command() {
957 let cmd = create_test_cmd(None, true, false);
958 let global_args = global::Args::default();
959
960 let _m = mock(
961 "GET",
962 "/repos/OpenZeppelin/stellar-contracts/contents/examples",
963 )
964 .with_status(200)
965 .with_header("content-type", "application/json")
966 .with_body(r#"[{"name": "example1", "type": "dir"}, {"name": "example2", "type": "dir"}]"#)
967 .create();
968
969 let result = cmd.run(&global_args).await;
970 assert!(result.is_ok());
971 }
972
973 #[tokio::test]
974 async fn test_fetch_latest_release() {
975 let _m = mock(
976 "GET",
977 "/repos/OpenZeppelin/stellar-contracts/releases/latest",
978 )
979 .with_status(200)
980 .with_header("content-type", "application/json")
981 .with_body(
982 r#"{
983 "tag_name": "v1.2.3",
984 "name": "Release v1.2.3",
985 "published_at": "2024-01-15T10:30:00Z"
986 }"#,
987 )
988 .create();
989
990 let mock_url = format!(
991 "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
992 server_url()
993 );
994 let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
995
996 assert!(result.is_ok());
997 let release = result.unwrap();
998 assert_eq!(release.tag_name, "v1.2.3");
999 }
1000
1001 #[tokio::test]
1002 async fn test_fetch_latest_release_error() {
1003 let _m = mock(
1004 "GET",
1005 "/repos/OpenZeppelin/stellar-contracts/releases/latest",
1006 )
1007 .with_status(404)
1008 .with_header("content-type", "application/json")
1009 .with_body(r#"{"message": "Not Found"}"#)
1010 .create();
1011
1012 let mock_url = format!(
1013 "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
1014 server_url()
1015 );
1016 let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
1017
1018 assert!(result.is_err());
1019 }
1020
1021 #[tokio::test]
1022 async fn test_no_action_specified() {
1023 let cmd = create_test_cmd(None, false, false);
1024 let global_args = global::Args::default();
1025 let result = cmd.run(&global_args).await;
1026 assert!(matches!(result, Err(Error::NoActionSpecified)));
1027 }
1028}