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");
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        // add sails-rs refs
317        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        // add app ref
371        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        // add sails-rs refs
388        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        // add sails-rs refs
400        self.cargo_add_sails_rs(manifest_path, Normal, None)?;
401        self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
402
403        // add app ref
404        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        // add sails-rs refs
426        self.cargo_add_sails_rs(manifest_path, Development, Some("gtest,gclient"))?;
427
428        // add tokio
429        cargo_add(
430            manifest_path,
431            ["tokio"],
432            Development,
433            Some("rt,macros"),
434            self.app,
435            self.offline,
436        )?;
437
438        // add app ref
439        cargo_add_by_path(
440            manifest_path,
441            self.app_path(),
442            Development,
443            None,
444            self.app,
445            self.offline,
446        )?;
447        // add client ref
448        cargo_add_by_path(
449            manifest_path,
450            self.client_path(),
451            Development,
452            None,
453            self.app,
454            self.offline,
455        )?;
456
457        // add tests
458        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()) // Don't pollute output
501        .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        // TODO: move dependency to workspace
520    }
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()) // Don't pollute output
542        .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        // TODO: move dependency to workspace
574    }
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()) // Don't pollute output
584        .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}