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.7.0-rc.1";
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        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        // Check if the example exists
198        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        // Create destination and copy example contents
215        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        // Parse the Cargo.toml
278        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        // Create new package metadata
294        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        // Copy over a lib section
320        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        // TODO: We might want to check rust version here as well, but it's not very trivial.
329        // Someone may use a nightly version and we always fail the check because technically the versions aren't the same
330        // We could just print a warning if there's a version mismatch
331
332        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            // This nested if statement gets the major dependency version from the workspace Cargo.toml
373            // and from the example Cargo.toml and checks that they are equal.
374            // If it fails, it simply prints a warning. But a mismatch is detected,
375            // it exits with an error (overridable by --force)
376            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                    // Check major versions are equal
381                    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            // Copy details from the example dependency
407            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        // Parse the workspace Cargo.toml
467        let mut manifest = cargo_toml::Manifest::from_path(workspace_path)?;
468
469        // Ensure workspace.dependencies exists
470        if manifest.workspace.is_none() {
471            // Create a minimal workspace with just what we need
472            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                // Check if we need to update the tag
497                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            // Write the updated manifest back to file
519            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        // Atomic multiswap imports atomic swap contract which is currently not supported
659        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                // Remove ignored files and directories
667                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                // Remove hidden directories (e.g. .git)
676                if path.starts_with(".") {
677                    fs::remove_dir_all(path)?;
678                } else {
679                    // Only allow simple examples for now (where Cargo.toml exists in the root)
680                    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        // Download and extract the specific tag directly
703        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        // Download the tar.gz file
722        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        // Get the response bytes
737        let bytes = response.bytes().await?;
738
739        fs::create_dir_all(dest_path)?;
740
741        // Extract the tar.gz in a blocking task to avoid blocking the async runtime
742        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                // Strip the root directory (stellar-contracts-{tag}/)
752                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        // Get the latest release tag
805        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, &copy_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)] // TODO: remove if not used
909    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}