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