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