soroban_cli/commands/contract/
init.rs1use std::borrow::Cow;
2use std::{
3 fs::{create_dir_all, metadata, write, Metadata},
4 io,
5 path::{Path, PathBuf},
6 str,
7};
8
9use clap::Parser;
10use rust_embed::RustEmbed;
11
12use crate::{commands::global, error_on_use_of_removed_arg, print, utils};
13
14const EXAMPLE_REMOVAL_NOTICE: &str = "Adding examples via cli is no longer supported. \
15You can still clone examples from the repo https://github.com/stellar/soroban-examples";
16const FRONTEND_EXAMPLE_REMOVAL_NOTICE: &str = "Using frontend template via cli is no longer \
17supported. You can search for frontend templates using github tags, \
18such as `soroban-template` or `soroban-frontend-template`";
19
20#[derive(Parser, Debug, Clone)]
21#[group(skip)]
22pub struct Cmd {
23 pub project_path: String,
24
25 #[arg(
26 long,
27 default_value = "hello-world",
28 long_help = "An optional flag to specify a new contract's name."
29 )]
30 pub name: String,
31
32 #[arg(
34 short,
35 long,
36 hide = true,
37 display_order = 100,
38 value_parser = error_on_use_of_removed_arg!(String, EXAMPLE_REMOVAL_NOTICE)
39 )]
40 pub with_example: Option<String>,
41
42 #[arg(
44 long,
45 hide = true,
46 display_order = 100,
47 value_parser = error_on_use_of_removed_arg!(String, FRONTEND_EXAMPLE_REMOVAL_NOTICE),
48 )]
49 pub frontend_template: Option<String>,
50
51 #[arg(long, long_help = "Overwrite all existing files.")]
52 pub overwrite: bool,
53}
54
55#[derive(thiserror::Error, Debug)]
56pub enum Error {
57 #[error("{0}: {1}")]
58 Io(String, io::Error),
59
60 #[error(transparent)]
61 Std(#[from] std::io::Error),
62
63 #[error("failed to convert bytes to string: {0}")]
64 ConvertBytesToString(#[from] str::Utf8Error),
65
66 #[error("contract package already exists: {0}")]
67 AlreadyExists(String),
68
69 #[error("provided project path exists and is not a directory")]
70 PathExistsNotDir,
71
72 #[error("provided project path exists and is not a cargo workspace root directory. Hint: run init on an empty or non-existing directory"
73 )]
74 PathExistsNotCargoProject,
75}
76
77impl Cmd {
78 #[allow(clippy::unused_self)]
79 pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
80 let runner = Runner {
81 args: self.clone(),
82 print: print::Print::new(global_args.quiet),
83 };
84
85 runner.run()
86 }
87}
88
89#[derive(RustEmbed)]
90#[folder = "src/utils/contract-workspace-template"]
91struct WorkspaceTemplateFiles;
92
93#[derive(RustEmbed)]
94#[folder = "src/utils/contract-template"]
95struct ContractTemplateFiles;
96
97struct Runner {
98 args: Cmd,
99 print: print::Print,
100}
101
102impl Runner {
103 fn run(&self) -> Result<(), Error> {
104 let project_path = PathBuf::from(&self.args.project_path);
105 self.print
106 .infoln(format!("Initializing workspace at {project_path:?}"));
107
108 Self::create_dir_all(&project_path)?;
110 self.copy_template_files(
111 project_path.as_path(),
112 &mut WorkspaceTemplateFiles::iter(),
113 WorkspaceTemplateFiles::get,
114 )?;
115
116 let contract_path = project_path.join("contracts").join(&self.args.name);
117 self.print
118 .infoln(format!("Initializing contract at {contract_path:?}"));
119
120 Self::create_dir_all(contract_path.as_path())?;
121 self.copy_template_files(
122 contract_path.as_path(),
123 &mut ContractTemplateFiles::iter(),
124 ContractTemplateFiles::get,
125 )?;
126
127 Ok(())
128 }
129
130 fn copy_template_files(
131 &self,
132 root_path: &Path,
133 files: &mut dyn Iterator<Item = Cow<str>>,
134 getter: fn(&str) -> Option<rust_embed::EmbeddedFile>,
135 ) -> Result<(), Error> {
136 for item in &mut *files {
137 let mut to = root_path.join(item.as_ref());
138 let item_path = Path::new(item.as_ref());
143 let is_toml = item_path.file_name().unwrap() == "Cargo.toml.removeextension";
144 if is_toml {
145 let item_parent_path = item_path.parent().unwrap();
146 to = root_path.join(item_parent_path).join("Cargo.toml");
147 }
148
149 let exists = Self::file_exists(&to);
150 if exists && !self.args.overwrite {
151 self.print
152 .infoln(format!("Skipped creating {to:?} as it already exists"));
153 continue;
154 }
155
156 Self::create_dir_all(to.parent().unwrap())?;
157
158 let Some(file) = getter(item.as_ref()) else {
159 self.print
160 .warnln(format!("Failed to read file: {}", item.as_ref()));
161 continue;
162 };
163
164 let mut file_contents = str::from_utf8(file.data.as_ref())
165 .map_err(Error::ConvertBytesToString)?
166 .to_string();
167
168 if is_toml {
169 let new_content = file_contents.replace("%contract-template%", &self.args.name);
170 file_contents = new_content;
171 }
172
173 if exists {
174 self.print
175 .plusln(format!("Writing {to:?} (overwriting existing file)"));
176 } else {
177 self.print.plusln(format!("Writing {to:?}"));
178 }
179 Self::write(&to, &file_contents)?;
180 }
181
182 Ok(())
183 }
184
185 fn file_exists(file_path: &Path) -> bool {
186 metadata(file_path)
187 .as_ref()
188 .map(Metadata::is_file)
189 .unwrap_or(false)
190 }
191
192 fn create_dir_all(path: &Path) -> Result<(), Error> {
193 create_dir_all(path).map_err(|e| Error::Io(format!("creating directory: {path:?}"), e))
194 }
195
196 fn write(path: &Path, contents: &str) -> Result<(), Error> {
197 write(path, contents).map_err(|e| Error::Io(format!("writing file: {path:?}"), e))
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use std::fs;
204 use std::fs::read_to_string;
205
206 use itertools::Itertools;
207
208 use super::*;
209
210 const TEST_PROJECT_NAME: &str = "test-project";
211
212 #[test]
213 fn test_init() {
214 let temp_dir = tempfile::tempdir().unwrap();
215 let project_dir = temp_dir.path().join(TEST_PROJECT_NAME);
216 let runner = Runner {
217 args: Cmd {
218 project_path: project_dir.to_string_lossy().to_string(),
219 name: "hello_world".to_string(),
220 with_example: None,
221 frontend_template: None,
222 overwrite: false,
223 },
224 print: print::Print::new(false),
225 };
226 runner.run().unwrap();
227
228 assert_base_template_files_exist(&project_dir);
229
230 assert_contract_files_exist(&project_dir, "hello_world");
231 assert_excluded_paths_do_not_exist(&project_dir);
232
233 assert_contract_cargo_file_is_well_formed(&project_dir, "hello_world");
234 assert_excluded_paths_do_not_exist(&project_dir);
235
236 let runner = Runner {
237 args: Cmd {
238 project_path: project_dir.to_string_lossy().to_string(),
239 name: "contract2".to_string(),
240 with_example: None,
241 frontend_template: None,
242 overwrite: false,
243 },
244 print: print::Print::new(false),
245 };
246 runner.run().unwrap();
247
248 assert_contract_files_exist(&project_dir, "contract2");
249 assert_excluded_paths_do_not_exist(&project_dir);
250
251 assert_contract_cargo_file_is_well_formed(&project_dir, "contract2");
252 assert_excluded_paths_do_not_exist(&project_dir);
253
254 temp_dir.close().unwrap();
255 }
256
257 fn assert_base_template_files_exist(project_dir: &Path) {
259 let expected_paths = ["contracts", "Cargo.toml", "README.md"];
260 for path in &expected_paths {
261 assert!(project_dir.join(path).exists());
262 }
263 }
264
265 fn assert_contract_files_exist(project_dir: &Path, contract_name: &str) {
266 let contract_dir = project_dir.join("contracts").join(contract_name);
267
268 assert!(contract_dir.exists());
269 assert!(contract_dir.as_path().join("Cargo.toml").exists());
270 assert!(contract_dir.as_path().join("src").join("lib.rs").exists());
271 assert!(contract_dir.as_path().join("src").join("test.rs").exists());
272 }
273
274 fn assert_contract_cargo_file_is_well_formed(project_dir: &Path, contract_name: &str) {
275 let contract_dir = project_dir.join("contracts").join(contract_name);
276 let cargo_toml_path = contract_dir.as_path().join("Cargo.toml");
277 let cargo_toml_str = read_to_string(cargo_toml_path.clone()).unwrap();
278 let doc: toml_edit::DocumentMut = cargo_toml_str.parse().unwrap();
279 assert!(
280 doc.get("dependencies")
281 .unwrap()
282 .get("soroban-sdk")
283 .unwrap()
284 .get("workspace")
285 .unwrap()
286 .as_bool()
287 .unwrap(),
288 "expected [dependencies.soroban-sdk] to be a workspace dependency"
289 );
290 assert!(
291 doc.get("dev-dependencies")
292 .unwrap()
293 .get("soroban-sdk")
294 .unwrap()
295 .get("workspace")
296 .unwrap()
297 .as_bool()
298 .unwrap(),
299 "expected [dev-dependencies.soroban-sdk] to be a workspace dependency"
300 );
301 assert_ne!(
302 0,
303 doc.get("dev-dependencies")
304 .unwrap()
305 .get("soroban-sdk")
306 .unwrap()
307 .get("features")
308 .unwrap()
309 .as_array()
310 .unwrap()
311 .len(),
312 "expected [dev-dependencies.soroban-sdk] to have a features list"
313 );
314 assert!(
315 doc.get("dev_dependencies").is_none(),
316 "erroneous 'dev_dependencies' section"
317 );
318 assert_eq!(
319 doc.get("lib")
320 .unwrap()
321 .get("crate-type")
322 .unwrap()
323 .as_array()
324 .unwrap()
325 .iter()
326 .map(|v| v.as_str().unwrap())
327 .collect::<Vec<_>>(),
328 ["lib", "cdylib"],
329 "expected [lib.crate-type] to be lib,cdylib"
330 );
331 }
332
333 fn assert_excluded_paths_do_not_exist(project_dir: &Path) {
334 let base_excluded_paths = [".git", ".github", "Makefile", ".vscode", "target"];
335 for path in &base_excluded_paths {
336 let filepath = project_dir.join(path);
337 assert!(!filepath.exists(), "{filepath:?} should not exist");
338 }
339 let contract_excluded_paths = ["target", "Cargo.lock"];
340 let contract_dirs = fs::read_dir(project_dir.join("contracts"))
341 .unwrap()
342 .map(|entry| entry.unwrap().path());
343 contract_dirs
344 .cartesian_product(contract_excluded_paths.iter())
345 .for_each(|(contract_dir, excluded_path)| {
346 let filepath = contract_dir.join(excluded_path);
347 assert!(!filepath.exists(), "{filepath:?} should not exist");
348 });
349 }
350}