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    path::{Path, PathBuf},
10    process::{Command, ExitStatus},
11};
12
13const SAILS_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15#[derive(Template)]
16#[template(path = "build.askama")]
17struct RootBuild {
18    app_crate_name: String,
19    program_struct_name: String,
20}
21
22#[derive(Template)]
23#[template(path = "src/lib.askama")]
24struct RootLib {
25    app_crate_name: String,
26}
27
28#[derive(Template)]
29#[template(path = "readme.askama")]
30struct Readme {
31    program_crate_name: String,
32    app_crate_name: String,
33    client_crate_name: String,
34    service_name: String,
35    app: bool,
36}
37
38#[derive(Template)]
39#[template(path = "app/src/lib.askama")]
40struct AppLib {
41    service_name: String,
42    service_name_snake: String,
43    program_struct_name: String,
44}
45
46#[derive(Template)]
47#[template(path = "client/build.askama")]
48struct ClientBuild {
49    app_crate_name: String,
50    program_struct_name: String,
51}
52
53#[derive(Template)]
54#[template(path = "client/src/lib.askama")]
55struct ClientLib {
56    client_file_name: String,
57}
58
59#[derive(Template)]
60#[template(path = "tests/gtest.askama")]
61struct TestsGtest {
62    program_crate_name: String,
63    client_crate_name: String,
64    client_program_name: String,
65    service_name: String,
66    service_name_snake: String,
67}
68
69pub struct ProgramGenerator {
70    path: PathBuf,
71    package_name: String,
72    sails_path: Option<PathBuf>,
73    app: bool,
74    offline: bool,
75    service_name: String,
76    program_struct_name: String,
77}
78
79impl ProgramGenerator {
80    pub fn new(
81        path: PathBuf,
82        name: Option<String>,
83        sails_path: Option<PathBuf>,
84        app: bool,
85        offline: bool,
86    ) -> Self {
87        let package_name = name.map_or_else(
88            || {
89                path.file_name()
90                    .expect("Invalid Path")
91                    .to_str()
92                    .expect("Invalid UTF-8 Path")
93                    .to_case(Case::Kebab)
94            },
95            |name| name.to_case(Case::Kebab),
96        );
97        let service_name = package_name.to_case(Case::Pascal);
98        Self {
99            path,
100            package_name,
101            sails_path,
102            app,
103            offline,
104            service_name,
105            program_struct_name: "Program".to_string(),
106        }
107    }
108
109    fn root_build(&self) -> RootBuild {
110        RootBuild {
111            app_crate_name: self.app_name().to_case(Case::Snake),
112            program_struct_name: self.program_struct_name.clone(),
113        }
114    }
115
116    fn root_lib(&self) -> RootLib {
117        RootLib {
118            app_crate_name: self.app_name().to_case(Case::Snake),
119        }
120    }
121
122    fn root_readme(&self) -> Readme {
123        Readme {
124            program_crate_name: self.package_name.to_owned(),
125            app_crate_name: self.app_name(),
126            client_crate_name: self.client_name(),
127            service_name: self.service_name.clone(),
128            app: self.app,
129        }
130    }
131
132    fn app_lib(&self) -> AppLib {
133        AppLib {
134            service_name: self.service_name.clone(),
135            service_name_snake: self.service_name.to_case(Case::Snake),
136            program_struct_name: self.program_struct_name.clone(),
137        }
138    }
139
140    fn client_build(&self) -> ClientBuild {
141        ClientBuild {
142            app_crate_name: self.app_name().to_case(Case::Snake),
143            program_struct_name: self.program_struct_name.clone(),
144        }
145    }
146
147    fn client_lib(&self) -> ClientLib {
148        ClientLib {
149            client_file_name: format!("{}_client", self.package_name.to_case(Case::Snake)),
150        }
151    }
152
153    fn tests_gtest(&self) -> TestsGtest {
154        TestsGtest {
155            program_crate_name: self.package_name.to_case(Case::Snake),
156            client_crate_name: self.client_name().to_case(Case::Snake),
157            client_program_name: self.client_name().to_case(Case::Pascal),
158            service_name: self.service_name.clone(),
159            service_name_snake: self.service_name.to_case(Case::Snake),
160        }
161    }
162
163    fn app_path(&self) -> PathBuf {
164        if self.app {
165            self.path.clone()
166        } else {
167            self.path.join("app")
168        }
169    }
170
171    fn app_name(&self) -> String {
172        if self.app {
173            self.package_name.clone()
174        } else {
175            format!("{}-app", self.package_name)
176        }
177    }
178
179    fn client_path(&self) -> PathBuf {
180        self.path.join("client")
181    }
182
183    fn client_name(&self) -> String {
184        format!("{}-client", self.package_name)
185    }
186
187    fn cargo_add_sails_rs<P: AsRef<Path>>(
188        &self,
189        manifest_path: P,
190        dependency: cargo_metadata::DependencyKind,
191        features: Option<&str>,
192    ) -> anyhow::Result<ExitStatus> {
193        if let Some(sails_path) = self.sails_path.as_ref() {
194            cargo_add_by_path(
195                manifest_path,
196                sails_path,
197                dependency,
198                features,
199                self.offline,
200            )
201        } else {
202            let sails_package = &[format!("sails-rs@{SAILS_VERSION}")];
203            cargo_add(
204                manifest_path,
205                sails_package,
206                dependency,
207                features,
208                self.offline,
209            )
210        }
211    }
212
213    pub fn generate(self) -> anyhow::Result<()> {
214        if self.app {
215            self.generate_app()?;
216        } else {
217            self.generate_root()?;
218            self.generate_app()?;
219            self.generate_client()?;
220            self.generate_build()?;
221            self.generate_tests()?;
222        }
223        self.fmt()?;
224        Ok(())
225    }
226
227    fn generate_app(&self) -> anyhow::Result<()> {
228        let path = &self.app_path();
229        cargo_new(path, &self.app_name(), self.offline)?;
230        let manifest_path = &manifest_path(path);
231
232        // add sails-rs refs
233        self.cargo_add_sails_rs(manifest_path, Normal, None)?;
234
235        let mut lib_rs = File::create(lib_rs_path(path))?;
236        self.app_lib().write_into(&mut lib_rs)?;
237
238        Ok(())
239    }
240
241    fn generate_root(&self) -> anyhow::Result<()> {
242        let path = &self.path;
243        cargo_new(path, &self.package_name, self.offline)?;
244
245        let manifest_path = &manifest_path(path);
246        cargo_toml_create_workspace(manifest_path)?;
247
248        let mut readme_md = File::create(readme_path(path))?;
249        self.root_readme().write_into(&mut readme_md)?;
250
251        Ok(())
252    }
253
254    fn generate_build(&self) -> anyhow::Result<()> {
255        let path = &self.path;
256        let manifest_path = &manifest_path(path);
257
258        let mut lib_rs = File::create(lib_rs_path(path))?;
259        self.root_lib().write_into(&mut lib_rs)?;
260
261        let mut build_rs = File::create(build_rs_path(path))?;
262        self.root_build().write_into(&mut build_rs)?;
263
264        // add app ref
265        cargo_add_by_path(manifest_path, self.app_path(), Normal, None, self.offline)?;
266        cargo_add_by_path(manifest_path, self.app_path(), Build, None, self.offline)?;
267        // add sails-rs refs
268        self.cargo_add_sails_rs(manifest_path, Normal, None)?;
269        self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
270
271        Ok(())
272    }
273
274    fn generate_client(&self) -> anyhow::Result<()> {
275        let path = &self.client_path();
276        cargo_new(path, &self.client_name(), self.offline)?;
277
278        let manifest_path = &manifest_path(path);
279        // add sails-rs refs
280        self.cargo_add_sails_rs(manifest_path, Normal, None)?;
281        self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
282
283        // add app ref
284        cargo_add_by_path(manifest_path, self.app_path(), Build, None, self.offline)?;
285
286        let mut build_rs = File::create(build_rs_path(path))?;
287        self.client_build().write_into(&mut build_rs)?;
288
289        let mut lib_rs = File::create(lib_rs_path(path))?;
290        self.client_lib().write_into(&mut lib_rs)?;
291
292        Ok(())
293    }
294
295    fn generate_tests(&self) -> anyhow::Result<()> {
296        let path = &self.path;
297        let manifest_path = &manifest_path(path);
298        // add sails-rs refs
299        self.cargo_add_sails_rs(manifest_path, Development, Some("gtest,gclient"))?;
300
301        // add tokio
302        cargo_add(
303            manifest_path,
304            ["tokio"],
305            Development,
306            Some("rt,macros"),
307            self.offline,
308        )?;
309
310        // add app ref
311        cargo_add_by_path(
312            manifest_path,
313            self.app_path(),
314            Development,
315            None,
316            self.offline,
317        )?;
318        // add client ref
319        cargo_add_by_path(
320            manifest_path,
321            self.client_path(),
322            Development,
323            None,
324            self.offline,
325        )?;
326
327        // add tests
328        let test_path = &tests_path(path);
329        fs::create_dir_all(test_path.parent().context("Parent should exists")?)?;
330        let mut gtest_rs = File::create(test_path)?;
331        self.tests_gtest().write_into(&mut gtest_rs)?;
332
333        Ok(())
334    }
335
336    fn fmt(&self) -> anyhow::Result<ExitStatus> {
337        let manifest_path = &manifest_path(&self.path);
338        cargo_fmt(manifest_path)
339    }
340}
341
342fn cargo_new<P: AsRef<Path>>(
343    target_dir: P,
344    name: &str,
345    offline: bool,
346) -> anyhow::Result<ExitStatus> {
347    let cargo_command = cargo_command();
348    let target_dir = target_dir.as_ref();
349    let cargo_new_or_init = if target_dir.exists() { "init" } else { "new" };
350    let mut cmd = Command::new(cargo_command);
351    cmd.stdout(std::process::Stdio::null()) // Don't pollute output
352        .arg(cargo_new_or_init)
353        .arg(target_dir)
354        .arg("--name")
355        .arg(name)
356        .arg("--lib")
357        .arg("--quiet");
358
359    if offline {
360        cmd.arg("--offline");
361    }
362
363    cmd.status()
364        .context("failed to execute `cargo new` command")
365}
366
367fn cargo_add<P, I, S>(
368    manifest_path: P,
369    packages: I,
370    dependency: cargo_metadata::DependencyKind,
371    features: Option<&str>,
372    offline: bool,
373) -> anyhow::Result<ExitStatus>
374where
375    P: AsRef<Path>,
376    I: IntoIterator<Item = S>,
377    S: AsRef<OsStr>,
378{
379    let cargo_command = cargo_command();
380
381    let mut cmd = Command::new(cargo_command);
382    cmd.stdout(std::process::Stdio::null()) // Don't pollute output
383        .arg("add")
384        .args(packages)
385        .arg("--manifest-path")
386        .arg(manifest_path.as_ref())
387        .arg("--quiet");
388
389    match dependency {
390        Development => {
391            cmd.arg("--dev");
392        }
393        Build => {
394            cmd.arg("--build");
395        }
396        _ => (),
397    };
398
399    if let Some(features) = features {
400        cmd.arg("--features").arg(features);
401    }
402
403    if offline {
404        cmd.arg("--offline");
405    }
406
407    cmd.status()
408        .context("failed to execute `cargo add` command")
409}
410
411fn cargo_fmt<P: AsRef<Path>>(manifest_path: P) -> anyhow::Result<ExitStatus> {
412    let cargo_command = cargo_command();
413
414    let mut cmd = Command::new(cargo_command);
415    cmd.stdout(std::process::Stdio::null()) // Don't pollute output
416        .arg("fmt")
417        .arg("--manifest-path")
418        .arg(manifest_path.as_ref())
419        .arg("--quiet");
420
421    cmd.status()
422        .context("failed to execute `cargo fmt` command")
423}
424
425fn cargo_add_by_path<P1: AsRef<Path>, P2: AsRef<Path>>(
426    manifest_path: P1,
427    crate_path: P2,
428    dependency: cargo_metadata::DependencyKind,
429    features: Option<&str>,
430    offline: bool,
431) -> anyhow::Result<ExitStatus> {
432    let crate_path = crate_path.as_ref().to_str().context("Invalid UTF-8 Path")?;
433    let package = &["--path", crate_path];
434    cargo_add(manifest_path, package, dependency, features, offline)
435}
436
437fn cargo_toml_create_workspace<P: AsRef<Path>>(manifest_path: P) -> anyhow::Result<()> {
438    let manifest_path = manifest_path.as_ref();
439    let cargo_toml = fs::read_to_string(manifest_path)?;
440    let mut document: toml_edit::DocumentMut = cargo_toml.parse()?;
441
442    let workspace = document
443        .entry("workspace")
444        .or_insert_with(toml_edit::table)
445        .as_table_mut()
446        .context("workspace was not a table in Cargo.toml")?;
447    _ = workspace
448        .entry("resolver")
449        .or_insert_with(|| toml_edit::value("3"));
450    _ = workspace
451        .entry("members")
452        .or_insert_with(|| toml_edit::value(toml_edit::Array::new()));
453
454    let workspace_package = workspace
455        .entry("package")
456        .or_insert_with(toml_edit::table)
457        .as_table_mut()
458        .context("workspace.package was not a table in Cargo.toml")?;
459    _ = workspace_package
460        .entry("version")
461        .or_insert_with(|| toml_edit::value("0.1.0"));
462    _ = workspace_package
463        .entry("edition")
464        .or_insert_with(|| toml_edit::value("2024"));
465
466    fs::write(manifest_path, document.to_string())?;
467
468    Ok(())
469}
470
471fn manifest_path<P: AsRef<Path>>(path: P) -> PathBuf {
472    path.as_ref().join("Cargo.toml")
473}
474
475fn build_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
476    path.as_ref().join("build.rs")
477}
478
479fn lib_rs_path<P: AsRef<Path>>(path: P) -> PathBuf {
480    path.as_ref().join("src/lib.rs")
481}
482
483fn tests_path<P: AsRef<Path>>(path: P) -> PathBuf {
484    path.as_ref().join("tests/gtest.rs")
485}
486
487fn readme_path<P: AsRef<Path>>(path: P) -> PathBuf {
488    path.as_ref().join("README.md")
489}
490
491fn cargo_command() -> String {
492    std::env::var("CARGO").unwrap_or("cargo".into())
493}