stellar_scaffold_cli/commands/generate/contract/
mod.rs1use 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 #[arg(long, conflicts_with_all = ["ls", "from_wizard"])]
15 pub from: Option<String>,
16
17 #[arg(long, conflicts_with_all = ["from", "from_wizard"])]
19 pub ls: bool,
20
21 #[arg(long, conflicts_with_all = ["from", "ls"])]
23 pub from_wizard: bool,
24
25 #[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 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 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 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 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 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, ©_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}