Skip to main content

stellar_scaffold_cli/commands/generate/contract/
mod.rs

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    /// Clone contract from `OpenZeppelin` examples or `soroban-examples`
35    #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
36    pub from: Option<String>,
37
38    /// List available contract examples
39    #[arg(long, conflicts_with_all = ["from", "from_wizard"])]
40    pub ls: bool,
41
42    /// Open contract generation wizard in browser
43    #[arg(long, conflicts_with_all = ["from", "ls"])]
44    pub from_wizard: bool,
45
46    /// Output directory for the generated contract (defaults to contracts/<example-name>)
47    #[arg(short, long)]
48    pub output: Option<PathBuf>,
49
50    /// Force add contract to existing project (ignoring some errors)
51    #[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        // Check if the example exists
156        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        // Create destination and copy example contents
162        fs::create_dir_all(dest_path)?;
163        Self::copy_directory_contents(&example_source_path, Path::new(&dest_path))?;
164
165        // Read and update workspace Cargo.toml
166        // Use dest_path (the project's contract directory) rather than
167        // example_source_path (the cache) so that `cargo locate-project
168        // --workspace` finds the project's Cargo.toml, not the cached
169        // OZ repo's workspace.
170        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        // Check if the example exists
201        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        // Create destination and copy example contents
218        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        // Parse the Cargo.toml
281        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        // Create new package metadata
297        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        // Copy over a lib section
323        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        // TODO: We might want to check rust version here as well, but it's not very trivial.
332        // Someone may use a nightly version and we always fail the check because technically the versions aren't the same
333        // We could just print a warning if there's a version mismatch
334
335        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            // This nested if statement gets the major dependency version from the workspace Cargo.toml
376            // and from the example Cargo.toml and checks that they are equal.
377            // If it fails, it simply prints a warning. But a mismatch is detected,
378            // it exits with an error (overridable by --force)
379            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                    // Check major versions are equal
384                    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            // Copy details from the example dependency
410            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        // Parse the workspace Cargo.toml
470        let mut manifest = cargo_toml::Manifest::from_path(workspace_path)?;
471
472        // Ensure workspace.dependencies exists
473        if manifest.workspace.is_none() {
474            // Create a minimal workspace with just what we need
475            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                // Check if we need to update the tag
500                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            // Write the updated manifest back to file
522            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        // Atomic multiswap imports atomic swap contract which is currently not supported
662        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                // Remove ignored files and directories
670                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                // Remove hidden directories (e.g. .git)
679                if path.starts_with(".") {
680                    fs::remove_dir_all(path)?;
681                } else {
682                    // Only allow simple examples for now (where Cargo.toml exists in the root)
683                    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        // Download and extract the specific tag directly
706        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        // Download the tar.gz file
725        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        // Get the response bytes
740        let bytes = response.bytes().await?;
741
742        fs::create_dir_all(dest_path)?;
743
744        // Extract the tar.gz in a blocking task to avoid blocking the async runtime
745        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                // Strip the root directory (stellar-contracts-{tag}/)
755                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        // Get the latest release tag
808        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, &copy_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)] // TODO: remove if not used
912    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}