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