sails_cli/
program_new.rs

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        // add sails-rs refs
292        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        // add app ref
347        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        // add sails-rs refs
351        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        // add sails-rs refs
371        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        // add app ref
383        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        // add sails-rs refs
398        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        // add tokio
409        cargo_add(
410            manifest_path,
411            ["tokio"],
412            Development,
413            Some("rt,macros"),
414            self.offline,
415        )?;
416
417        // add app ref
418        cargo_add(
419            manifest_path,
420            [self.app_name()],
421            Development,
422            None,
423            self.offline,
424        )?;
425        // add client ref
426        cargo_add(
427            manifest_path,
428            [self.client_name()],
429            Development,
430            None,
431            self.offline,
432        )?;
433
434        // add tests
435        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()) // Don't pollute output
478        .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()) // Don't pollute output
581        .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()) // Don't pollute output
618        .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}