soroban_cli/commands/contract/
init.rs

1use 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    // TODO: remove in future version (23+) https://github.com/stellar/stellar-cli/issues/1586
33    #[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    // TODO: remove in future version (23+) https://github.com/stellar/stellar-cli/issues/1586
43    #[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        // create a project dir, and copy the contents of the base template (contract-init-template) into it
109        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            // We need to include the Cargo.toml file as Cargo.toml.removeextension in the template
139            // so that it will be included the package. This is making sure that the Cargo file is
140            // written as Cargo.toml in the new project. This is a workaround for this issue:
141            // https://github.com/rust-lang/cargo/issues/8597.
142            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    // test helpers
258    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}