1use std::{
2 env, fmt, fs, io,
3 path::{Path, PathBuf},
4};
5
6use clap::{Args, Parser, Subcommand};
7use heck::{ToKebabCase, ToPascalCase, ToSnakeCase};
8
9pub type Result<T> = std::result::Result<T, CliError>;
10
11#[derive(Debug)]
12pub enum CliError {
13 Io { path: PathBuf, source: io::Error },
14 AlreadyExists(PathBuf),
15 InvalidName(String),
16}
17
18impl fmt::Display for CliError {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
22 Self::AlreadyExists(path) => {
23 write!(
24 f,
25 "{} already exists; refusing to overwrite",
26 path.display()
27 )
28 }
29 Self::InvalidName(name) => write!(f, "invalid project or feature name `{name}`"),
30 }
31 }
32}
33
34impl std::error::Error for CliError {}
35
36#[derive(Parser, Debug)]
37#[command(
38 name = "caelix",
39 version,
40 about = "Generate Caelix applications and features"
41)]
42struct Cli {
43 #[command(subcommand)]
44 command: Command,
45}
46
47#[derive(Subcommand, Debug)]
48enum Command {
49 New(NewArgs),
50 #[command(alias = "g")]
51 Generate(GenerateArgs),
52}
53
54#[derive(Args, Debug)]
55struct NewArgs {
56 name: String,
57}
58
59#[derive(Args, Debug)]
60struct GenerateArgs {
61 #[command(subcommand)]
62 kind: GenerateKind,
63}
64
65#[derive(Subcommand, Debug)]
66enum GenerateKind {
67 Service(NameArgs),
68 Controller(NameArgs),
69 Module(NameArgs),
70}
71
72#[derive(Args, Debug)]
73struct NameArgs {
74 name: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct FeatureName {
79 raw: String,
80 module_name: String,
81 route_path: String,
82 type_prefix: String,
83}
84
85impl FeatureName {
86 pub fn parse(name: impl Into<String>) -> Result<Self> {
87 let raw = name.into();
88 let trimmed = raw.trim();
89 if trimmed.is_empty() || trimmed.contains(['/', '\\']) {
90 return Err(CliError::InvalidName(raw));
91 }
92
93 let module_name = trimmed.to_snake_case();
94 let route_path = trimmed.to_kebab_case();
95 let type_prefix = trimmed.to_pascal_case();
96
97 if module_name.is_empty()
98 || route_path.is_empty()
99 || type_prefix.is_empty()
100 || module_name
101 .chars()
102 .next()
103 .is_some_and(|ch| ch.is_ascii_digit())
104 {
105 return Err(CliError::InvalidName(trimmed.to_string()));
106 }
107
108 Ok(Self {
109 raw: trimmed.to_string(),
110 module_name,
111 route_path,
112 type_prefix,
113 })
114 }
115
116 pub fn module_name(&self) -> &str {
117 &self.module_name
118 }
119
120 pub fn route_path(&self) -> &str {
121 &self.route_path
122 }
123
124 pub fn service_type(&self) -> String {
125 format!("{}Service", self.type_prefix)
126 }
127
128 pub fn controller_type(&self) -> String {
129 format!("{}Controller", self.type_prefix)
130 }
131
132 pub fn module_type(&self) -> String {
133 format!("{}Module", self.type_prefix)
134 }
135}
136
137pub fn run_from_env() -> Result<String> {
138 let cwd = env::current_dir().map_err(|source| CliError::Io {
139 path: PathBuf::from("."),
140 source,
141 })?;
142 run_from(env::args_os(), cwd)
143}
144
145pub fn run_from<I, T>(args: I, cwd: impl AsRef<Path>) -> Result<String>
146where
147 I: IntoIterator<Item = T>,
148 T: Into<std::ffi::OsString> + Clone,
149{
150 let cli = Cli::parse_from(args);
151 run(cli, cwd.as_ref())
152}
153
154fn run(cli: Cli, cwd: &Path) -> Result<String> {
155 match cli.command {
156 Command::New(args) => generate_new(args, cwd),
157 Command::Generate(args) => match args.kind {
158 GenerateKind::Service(args) => generate_service(&FeatureName::parse(args.name)?, cwd),
159 GenerateKind::Controller(args) => {
160 generate_controller(&FeatureName::parse(args.name)?, cwd)
161 }
162 GenerateKind::Module(args) => generate_module(&FeatureName::parse(args.name)?, cwd),
163 },
164 }
165}
166
167fn generate_new(args: NewArgs, cwd: &Path) -> Result<String> {
168 let target_dir = cwd.join(&args.name);
169 ensure_missing(&target_dir)?;
170
171 let package_name = package_name_for_path(&target_dir, &args.name)?;
172 let crate_name = package_name.to_snake_case();
173
174 fs::create_dir_all(target_dir.join("src")).map_err(|source| CliError::Io {
175 path: target_dir.join("src"),
176 source,
177 })?;
178
179 let cargo_toml = render_app_cargo_toml(&package_name);
180 create_file(target_dir.join("Cargo.toml"), &cargo_toml)?;
181 create_file(target_dir.join("src/main.rs"), &render_main_rs(&crate_name))?;
182 create_file(target_dir.join("src/lib.rs"), render_lib_rs())?;
183 create_file(target_dir.join("src/app.rs"), render_app_rs())?;
184
185 Ok(format!(
186 "Created Caelix application `{}` in {}\n\nNext steps:\n- cd {}\n- cargo run\n",
187 package_name,
188 target_dir.display(),
189 target_dir.display()
190 ))
191}
192
193fn generate_service(feature: &FeatureName, cwd: &Path) -> Result<String> {
194 let feature_dir = src_dir(cwd).join(feature.module_name());
195 let service_path = feature_dir.join("service.rs");
196 let mod_path = feature_dir.join("mod.rs");
197
198 ensure_missing(&service_path)?;
199 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
200 path: feature_dir.clone(),
201 source,
202 })?;
203 create_file(&service_path, &render_service(feature))?;
204
205 let mut created = vec![service_path];
206 if !mod_path.exists() {
207 create_file(
208 &mod_path,
209 &render_feature_mod(feature, FeatureModKind::Service),
210 )?;
211 created.push(mod_path);
212 }
213
214 Ok(format!(
215 "{}\n\n{}",
216 created_files(&created),
217 service_instructions(feature)
218 ))
219}
220
221fn generate_controller(feature: &FeatureName, cwd: &Path) -> Result<String> {
222 let feature_dir = src_dir(cwd).join(feature.module_name());
223 let service_path = feature_dir.join("service.rs");
224 let controller_path = feature_dir.join("controller.rs");
225 let mod_path = feature_dir.join("mod.rs");
226 let has_service = service_path.exists();
227
228 ensure_missing(&controller_path)?;
229 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
230 path: feature_dir.clone(),
231 source,
232 })?;
233 create_file(&controller_path, &render_controller(feature, has_service))?;
234
235 let mut created = vec![controller_path];
236 if !mod_path.exists() {
237 let kind = if has_service {
238 FeatureModKind::ControllerWithService
239 } else {
240 FeatureModKind::Controller
241 };
242 create_file(&mod_path, &render_feature_mod(feature, kind))?;
243 created.push(mod_path);
244 }
245
246 let mut output = format!(
247 "{}\n\n{}",
248 created_files(&created),
249 controller_instructions(feature, has_service)
250 );
251 if !has_service {
252 output.push_str(&format!(
253 "\n\nNote: src/{}/service.rs was not found, so {} was generated without a {} dependency.\n",
254 feature.module_name(),
255 feature.controller_type(),
256 feature.service_type()
257 ));
258 }
259 Ok(output)
260}
261
262fn generate_module(feature: &FeatureName, cwd: &Path) -> Result<String> {
263 let feature_dir = src_dir(cwd).join(feature.module_name());
264 let mod_path = feature_dir.join("mod.rs");
265 let service_path = feature_dir.join("service.rs");
266 let controller_path = feature_dir.join("controller.rs");
267
268 ensure_missing(&mod_path)?;
269 ensure_missing(&service_path)?;
270 ensure_missing(&controller_path)?;
271
272 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
273 path: feature_dir.clone(),
274 source,
275 })?;
276 create_file(&mod_path, &render_feature_module(feature))?;
277 create_file(&service_path, &render_service(feature))?;
278 create_file(&controller_path, &render_controller(feature, true))?;
279
280 Ok(format!(
281 "{}\n\n{}",
282 created_files(&[mod_path, service_path, controller_path]),
283 module_instructions(feature)
284 ))
285}
286
287fn src_dir(cwd: &Path) -> PathBuf {
288 cwd.join("src")
289}
290
291fn package_name_for_path(path: &Path, fallback: &str) -> Result<String> {
292 let name = path
293 .file_name()
294 .and_then(|name| name.to_str())
295 .unwrap_or(fallback)
296 .trim();
297
298 if name.is_empty() {
299 return Err(CliError::InvalidName(fallback.to_string()));
300 }
301
302 Ok(name.to_kebab_case())
303}
304
305pub fn render_app_cargo_toml(package_name: &str) -> String {
306 format!(
307 r#"[package]
308name = "{package_name}"
309version = "0.0.1"
310edition = "2024"
311
312[dependencies]
313actix-web = "4.14.0"
314caelix = "0.0.2"
315serde = {{ version = "1.0.228", features = ["derive"] }}
316"#
317 )
318}
319
320fn render_main_rs(crate_name: &str) -> String {
321 format!(
322 r#"use caelix::Application;
323use {crate_name}::AppModule;
324
325#[caelix::main]
326async fn main() -> std::io::Result<()> {{
327 Application::new::<AppModule>()
328 .await
329 .listen("127.0.0.1:8080")
330 .await
331}}
332"#,
333 crate_name = crate_name
334 )
335}
336
337fn render_lib_rs() -> &'static str {
338 "pub mod app;\n\npub use app::AppModule;\n"
339}
340
341fn render_app_rs() -> &'static str {
342 r#"use caelix::{Module, ModuleMetadata};
343
344pub struct AppModule;
345
346impl Module for AppModule {
347 fn register() -> ModuleMetadata {
348 ModuleMetadata::new()
349 }
350}
351"#
352}
353
354pub fn render_service(feature: &FeatureName) -> String {
355 let service = feature.service_type();
356 format!(
357 r#"use caelix::injectable;
358
359#[injectable]
360pub struct {service};
361
362impl {service} {{
363 pub fn hello(&self) -> String {{
364 "Hello from {service}".to_string()
365 }}
366}}
367"#
368 )
369}
370
371pub fn render_controller(feature: &FeatureName, has_service: bool) -> String {
372 let controller = feature.controller_type();
373 let route = feature.route_path();
374
375 if has_service {
376 let service = feature.service_type();
377 format!(
378 r#"use std::sync::Arc;
379
380use caelix::{{controller, get, injectable, Result}};
381
382use super::{service};
383
384#[injectable]
385pub struct {controller} {{
386 service: Arc<{service}>,
387}}
388
389#[controller("/{route}")]
390impl {controller} {{
391 #[get("")]
392 pub async fn hello(&self) -> Result<String> {{
393 Ok(self.service.hello())
394 }}
395}}
396"#
397 )
398 } else {
399 format!(
400 r#"use caelix::{{controller, get, injectable, Result}};
401
402#[injectable]
403pub struct {controller};
404
405#[controller("/{route}")]
406impl {controller} {{
407 #[get("")]
408 pub async fn hello(&self) -> Result<String> {{
409 Ok("Hello from {controller}".to_string())
410 }}
411}}
412"#
413 )
414 }
415}
416
417#[derive(Clone, Copy)]
418enum FeatureModKind {
419 Service,
420 Controller,
421 ControllerWithService,
422}
423
424fn render_feature_mod(feature: &FeatureName, kind: FeatureModKind) -> String {
425 let service = feature.service_type();
426 let controller = feature.controller_type();
427
428 match kind {
429 FeatureModKind::Service => format!(
430 r#"pub mod service;
431
432pub use service::{service};
433"#
434 ),
435 FeatureModKind::Controller => format!(
436 r#"pub mod controller;
437
438pub use controller::{controller};
439"#
440 ),
441 FeatureModKind::ControllerWithService => format!(
442 r#"pub mod controller;
443pub mod service;
444
445pub use controller::{controller};
446pub use service::{service};
447"#
448 ),
449 }
450}
451
452pub fn render_feature_module(feature: &FeatureName) -> String {
453 let module = feature.module_type();
454 let service = feature.service_type();
455 let controller = feature.controller_type();
456
457 format!(
458 r#"pub mod controller;
459pub mod service;
460
461pub use controller::{controller};
462pub use service::{service};
463
464use caelix::{{Module, ModuleMetadata}};
465
466pub struct {module};
467
468impl Module for {module} {{
469 fn register() -> ModuleMetadata {{
470 ModuleMetadata::new()
471 .provider::<{service}>()
472 .controller::<{controller}>()
473 }}
474}}
475"#
476 )
477}
478
479fn service_instructions(feature: &FeatureName) -> String {
480 format!(
481 r#"Manual registration:
482- Ensure `src/{}/mod.rs` contains `pub mod service;` and `pub use service::{};`.
483- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
484- Add `use crate::{}::{};` to the module that should own the service.
485- Add `.provider::<{}>()` inside that module's `register()`."#,
486 feature.module_name(),
487 feature.service_type(),
488 feature.module_name(),
489 feature.module_name(),
490 feature.service_type(),
491 feature.service_type()
492 )
493}
494
495fn controller_instructions(feature: &FeatureName, has_service: bool) -> String {
496 let mut instructions = format!(
497 r#"Manual registration:
498- Ensure `src/{}/mod.rs` contains `pub mod controller;` and `pub use controller::{};`.
499- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
500- Add `use crate::{}::{};` to the module that should own the controller.
501- Add `.controller::<{}>()` inside that module's `register()`."#,
502 feature.module_name(),
503 feature.controller_type(),
504 feature.module_name(),
505 feature.module_name(),
506 feature.controller_type(),
507 feature.controller_type()
508 );
509
510 if has_service {
511 instructions.push_str(&format!(
512 "\n- Ensure `{}` is registered as a provider in the same module.",
513 feature.service_type()
514 ));
515 }
516
517 instructions
518}
519
520fn module_instructions(feature: &FeatureName) -> String {
521 format!(
522 r#"Manual registration:
523- Add `pub mod {};` to `src/lib.rs`.
524- Add `use crate::{}::{};` to `src/app.rs`.
525- Add `.import::<{}>()` inside `AppModule::register()`."#,
526 feature.module_name(),
527 feature.module_name(),
528 feature.module_type(),
529 feature.module_type()
530 )
531}
532
533fn created_files(paths: &[PathBuf]) -> String {
534 let mut output = String::from("Created files:\n");
535 for path in paths {
536 output.push_str(&format!("- {}\n", path.display()));
537 }
538 output
539}
540
541fn ensure_missing(path: &Path) -> Result<()> {
542 if path.exists() {
543 Err(CliError::AlreadyExists(path.to_path_buf()))
544 } else {
545 Ok(())
546 }
547}
548
549fn create_file(path: impl AsRef<Path>, contents: impl AsRef<str>) -> Result<()> {
550 let path = path.as_ref();
551 ensure_missing(path)?;
552 fs::write(path, contents.as_ref()).map_err(|source| CliError::Io {
553 path: path.to_path_buf(),
554 source,
555 })
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn feature_name_converts_to_rust_and_route_names() {
564 let feature = FeatureName::parse("auth-session").unwrap();
565
566 assert_eq!(feature.module_name(), "auth_session");
567 assert_eq!(feature.route_path(), "auth-session");
568 assert_eq!(feature.service_type(), "AuthSessionService");
569 assert_eq!(feature.controller_type(), "AuthSessionController");
570 assert_eq!(feature.module_type(), "AuthSessionModule");
571 }
572
573 #[test]
574 fn service_template_uses_injectable_struct() {
575 let feature = FeatureName::parse("users").unwrap();
576 let rendered = render_service(&feature);
577
578 assert!(rendered.contains("#[injectable]\npub struct UsersService;"));
579 assert!(rendered.contains("Hello from UsersService"));
580 }
581
582 #[test]
583 fn controller_template_omits_service_when_missing() {
584 let feature = FeatureName::parse("users").unwrap();
585 let rendered = render_controller(&feature, false);
586
587 assert!(rendered.contains("pub struct UsersController;"));
588 assert!(!rendered.contains("Arc<UsersService>"));
589 assert!(rendered.contains("#[controller(\"/users\")]"));
590 }
591
592 #[test]
593 fn module_template_registers_provider_and_controller() {
594 let feature = FeatureName::parse("users").unwrap();
595 let rendered = render_feature_module(&feature);
596
597 assert!(rendered.contains("pub struct UsersModule;"));
598 assert!(rendered.contains(".provider::<UsersService>()"));
599 assert!(rendered.contains(".controller::<UsersController>()"));
600 }
601}