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");
15
16trait ExitStatusExt: Sized {
17 fn exit_result(self) -> io::Result<()>;
18}
19
20impl ExitStatusExt for ExitStatus {
21 fn exit_result(self) -> io::Result<()> {
22 if self.success() {
23 Ok(())
24 } else {
25 Err(io::Error::from(io::ErrorKind::Other))
26 }
27 }
28}
29
30trait OutputExt: Sized {
31 fn exit_result(self) -> io::Result<Self>;
32}
33
34impl OutputExt for Output {
35 fn exit_result(self) -> io::Result<Self> {
36 if self.status.success() {
37 Ok(self)
38 } else {
39 Err(io::Error::from(io::ErrorKind::Other))
40 }
41 }
42}
43
44#[derive(Template)]
45#[template(path = ".github/workflows/ci.askama")]
46struct CIWorkflow {
47 git_branch_name: String,
48 client_file_name: String,
49}
50
51#[derive(Template)]
52#[template(path = "app/src/lib.askama")]
53struct AppLib {
54 service_name: String,
55 service_name_snake: String,
56 program_struct_name: String,
57}
58
59#[derive(Template)]
60#[template(path = "client/src/lib.askama")]
61struct ClientLib {
62 client_file_name: String,
63}
64
65#[derive(Template)]
66#[template(path = "client/build.askama")]
67struct ClientBuild {
68 app_crate_name: String,
69 program_struct_name: String,
70}
71
72#[derive(Template)]
73#[template(path = "src/lib.askama")]
74struct RootLib {
75 app_crate_name: String,
76}
77
78#[derive(Template)]
79#[template(path = "tests/gtest.askama")]
80struct TestsGtest {
81 program_crate_name: String,
82 client_crate_name: String,
83 client_program_name: String,
84 service_name: String,
85 service_name_snake: String,
86}
87
88#[derive(Template)]
89#[template(path = "build.askama")]
90struct RootBuild {
91 app_crate_name: String,
92 program_struct_name: String,
93}
94
95#[derive(Template)]
96#[template(path = "license.askama")]
97struct RootLicense {
98 package_author: String,
99}
100
101#[derive(Template)]
102#[template(path = "readme.askama")]
103struct RootReadme {
104 program_crate_name: String,
105 github_username: String,
106 app_crate_name: String,
107 client_crate_name: String,
108 service_name: String,
109 app: bool,
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 app: bool,
124 offline: 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 app: bool,
143 offline: 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 app,
167 offline,
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 app: self.app,
238 }
239 }
240
241 fn root_rust_toolchain(&self) -> RootRustToolchain {
242 RootRustToolchain
243 }
244
245 fn app_path(&self) -> PathBuf {
246 if self.app {
247 self.path.clone()
248 } else {
249 self.path.join("app")
250 }
251 }
252
253 fn app_name(&self) -> String {
254 if self.app {
255 self.package_name.clone()
256 } else {
257 format!("{}-app", self.package_name)
258 }
259 }
260
261 fn client_path(&self) -> PathBuf {
262 self.path.join("client")
263 }
264
265 fn client_name(&self) -> String {
266 format!("{}-client", self.package_name)
267 }
268
269 fn cargo_add_sails_rs<P: AsRef<Path>>(
270 &self,
271 manifest_path: P,
272 dependency: cargo_metadata::DependencyKind,
273 features: Option<&str>,
274 ) -> anyhow::Result<()> {
275 if let Some(sails_path) = self.sails_path.as_ref() {
276 cargo_add_by_path(
277 manifest_path,
278 sails_path,
279 dependency,
280 features,
281 self.app,
282 self.offline,
283 )
284 } else {
285 let sails_package = &[format!("sails-rs@{SAILS_VERSION}")];
286 cargo_add(
287 manifest_path,
288 sails_package,
289 dependency,
290 features,
291 self.app,
292 self.offline,
293 )
294 }
295 }
296
297 pub fn generate(self) -> anyhow::Result<()> {
298 if self.app {
299 self.generate_app()?;
300 } else {
301 self.generate_root()?;
302 self.generate_app()?;
303 self.generate_client()?;
304 self.generate_build()?;
305 self.generate_tests()?;
306 }
307 self.fmt()?;
308 Ok(())
309 }
310
311 fn generate_app(&self) -> anyhow::Result<()> {
312 let path = &self.app_path();
313 cargo_new(path, &self.app_name(), self.app, self.offline)?;
314 let manifest_path = &manifest_path(path);
315
316 self.cargo_add_sails_rs(manifest_path, Normal, None)?;
318
319 let mut lib_rs = File::create(lib_rs_path(path))?;
320 self.app_lib().write_into(&mut lib_rs)?;
321
322 Ok(())
323 }
324
325 fn generate_root(&self) -> anyhow::Result<()> {
326 let path = &self.path;
327 cargo_new(path, &self.package_name, self.app, self.offline)?;
328
329 let git_branch_name = git_show_current_branch(path)?;
330
331 fs::create_dir_all(ci_workflow_dir_path(path))?;
332 let mut ci_workflow_yml = File::create(ci_workflow_path(path))?;
333 self.ci_workflow(&git_branch_name)
334 .write_into(&mut ci_workflow_yml)?;
335
336 let mut gitignore = File::create(gitignore_path(path))?;
337 gitignore.write_all(Self::GITIGNORE_ENTRIES.join("\n").as_ref())?;
338
339 let manifest_path = &manifest_path(path);
340 cargo_toml_create_workspace_and_fill_package(
341 manifest_path,
342 &self.package_name,
343 &self.package_author,
344 &self.github_username,
345 )?;
346
347 let mut license = File::create(license_path(path))?;
348 self.root_license().write_into(&mut license)?;
349
350 let mut readme_md = File::create(readme_path(path))?;
351 self.root_readme().write_into(&mut readme_md)?;
352
353 let mut rust_toolchain_toml = File::create(rust_toolchain_path(path))?;
354 self.root_rust_toolchain()
355 .write_into(&mut rust_toolchain_toml)?;
356
357 Ok(())
358 }
359
360 fn generate_build(&self) -> anyhow::Result<()> {
361 let path = &self.path;
362 let manifest_path = &manifest_path(path);
363
364 let mut lib_rs = File::create(lib_rs_path(path))?;
365 self.root_lib().write_into(&mut lib_rs)?;
366
367 let mut build_rs = File::create(build_rs_path(path))?;
368 self.root_build().write_into(&mut build_rs)?;
369
370 cargo_add_by_path(
372 manifest_path,
373 self.app_path(),
374 Normal,
375 None,
376 self.app,
377 self.offline,
378 )?;
379 cargo_add_by_path(
380 manifest_path,
381 self.app_path(),
382 Build,
383 None,
384 self.app,
385 self.offline,
386 )?;
387 self.cargo_add_sails_rs(manifest_path, Normal, None)?;
389 self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
390
391 Ok(())
392 }
393
394 fn generate_client(&self) -> anyhow::Result<()> {
395 let path = &self.client_path();
396 cargo_new(path, &self.client_name(), self.app, self.offline)?;
397
398 let manifest_path = &manifest_path(path);
399 self.cargo_add_sails_rs(manifest_path, Normal, None)?;
401 self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
402
403 cargo_add_by_path(
405 manifest_path,
406 self.app_path(),
407 Build,
408 None,
409 self.app,
410 self.offline,
411 )?;
412
413 let mut build_rs = File::create(build_rs_path(path))?;
414 self.client_build().write_into(&mut build_rs)?;
415
416 let mut lib_rs = File::create(lib_rs_path(path))?;
417 self.client_lib().write_into(&mut lib_rs)?;
418
419 Ok(())
420 }
421
422 fn generate_tests(&self) -> anyhow::Result<()> {
423 let path = &self.path;
424 let manifest_path = &manifest_path(path);
425 self.cargo_add_sails_rs(manifest_path, Development, Some("gtest,gclient"))?;
427
428 cargo_add(
430 manifest_path,
431 ["tokio"],
432 Development,
433 Some("rt,macros"),
434 self.app,
435 self.offline,
436 )?;
437
438 cargo_add_by_path(
440 manifest_path,
441 self.app_path(),
442 Development,
443 None,
444 self.app,
445 self.offline,
446 )?;
447 cargo_add_by_path(
449 manifest_path,
450 self.client_path(),
451 Development,
452 None,
453 self.app,
454 self.offline,
455 )?;
456
457 let test_path = &tests_path(path);
459 fs::create_dir_all(test_path.parent().context("Parent should exists")?)?;
460 let mut gtest_rs = File::create(test_path)?;
461 self.tests_gtest().write_into(&mut gtest_rs)?;
462
463 Ok(())
464 }
465
466 fn fmt(&self) -> anyhow::Result<()> {
467 let manifest_path = &manifest_path(&self.path);
468 cargo_fmt(manifest_path)
469 }
470}
471
472fn git_show_current_branch<P: AsRef<Path>>(target_dir: P) -> anyhow::Result<String> {
473 let git_command = git_command();
474 let mut cmd = Command::new(git_command);
475 cmd.stdout(Stdio::piped())
476 .arg("-C")
477 .arg(target_dir.as_ref())
478 .arg("branch")
479 .arg("--show-current");
480
481 let output = cmd
482 .output()?
483 .exit_result()
484 .context("failed to get current git branch")?;
485 let git_branch_name = String::from_utf8(output.stdout)?;
486
487 Ok(git_branch_name.trim().into())
488}
489
490fn cargo_new<P: AsRef<Path>>(
491 target_dir: P,
492 name: &str,
493 app: bool,
494 offline: bool,
495) -> anyhow::Result<()> {
496 let cargo_command = cargo_command();
497 let target_dir = target_dir.as_ref();
498 let cargo_new_or_init = if target_dir.exists() { "init" } else { "new" };
499 let mut cmd = Command::new(cargo_command);
500 cmd.stdout(Stdio::null()) .arg(cargo_new_or_init)
502 .arg(target_dir)
503 .arg("--name")
504 .arg(name)
505 .arg("--lib")
506 .arg("--quiet");
507
508 if offline {
509 cmd.arg("--offline");
510 }
511
512 cmd.status()
513 .context("failed to execute `cargo new` command")?
514 .exit_result()
515 .context("failed to run `cargo new` command")?;
516
517 let is_workspace = !app;
518 if is_workspace {
519 }
521
522 Ok(())
523}
524
525fn cargo_add<P, I, S>(
526 manifest_path: P,
527 packages: I,
528 dependency: cargo_metadata::DependencyKind,
529 features: Option<&str>,
530 app: bool,
531 offline: bool,
532) -> anyhow::Result<()>
533where
534 P: AsRef<Path>,
535 I: IntoIterator<Item = S>,
536 S: AsRef<OsStr>,
537{
538 let cargo_command = cargo_command();
539
540 let mut cmd = Command::new(cargo_command);
541 cmd.stdout(Stdio::null()) .arg("add")
543 .args(packages)
544 .arg("--manifest-path")
545 .arg(manifest_path.as_ref())
546 .arg("--quiet");
547
548 match dependency {
549 Development => {
550 cmd.arg("--dev");
551 }
552 Build => {
553 cmd.arg("--build");
554 }
555 _ => (),
556 };
557
558 if let Some(features) = features {
559 cmd.arg("--features").arg(features);
560 }
561
562 if offline {
563 cmd.arg("--offline");
564 }
565
566 cmd.status()
567 .context("failed to execute `cargo add` command")?
568 .exit_result()
569 .context("failed to run `cargo add` command")?;
570
571 let is_workspace = !app;
572 if is_workspace {
573 }
575
576 Ok(())
577}
578
579fn cargo_fmt<P: AsRef<Path>>(manifest_path: P) -> anyhow::Result<()> {
580 let cargo_command = cargo_command();
581
582 let mut cmd = Command::new(cargo_command);
583 cmd.stdout(Stdio::null()) .arg("fmt")
585 .arg("--manifest-path")
586 .arg(manifest_path.as_ref())
587 .arg("--quiet");
588
589 cmd.status()
590 .context("failed to execute `cargo fmt` command")?
591 .exit_result()
592 .context("failed to run `cargo fmt` command")
593}
594
595fn cargo_add_by_path<P1: AsRef<Path>, P2: AsRef<Path>>(
596 manifest_path: P1,
597 crate_path: P2,
598 dependency: cargo_metadata::DependencyKind,
599 features: Option<&str>,
600 app: bool,
601 offline: bool,
602) -> anyhow::Result<()> {
603 let crate_path = crate_path.as_ref().to_str().context("Invalid UTF-8 Path")?;
604 let package = &["--path", crate_path];
605 cargo_add(manifest_path, package, dependency, features, app, offline)
606}
607
608fn cargo_toml_create_workspace_and_fill_package<P: AsRef<Path>>(
609 manifest_path: P,
610 name: &str,
611 author: &str,
612 username: &str,
613) -> anyhow::Result<()> {
614 let manifest_path = manifest_path.as_ref();
615 let cargo_toml = fs::read_to_string(manifest_path)?;
616 let mut document: toml_edit::DocumentMut = cargo_toml.parse()?;
617
618 let package = document
619 .entry("package")
620 .or_insert_with(toml_edit::table)
621 .as_table_mut()
622 .context("package was not a table in Cargo.toml")?;
623 package.remove("edition");
624 for key in [
625 "version",
626 "authors",
627 "edition",
628 "rust-version",
629 "repository",
630 "license",
631 "keywords",
632 "categories",
633 ] {
634 let item = package.entry(key).or_insert_with(toml_edit::table);
635 let mut table = toml_edit::Table::new();
636 table.insert("workspace", toml_edit::value(true));
637 table.set_dotted(true);
638 *item = table.into();
639 }
640
641 for key in ["build-dependencies", "dev-dependencies"] {
642 _ = document
643 .entry(key)
644 .or_insert_with(toml_edit::table)
645 .as_table_mut()
646 .with_context(|| format!("package.{key} was not a table in Cargo.toml"))?;
647 }
648
649 let workspace = document
650 .entry("workspace")
651 .or_insert_with(toml_edit::table)
652 .as_table_mut()
653 .context("workspace was not a table in Cargo.toml")?;
654 _ = workspace
655 .entry("resolver")
656 .or_insert_with(|| toml_edit::value("3"));
657 _ = workspace
658 .entry("members")
659 .or_insert_with(|| toml_edit::value(toml_edit::Array::new()));
660
661 let workspace_package = workspace
662 .entry("package")
663 .or_insert_with(toml_edit::table)
664 .as_table_mut()
665 .context("workspace.package was not a table in Cargo.toml")?;
666 _ = workspace_package
667 .entry("version")
668 .or_insert_with(|| toml_edit::value("0.1.0"));
669 let mut authors = toml_edit::Array::new();
670 authors.push(author);
671 _ = workspace_package
672 .entry("authors")
673 .or_insert_with(|| toml_edit::value(authors));
674 for (key, value) in [
675 ("edition", "2024".into()),
676 ("rust-version", "1.91".into()),
677 (
678 "repository",
679 format!("https://github.com/{username}/{name}"),
680 ),
681 ("license", "MIT".into()),
682 ] {
683 _ = workspace_package
684 .entry(key)
685 .or_insert_with(|| toml_edit::value(value));
686 }
687 let keywords =
688 toml_edit::Array::from_iter(["gear", "sails", "smart-contracts", "wasm", "no-std"]);
689 _ = workspace_package
690 .entry("keywords")
691 .or_insert_with(|| toml_edit::value(keywords));
692 let categories =
693 toml_edit::Array::from_iter(["cryptography::cryptocurrencies", "no-std", "wasm"]);
694 _ = workspace_package
695 .entry("categories")
696 .or_insert_with(|| toml_edit::value(categories));
697
698 _ = workspace
699 .entry("dependencies")
700 .or_insert_with(toml_edit::table)
701 .as_table_mut()
702 .context("workspace.dependencies was not a table in Cargo.toml")?;
703
704 fs::write(manifest_path, document.to_string())?;
705
706 Ok(())
707}
708
709fn ci_workflow_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
710 path.as_ref().join(".github/workflows")
711}
712
713fn ci_workflow_path<P: AsRef<Path>>(path: P) -> PathBuf {
714 path.as_ref().join(".github/workflows/ci.yml")
715}
716
717fn gitignore_path<P: AsRef<Path>>(path: P) -> PathBuf {
718 path.as_ref().join(".gitignore")
719}
720
721fn manifest_path<P: AsRef<Path>>(path: P) -> PathBuf {
722 path.as_ref().join("Cargo.toml")
723}
724
725fn build_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
726 path.as_ref().join("build.rs")
727}
728
729fn lib_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
730 path.as_ref().join("src/lib.rs")
731}
732
733fn tests_path<P: AsRef<Path>>(path: P) -> PathBuf {
734 path.as_ref().join("tests/gtest.rs")
735}
736
737fn license_path<P: AsRef<Path>>(path: P) -> PathBuf {
738 path.as_ref().join("LICENSE")
739}
740
741fn readme_path<P: AsRef<Path>>(path: P) -> PathBuf {
742 path.as_ref().join("README.md")
743}
744
745fn rust_toolchain_path<P: AsRef<Path>>(path: P) -> PathBuf {
746 path.as_ref().join("rust-toolchain.toml")
747}
748
749fn git_command() -> String {
750 env::var("GIT").unwrap_or("git".into())
751}
752
753fn cargo_command() -> String {
754 env::var("CARGO").unwrap_or("cargo".into())
755}