stellar_scaffold_cli/commands/generate/contract/
mod.rs

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