stellar_scaffold_cli/commands/generate/contract/
mod.rs

1use clap::Parser;
2use reqwest;
3use serde::Deserialize;
4use std::{fs, path::Path};
5
6#[derive(Deserialize)]
7struct Release {
8    tag_name: String,
9}
10
11#[derive(Parser, Debug)]
12pub struct Cmd {
13    /// Clone contract from `OpenZeppelin` examples
14    #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
15    pub from: Option<String>,
16
17    /// List available contract examples
18    #[arg(long, conflicts_with_all = ["from", "from_wizard"])]
19    pub ls: bool,
20
21    /// Open contract generation wizard in browser
22    #[arg(long, conflicts_with_all = ["from", "ls"])]
23    pub from_wizard: bool,
24
25    /// Output directory for the generated contract (defaults to contracts/<example-name>)
26    #[arg(short, long)]
27    pub output: Option<String>,
28}
29
30#[derive(thiserror::Error, Debug)]
31pub enum Error {
32    #[error(transparent)]
33    Io(#[from] std::io::Error),
34    #[error(transparent)]
35    Reqwest(#[from] reqwest::Error),
36    #[error("Git command failed: {0}")]
37    GitCloneFailed(String),
38    #[error("Example '{0}' not found in OpenZeppelin stellar-contracts")]
39    ExampleNotFound(String),
40    #[error("Failed to open browser: {0}")]
41    BrowserFailed(String),
42    #[error("No action specified. Use --from, --ls, or --from-wizard")]
43    NoActionSpecified,
44}
45
46impl Cmd {
47    pub async fn run(&self) -> Result<(), Error> {
48        match (&self.from, self.ls, self.from_wizard) {
49            (Some(example_name), _, _) => self.clone_example(example_name).await,
50            (_, true, _) => self.list_examples().await,
51            (_, _, true) => open_wizard(),
52            _ => Err(Error::NoActionSpecified),
53        }
54    }
55
56    async fn ensure_cache_updated(&self) -> Result<std::path::PathBuf, Error> {
57        let cache_dir = dirs::cache_dir().ok_or_else(|| {
58            Error::Io(std::io::Error::new(
59                std::io::ErrorKind::NotFound,
60                "Cache directory not found",
61            ))
62        })?;
63
64        let base_cache_path = cache_dir.join("stellar-scaffold-cli/openzeppelin-stellar-contracts");
65
66        // Get the latest release tag
67        let Release { tag_name } = Self::fetch_latest_release().await?;
68        let repo_cache_path = base_cache_path.join(&tag_name);
69        let cache_ref_file = repo_cache_path.join(".release_ref");
70
71        let should_update_cache = if repo_cache_path.exists() {
72            if let Ok(cached_tag) = fs::read_to_string(&cache_ref_file) {
73                if cached_tag.trim() == tag_name {
74                    eprintln!("šŸ“‚ Using cached repository (release {tag_name})...");
75                    false
76                } else {
77                    eprintln!("šŸ“‚ New release available ({tag_name}). Updating cache...");
78                    true
79                }
80            } else {
81                eprintln!("šŸ“‚ Cache metadata missing. Updating...");
82                true
83            }
84        } else {
85            eprintln!("šŸ“‚ Cache not found. Downloading release {tag_name}...");
86            true
87        };
88
89        if should_update_cache {
90            if repo_cache_path.exists() {
91                fs::remove_dir_all(&repo_cache_path)?;
92            }
93            Self::cache_repository(&repo_cache_path, &cache_ref_file, &tag_name)?;
94        }
95
96        Ok(repo_cache_path)
97    }
98
99    async fn clone_example(&self, example_name: &str) -> Result<(), Error> {
100        eprintln!("šŸ” Downloading example '{example_name}'...");
101
102        let dest_path = self
103            .output
104            .clone()
105            .unwrap_or_else(|| format!("contracts/{example_name}"));
106
107        let repo_cache_path = self.ensure_cache_updated().await?;
108
109        // Check if the example exists
110        let example_source_path = repo_cache_path.join(format!("examples/{example_name}"));
111        if !example_source_path.exists() {
112            return Err(Error::ExampleNotFound(example_name.to_string()));
113        }
114
115        // Create destination and copy contents
116        fs::create_dir_all(&dest_path)?;
117        Self::copy_directory_contents(&example_source_path, Path::new(&dest_path))?;
118
119        eprintln!("āœ… Successfully downloaded example '{example_name}' to {dest_path}");
120        Ok(())
121    }
122
123    async fn list_examples(&self) -> Result<(), Error> {
124        eprintln!("šŸ“‹ Fetching available contract examples...");
125
126        let repo_cache_path = self.ensure_cache_updated().await?;
127
128        // Read examples from the cached repository
129        let examples_path = repo_cache_path.join("examples");
130        let mut examples = Vec::new();
131
132        if examples_path.exists() {
133            for entry in fs::read_dir(examples_path)? {
134                let entry = entry?;
135                if entry.path().is_dir() {
136                    if let Some(name) = entry.file_name().to_str() {
137                        examples.push(name.to_string());
138                    }
139                }
140            }
141            examples.sort();
142        }
143
144        eprintln!("\nšŸ“¦ Available contract examples:");
145        eprintln!("────────────────────────────────");
146
147        for example in examples {
148            eprintln!("  šŸ“ {example}");
149        }
150
151        eprintln!("\nšŸ’” Usage:");
152        eprintln!("   stellar-registry contract generate --from <example-name>");
153        eprintln!("   Example: stellar-registry contract generate --from nft-royalties");
154
155        Ok(())
156    }
157
158    async fn fetch_latest_release() -> Result<Release, Error> {
159        Self::fetch_latest_release_from_url(
160            "https://api.github.com/repos/OpenZeppelin/stellar-contracts/releases/latest",
161        )
162        .await
163    }
164
165    async fn fetch_latest_release_from_url(url: &str) -> Result<Release, Error> {
166        let client = reqwest::Client::new();
167        let response = client
168            .get(url)
169            .header("User-Agent", "stellar-scaffold-cli")
170            .send()
171            .await?;
172
173        if !response.status().is_success() {
174            return Err(Error::Reqwest(response.error_for_status().unwrap_err()));
175        }
176
177        let release: Release = response.json().await?;
178        Ok(release)
179    }
180
181    fn cache_repository(
182        repo_cache_path: &Path,
183        cache_ref_file: &Path,
184        tag_name: &str,
185    ) -> Result<(), Error> {
186        fs::create_dir_all(repo_cache_path)?;
187
188        // Use the specific tag instead of main branch
189        let repo_ref = format!("OpenZeppelin/stellar-contracts#{tag_name}");
190        degit::degit(&repo_ref, &repo_cache_path.to_string_lossy());
191
192        if repo_cache_path.read_dir()?.next().is_none() {
193            return Err(Error::GitCloneFailed(format!(
194                "Failed to download repository release {tag_name} to cache"
195            )));
196        }
197
198        fs::write(cache_ref_file, tag_name)?;
199        Ok(())
200    }
201
202    fn copy_directory_contents(source: &Path, dest: &Path) -> Result<(), Error> {
203        let copy_options = fs_extra::dir::CopyOptions::new()
204            .overwrite(true)
205            .copy_inside(true);
206
207        fs_extra::dir::copy(source, dest, &copy_options)
208            .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
209
210        Ok(())
211    }
212}
213
214fn open_wizard() -> Result<(), Error> {
215    eprintln!("šŸ§™ Opening OpenZeppelin Contract Wizard...");
216
217    let url = "https://wizard.openzeppelin.com/stellar";
218
219    webbrowser::open(url)
220        .map_err(|e| Error::BrowserFailed(format!("Failed to open browser: {e}")))?;
221
222    eprintln!("āœ… Opened Contract Wizard in your default browser");
223    eprintln!("\nšŸ“‹ Instructions:");
224    eprintln!("   1. Configure your contract in the wizard");
225    eprintln!("   2. Click 'Download' to get your contract files");
226    eprintln!("   3. Extract the downloaded ZIP file");
227    eprintln!("   4. Move the contract folder to your contracts/ directory");
228    eprintln!("   5. Add the contract to your workspace Cargo.toml if needed");
229    eprintln!(
230        "\nšŸ’” The wizard will generate a complete Soroban contract with your selected features!"
231    );
232
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use mockito::{mock, server_url};
240
241    fn create_test_cmd(from: Option<String>, ls: bool, from_wizard: bool) -> Cmd {
242        Cmd {
243            from,
244            ls,
245            from_wizard,
246            output: None,
247        }
248    }
249
250    #[tokio::test]
251    async fn test_ls_command() {
252        let cmd = create_test_cmd(None, true, false);
253
254        let _m = mock(
255            "GET",
256            "/repos/OpenZeppelin/stellar-contracts/contents/examples",
257        )
258        .with_status(200)
259        .with_header("content-type", "application/json")
260        .with_body(r#"[{"name": "example1", "type": "dir"}, {"name": "example2", "type": "dir"}]"#)
261        .create();
262
263        let result = cmd.run().await;
264        assert!(result.is_ok());
265    }
266
267    #[tokio::test]
268    async fn test_fetch_latest_release() {
269        let _m = mock(
270            "GET",
271            "/repos/OpenZeppelin/stellar-contracts/releases/latest",
272        )
273        .with_status(200)
274        .with_header("content-type", "application/json")
275        .with_body(
276            r#"{
277                "tag_name": "v1.2.3",
278                "name": "Release v1.2.3",
279                "published_at": "2024-01-15T10:30:00Z"
280            }"#,
281        )
282        .create();
283
284        let mock_url = format!(
285            "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
286            server_url()
287        );
288        let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
289
290        assert!(result.is_ok());
291        let release = result.unwrap();
292        assert_eq!(release.tag_name, "v1.2.3");
293    }
294
295    #[tokio::test]
296    async fn test_fetch_latest_release_error() {
297        let _m = mock(
298            "GET",
299            "/repos/OpenZeppelin/stellar-contracts/releases/latest",
300        )
301        .with_status(404)
302        .with_header("content-type", "application/json")
303        .with_body(r#"{"message": "Not Found"}"#)
304        .create();
305
306        let mock_url = format!(
307            "{}/repos/OpenZeppelin/stellar-contracts/releases/latest",
308            server_url()
309        );
310        let result = Cmd::fetch_latest_release_from_url(&mock_url).await;
311
312        assert!(result.is_err());
313    }
314
315    #[tokio::test]
316    async fn test_no_action_specified() {
317        let cmd = create_test_cmd(None, false, false);
318        let result = cmd.run().await;
319        assert!(matches!(result, Err(Error::NoActionSpecified)));
320    }
321}