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 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 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 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 self.cargo_add_sails_rs(manifest_path, Normal, None)?;
281 self.cargo_add_sails_rs(manifest_path, Build, Some("build"))?;
282
283 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 self.cargo_add_sails_rs(manifest_path, Development, Some("gtest,gclient"))?;
300
301 cargo_add(
303 manifest_path,
304 ["tokio"],
305 Development,
306 Some("rt,macros"),
307 self.offline,
308 )?;
309
310 cargo_add_by_path(
312 manifest_path,
313 self.app_path(),
314 Development,
315 None,
316 self.offline,
317 )?;
318 cargo_add_by_path(
320 manifest_path,
321 self.client_path(),
322 Development,
323 None,
324 self.offline,
325 )?;
326
327 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()) .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()) .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()) .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}