1use anyhow::Context;
2use askama::Template;
3use cargo_metadata::DependencyKind::{Build, Development, Normal};
4use convert_case::{Case, Casing};
5use std::{
6 env,
7 ffi::OsStr,
8 fs::{self, File},
9 io::{self, Write},
10 path::{Path, PathBuf},
11 process::{Command, ExitStatus, Output, Stdio},
12};
13
14const SAILS_VERSION: &str = env!("CARGO_PKG_VERSION");
15const TOKIO_VERSION: &str = "1.48.0";
16
17trait ExitStatusExt: Sized {
18 fn exit_result(self) -> io::Result<()>;
19}
20
21impl ExitStatusExt for ExitStatus {
22 fn exit_result(self) -> io::Result<()> {
23 if self.success() {
24 Ok(())
25 } else {
26 Err(io::Error::from(io::ErrorKind::Other))
27 }
28 }
29}
30
31trait OutputExt: Sized {
32 fn exit_result(self) -> io::Result<Self>;
33}
34
35impl OutputExt for Output {
36 fn exit_result(self) -> io::Result<Self> {
37 if self.status.success() {
38 Ok(self)
39 } else {
40 Err(io::Error::from(io::ErrorKind::Other))
41 }
42 }
43}
44
45#[derive(Template)]
46#[template(path = ".github/workflows/ci.askama")]
47struct CIWorkflow {
48 git_branch_name: String,
49 client_file_name: String,
50}
51
52#[derive(Template)]
53#[template(path = "app/src/lib.askama")]
54struct AppLib {
55 service_name: String,
56 service_name_snake: String,
57 program_struct_name: String,
58}
59
60#[derive(Template)]
61#[template(path = "client/src/lib.askama")]
62struct ClientLib {
63 client_file_name: String,
64}
65
66#[derive(Template)]
67#[template(path = "client/build.askama")]
68struct ClientBuild {
69 app_crate_name: String,
70 program_struct_name: String,
71}
72
73#[derive(Template)]
74#[template(path = "src/lib.askama")]
75struct RootLib {
76 app_crate_name: String,
77}
78
79#[derive(Template)]
80#[template(path = "tests/gtest.askama")]
81struct TestsGtest {
82 program_crate_name: String,
83 client_crate_name: String,
84 client_program_name: String,
85 service_name: String,
86 service_name_snake: String,
87}
88
89#[derive(Template)]
90#[template(path = "build.askama")]
91struct RootBuild {
92 app_crate_name: String,
93 program_struct_name: String,
94}
95
96#[derive(Template)]
97#[template(path = "license.askama")]
98struct RootLicense {
99 package_author: String,
100}
101
102#[derive(Template)]
103#[template(path = "readme.askama")]
104struct RootReadme {
105 program_crate_name: String,
106 github_username: String,
107 app_crate_name: String,
108 client_crate_name: String,
109 service_name: String,
110}
111
112#[derive(Template)]
113#[template(path = "rust-toolchain.askama")]
114struct RootRustToolchain;
115
116pub struct ProgramGenerator {
117 path: PathBuf,
118 package_name: String,
119 package_author: String,
120 github_username: String,
121 client_file_name: String,
122 sails_path: Option<PathBuf>,
123 offline: bool,
124 ethereum: bool,
125 service_name: String,
126 program_struct_name: String,
127}
128
129impl ProgramGenerator {
130 const DEFAULT_AUTHOR: &str = "Gear Technologies";
131 const DEFAULT_GITHUB_USERNAME: &str = "gear-tech";
132
133 const GITIGNORE_ENTRIES: &[&str] =
134 &[".binpath", ".DS_Store", ".vscode", ".idea", "/target", ""];
135
136 pub fn new(
137 path: PathBuf,
138 name: Option<String>,
139 author: Option<String>,
140 username: Option<String>,
141 sails_path: Option<PathBuf>,
142 offline: bool,
143 ethereum: bool,
144 ) -> Self {
145 let package_name = name.map_or_else(
146 || {
147 path.file_name()
148 .expect("Invalid Path")
149 .to_str()
150 .expect("Invalid UTF-8 Path")
151 .to_case(Case::Kebab)
152 },
153 |name| name.to_case(Case::Kebab),
154 );
155 let service_name = package_name.to_case(Case::Pascal);
156 let package_author = author.unwrap_or_else(|| Self::DEFAULT_AUTHOR.to_string());
157 let github_username = username.unwrap_or_else(|| Self::DEFAULT_GITHUB_USERNAME.to_string());
158 let client_file_name = format!("{}_client", package_name.to_case(Case::Snake));
159 Self {
160 path,
161 package_name,
162 package_author,
163 github_username,
164 client_file_name,
165 sails_path,
166 offline,
167 ethereum,
168 service_name,
169 program_struct_name: "Program".to_string(),
170 }
171 }
172
173 fn ci_workflow(&self, git_branch_name: &str) -> CIWorkflow {
174 CIWorkflow {
175 git_branch_name: git_branch_name.into(),
176 client_file_name: self.client_file_name.clone(),
177 }
178 }
179
180 fn app_lib(&self) -> AppLib {
181 AppLib {
182 service_name: self.service_name.clone(),
183 service_name_snake: self.service_name.to_case(Case::Snake),
184 program_struct_name: self.program_struct_name.clone(),
185 }
186 }
187
188 fn client_lib(&self) -> ClientLib {
189 ClientLib {
190 client_file_name: self.client_file_name.clone(),
191 }
192 }
193
194 fn client_build(&self) -> ClientBuild {
195 ClientBuild {
196 app_crate_name: self.app_name().to_case(Case::Snake),
197 program_struct_name: self.program_struct_name.clone(),
198 }
199 }
200
201 fn root_lib(&self) -> RootLib {
202 RootLib {
203 app_crate_name: self.app_name().to_case(Case::Snake),
204 }
205 }
206
207 fn tests_gtest(&self) -> TestsGtest {
208 TestsGtest {
209 program_crate_name: self.package_name.to_case(Case::Snake),
210 client_crate_name: self.client_name().to_case(Case::Snake),
211 client_program_name: self.client_name().to_case(Case::Pascal),
212 service_name: self.service_name.clone(),
213 service_name_snake: self.service_name.to_case(Case::Snake),
214 }
215 }
216
217 fn root_build(&self) -> RootBuild {
218 RootBuild {
219 app_crate_name: self.app_name().to_case(Case::Snake),
220 program_struct_name: self.program_struct_name.clone(),
221 }
222 }
223
224 fn root_license(&self) -> RootLicense {
225 RootLicense {
226 package_author: self.package_author.clone(),
227 }
228 }
229
230 fn root_readme(&self) -> RootReadme {
231 RootReadme {
232 program_crate_name: self.package_name.clone(),
233 github_username: self.github_username.clone(),
234 app_crate_name: self.app_name(),
235 client_crate_name: self.client_name(),
236 service_name: self.service_name.clone(),
237 }
238 }
239
240 fn root_rust_toolchain(&self) -> RootRustToolchain {
241 RootRustToolchain
242 }
243
244 fn app_path(&self) -> PathBuf {
245 self.path.join("app")
246 }
247
248 fn app_name(&self) -> String {
249 format!("{}-app", self.package_name)
250 }
251
252 fn client_path(&self) -> PathBuf {
253 self.path.join("client")
254 }
255
256 fn client_name(&self) -> String {
257 format!("{}-client", self.package_name)
258 }
259
260 fn cargo_add_sails_rs<P: AsRef<Path>>(
261 &self,
262 manifest_path: P,
263 dependency: cargo_metadata::DependencyKind,
264 features: Option<&str>,
265 ) -> anyhow::Result<()> {
266 let sails_package = ["sails-rs"];
267 cargo_add(
268 manifest_path,
269 sails_package,
270 dependency,
271 features,
272 self.offline,
273 )
274 }
275
276 pub fn generate(self) -> anyhow::Result<()> {
277 self.generate_root()?;
278 self.generate_app()?;
279 self.generate_client()?;
280 self.generate_build()?;
281 self.generate_tests()?;
282 self.fmt()?;
283 Ok(())
284 }
285
286 fn generate_app(&self) -> anyhow::Result<()> {
287 let path = &self.app_path();
288 cargo_new(path, &self.app_name(), self.offline, false)?;
289 let manifest_path = &manifest_path(path);
290
291 self.cargo_add_sails_rs(manifest_path, Normal, self.ethereum.then_some("ethexe"))?;
293
294 let mut lib_rs = File::create(lib_rs_path(path))?;
295 self.app_lib().write_into(&mut lib_rs)?;
296
297 Ok(())
298 }
299
300 fn generate_root(&self) -> anyhow::Result<()> {
301 let path = &self.path;
302 cargo_new(path, &self.package_name, self.offline, true)?;
303
304 let git_branch_name = git_show_current_branch(path)?;
305
306 fs::create_dir_all(ci_workflow_dir_path(path))?;
307 let mut ci_workflow_yml = File::create(ci_workflow_path(path))?;
308 self.ci_workflow(&git_branch_name)
309 .write_into(&mut ci_workflow_yml)?;
310
311 let mut gitignore = File::create(gitignore_path(path))?;
312 gitignore.write_all(Self::GITIGNORE_ENTRIES.join("\n").as_ref())?;
313
314 let manifest_path = &manifest_path(path);
315 cargo_toml_create_workspace_and_fill_package(
316 manifest_path,
317 &self.package_name,
318 &self.package_author,
319 &self.github_username,
320 &self.sails_path,
321 )?;
322
323 let mut license = File::create(license_path(path))?;
324 self.root_license().write_into(&mut license)?;
325
326 let mut readme_md = File::create(readme_path(path))?;
327 self.root_readme().write_into(&mut readme_md)?;
328
329 let mut rust_toolchain_toml = File::create(rust_toolchain_path(path))?;
330 self.root_rust_toolchain()
331 .write_into(&mut rust_toolchain_toml)?;
332
333 Ok(())
334 }
335
336 fn generate_build(&self) -> anyhow::Result<()> {
337 let path = &self.path;
338 let manifest_path = &manifest_path(path);
339
340 let mut lib_rs = File::create(lib_rs_path(path))?;
341 self.root_lib().write_into(&mut lib_rs)?;
342
343 let mut build_rs = File::create(build_rs_path(path))?;
344 self.root_build().write_into(&mut build_rs)?;
345
346 cargo_add(manifest_path, [self.app_name()], Normal, None, self.offline)?;
348 cargo_add(manifest_path, [self.app_name()], Build, None, self.offline)?;
349
350 self.cargo_add_sails_rs(manifest_path, Normal, self.ethereum.then_some("ethexe"))?;
352 self.cargo_add_sails_rs(
353 manifest_path,
354 Build,
355 Some(if self.ethereum {
356 "ethexe,build"
357 } else {
358 "build"
359 }),
360 )?;
361
362 Ok(())
363 }
364
365 fn generate_client(&self) -> anyhow::Result<()> {
366 let path = &self.client_path();
367 cargo_new(path, &self.client_name(), self.offline, false)?;
368
369 let manifest_path = &manifest_path(path);
370 self.cargo_add_sails_rs(manifest_path, Normal, self.ethereum.then_some("ethexe"))?;
372 self.cargo_add_sails_rs(
373 manifest_path,
374 Build,
375 Some(if self.ethereum {
376 "ethexe,build"
377 } else {
378 "build"
379 }),
380 )?;
381
382 cargo_add(manifest_path, [self.app_name()], Build, None, self.offline)?;
384
385 let mut build_rs = File::create(build_rs_path(path))?;
386 self.client_build().write_into(&mut build_rs)?;
387
388 let mut lib_rs = File::create(lib_rs_path(path))?;
389 self.client_lib().write_into(&mut lib_rs)?;
390
391 Ok(())
392 }
393
394 fn generate_tests(&self) -> anyhow::Result<()> {
395 let path = &self.path;
396 let manifest_path = &manifest_path(path);
397 self.cargo_add_sails_rs(
399 manifest_path,
400 Development,
401 Some(if self.ethereum {
402 "ethexe,gtest,gclient"
403 } else {
404 "gtest,gclient"
405 }),
406 )?;
407
408 cargo_add(
410 manifest_path,
411 ["tokio"],
412 Development,
413 Some("rt,macros"),
414 self.offline,
415 )?;
416
417 cargo_add(
419 manifest_path,
420 [self.app_name()],
421 Development,
422 None,
423 self.offline,
424 )?;
425 cargo_add(
427 manifest_path,
428 [self.client_name()],
429 Development,
430 None,
431 self.offline,
432 )?;
433
434 let test_path = &tests_path(path);
436 fs::create_dir_all(test_path.parent().context("Parent should exists")?)?;
437 let mut gtest_rs = File::create(test_path)?;
438 self.tests_gtest().write_into(&mut gtest_rs)?;
439
440 Ok(())
441 }
442
443 fn fmt(&self) -> anyhow::Result<()> {
444 let manifest_path = &manifest_path(&self.path);
445 cargo_fmt(manifest_path)
446 }
447}
448
449fn git_show_current_branch<P: AsRef<Path>>(target_dir: P) -> anyhow::Result<String> {
450 let git_command = git_command();
451 let mut cmd = Command::new(git_command);
452 cmd.stdout(Stdio::piped())
453 .arg("-C")
454 .arg(target_dir.as_ref())
455 .arg("branch")
456 .arg("--show-current");
457
458 let output = cmd
459 .output()?
460 .exit_result()
461 .context("failed to get current git branch")?;
462 let git_branch_name = String::from_utf8(output.stdout)?;
463
464 Ok(git_branch_name.trim().into())
465}
466
467fn cargo_new<P: AsRef<Path>>(
468 target_dir: P,
469 name: &str,
470 offline: bool,
471 root: bool,
472) -> anyhow::Result<()> {
473 let cargo_command = cargo_command();
474 let target_dir = target_dir.as_ref();
475 let cargo_new_or_init = if target_dir.exists() { "init" } else { "new" };
476 let mut cmd = Command::new(cargo_command);
477 cmd.stdout(Stdio::null()) .arg(cargo_new_or_init)
479 .arg(target_dir)
480 .arg("--name")
481 .arg(name)
482 .arg("--lib")
483 .arg("--quiet");
484
485 if offline {
486 cmd.arg("--offline");
487 }
488
489 cmd.status()
490 .context("failed to execute `cargo new` command")?
491 .exit_result()
492 .context("failed to run `cargo new` command")?;
493
494 if !root {
495 let manifest_path = target_dir.join("Cargo.toml");
496 let cargo_toml = fs::read_to_string(&manifest_path)?;
497 let mut document: toml_edit::DocumentMut = cargo_toml.parse()?;
498
499 let crate_path = name
500 .rsplit_once('-')
501 .map(|(_, crate_path)| crate_path)
502 .unwrap_or(name);
503 let description = match crate_path {
504 "app" => "Package containing business logic for the program",
505 "client" => {
506 "Package containing the client for the program allowing to interact with it"
507 }
508 _ => unreachable!(),
509 };
510
511 let package = document
512 .entry("package")
513 .or_insert_with(toml_edit::table)
514 .as_table_mut()
515 .context("package was not a table in Cargo.toml")?;
516
517 let mut entries = vec![];
518
519 for key in ["repository", "license", "keywords", "categories"] {
520 if let Some(entry) = package.remove_entry(key) {
521 entries.push(entry);
522 }
523 }
524
525 _ = package
526 .entry("description")
527 .or_insert_with(|| toml_edit::value(description));
528
529 for (key, item) in entries {
530 package.insert_formatted(&key, item);
531 }
532
533 fs::write(manifest_path, document.to_string())?;
534
535 if let Some(parent_dir) = target_dir.parent() {
536 let manifest_path = parent_dir.join("Cargo.toml");
537 let cargo_toml = fs::read_to_string(&manifest_path)?;
538 let mut document: toml_edit::DocumentMut = cargo_toml.parse()?;
539
540 let workspace = document
541 .entry("workspace")
542 .or_insert_with(toml_edit::table)
543 .as_table_mut()
544 .context("workspace was not a table in Cargo.toml")?;
545
546 let dependencies = workspace
547 .entry("dependencies")
548 .or_insert_with(toml_edit::table)
549 .as_table_mut()
550 .context("workspace.dependencies was not a table in Cargo.toml")?;
551
552 let mut dependency = toml_edit::InlineTable::new();
553 dependency.insert("version", "0.1.0".into());
554 dependency.insert("path", crate_path.into());
555
556 dependencies.insert(name, dependency.into());
557
558 fs::write(manifest_path, document.to_string())?;
559 }
560 }
561
562 Ok(())
563}
564
565fn cargo_add<P, I, S>(
566 manifest_path: P,
567 packages: I,
568 dependency: cargo_metadata::DependencyKind,
569 features: Option<&str>,
570 offline: bool,
571) -> anyhow::Result<()>
572where
573 P: AsRef<Path>,
574 I: IntoIterator<Item = S>,
575 S: AsRef<OsStr>,
576{
577 let cargo_command = cargo_command();
578
579 let mut cmd = Command::new(cargo_command);
580 cmd.stdout(Stdio::null()) .arg("add")
582 .args(packages)
583 .arg("--manifest-path")
584 .arg(manifest_path.as_ref())
585 .arg("--quiet");
586
587 match dependency {
588 Development => {
589 cmd.arg("--dev");
590 }
591 Build => {
592 cmd.arg("--build");
593 }
594 _ => (),
595 };
596
597 if let Some(features) = features {
598 cmd.arg("--features").arg(features);
599 }
600
601 if offline {
602 cmd.arg("--offline");
603 }
604
605 cmd.status()
606 .context("failed to execute `cargo add` command")?
607 .exit_result()
608 .context("failed to run `cargo add` command")?;
609
610 Ok(())
611}
612
613fn cargo_fmt<P: AsRef<Path>>(manifest_path: P) -> anyhow::Result<()> {
614 let cargo_command = cargo_command();
615
616 let mut cmd = Command::new(cargo_command);
617 cmd.stdout(Stdio::null()) .arg("fmt")
619 .arg("--manifest-path")
620 .arg(manifest_path.as_ref())
621 .arg("--quiet");
622
623 cmd.status()
624 .context("failed to execute `cargo fmt` command")?
625 .exit_result()
626 .context("failed to run `cargo fmt` command")
627}
628
629fn cargo_toml_create_workspace_and_fill_package<P: AsRef<Path>>(
630 manifest_path: P,
631 name: &str,
632 author: &str,
633 username: &str,
634 sails_path: &Option<PathBuf>,
635) -> anyhow::Result<()> {
636 let manifest_path = manifest_path.as_ref();
637 let cargo_toml = fs::read_to_string(manifest_path)?;
638 let mut document: toml_edit::DocumentMut = cargo_toml.parse()?;
639
640 let package = document
641 .entry("package")
642 .or_insert_with(toml_edit::table)
643 .as_table_mut()
644 .context("package was not a table in Cargo.toml")?;
645 package.remove("edition");
646 for key in [
647 "version",
648 "authors",
649 "edition",
650 "rust-version",
651 "description",
652 "repository",
653 "license",
654 "keywords",
655 "categories",
656 ] {
657 if key == "description" {
658 _ = package.entry(key).or_insert_with(|| {
659 toml_edit::value(
660 "Package allowing to build WASM binary for the program and IDL file for it",
661 )
662 });
663 } else {
664 let item = package.entry(key).or_insert_with(toml_edit::table);
665 let mut table = toml_edit::Table::new();
666 table.insert("workspace", toml_edit::value(true));
667 table.set_dotted(true);
668 *item = table.into();
669 }
670 }
671
672 for key in ["dev-dependencies", "build-dependencies"] {
673 _ = document
674 .entry(key)
675 .or_insert_with(toml_edit::table)
676 .as_table_mut()
677 .with_context(|| format!("package.{key} was not a table in Cargo.toml"))?;
678 }
679
680 let workspace = document
681 .entry("workspace")
682 .or_insert_with(toml_edit::table)
683 .as_table_mut()
684 .context("workspace was not a table in Cargo.toml")?;
685 _ = workspace
686 .entry("resolver")
687 .or_insert_with(|| toml_edit::value("3"));
688 _ = workspace
689 .entry("members")
690 .or_insert_with(|| toml_edit::value(toml_edit::Array::new()));
691
692 let workspace_package = workspace
693 .entry("package")
694 .or_insert_with(toml_edit::table)
695 .as_table_mut()
696 .context("workspace.package was not a table in Cargo.toml")?;
697 _ = workspace_package
698 .entry("version")
699 .or_insert_with(|| toml_edit::value("0.1.0"));
700 let mut authors = toml_edit::Array::new();
701 authors.push(author);
702 _ = workspace_package
703 .entry("authors")
704 .or_insert_with(|| toml_edit::value(authors));
705 for (key, value) in [
706 ("edition", "2024".into()),
707 ("rust-version", "1.91".into()),
708 (
709 "repository",
710 format!("https://github.com/{username}/{name}"),
711 ),
712 ("license", "MIT".into()),
713 ] {
714 _ = workspace_package
715 .entry(key)
716 .or_insert_with(|| toml_edit::value(value));
717 }
718 let keywords =
719 toml_edit::Array::from_iter(["gear", "sails", "smart-contracts", "wasm", "no-std"]);
720 _ = workspace_package
721 .entry("keywords")
722 .or_insert_with(|| toml_edit::value(keywords));
723 let categories =
724 toml_edit::Array::from_iter(["cryptography::cryptocurrencies", "no-std", "wasm"]);
725 _ = workspace_package
726 .entry("categories")
727 .or_insert_with(|| toml_edit::value(categories));
728
729 let dependencies = workspace
730 .entry("dependencies")
731 .or_insert_with(toml_edit::table)
732 .as_table_mut()
733 .context("workspace.dependencies was not a table in Cargo.toml")?;
734
735 if let Some(sails_path) = sails_path {
736 let mut dependency = toml_edit::InlineTable::new();
737 dependency.insert(
738 "path",
739 sails_path
740 .canonicalize()?
741 .to_str()
742 .context("failed to convert to UTF-8 string")?
743 .into(),
744 );
745 dependencies.insert("sails-rs", dependency.into());
746 } else {
747 dependencies.insert("sails-rs", SAILS_VERSION.into());
748 }
749
750 dependencies.insert("tokio", TOKIO_VERSION.into());
751
752 fs::write(manifest_path, document.to_string())?;
753
754 Ok(())
755}
756
757fn ci_workflow_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
758 path.as_ref().join(".github/workflows")
759}
760
761fn ci_workflow_path<P: AsRef<Path>>(path: P) -> PathBuf {
762 path.as_ref().join(".github/workflows/ci.yml")
763}
764
765fn gitignore_path<P: AsRef<Path>>(path: P) -> PathBuf {
766 path.as_ref().join(".gitignore")
767}
768
769fn manifest_path<P: AsRef<Path>>(path: P) -> PathBuf {
770 path.as_ref().join("Cargo.toml")
771}
772
773fn build_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
774 path.as_ref().join("build.rs")
775}
776
777fn lib_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
778 path.as_ref().join("src/lib.rs")
779}
780
781fn tests_path<P: AsRef<Path>>(path: P) -> PathBuf {
782 path.as_ref().join("tests/gtest.rs")
783}
784
785fn license_path<P: AsRef<Path>>(path: P) -> PathBuf {
786 path.as_ref().join("LICENSE")
787}
788
789fn readme_path<P: AsRef<Path>>(path: P) -> PathBuf {
790 path.as_ref().join("README.md")
791}
792
793fn rust_toolchain_path<P: AsRef<Path>>(path: P) -> PathBuf {
794 path.as_ref().join("rust-toolchain.toml")
795}
796
797fn git_command() -> String {
798 env::var("GIT").unwrap_or("git".into())
799}
800
801fn cargo_command() -> String {
802 env::var("CARGO").unwrap_or("cargo".into())
803}