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("AGENTS.md"), render_agents_md())?;
182 create_file(target_dir.join("src/main.rs"), &render_main_rs(&crate_name))?;
183 create_file(target_dir.join("src/lib.rs"), render_lib_rs())?;
184 create_file(target_dir.join("src/app.rs"), render_app_rs())?;
185
186 Ok(format!(
187 "Created Caelix application `{}` in {}\n\nNext steps:\n- cd {}\n- cargo run\n",
188 package_name,
189 target_dir.display(),
190 target_dir.display()
191 ))
192}
193
194fn generate_service(feature: &FeatureName, cwd: &Path) -> Result<String> {
195 let feature_dir = src_dir(cwd).join(feature.module_name());
196 let service_path = feature_dir.join("service.rs");
197 let mod_path = feature_dir.join("mod.rs");
198
199 ensure_missing(&service_path)?;
200 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
201 path: feature_dir.clone(),
202 source,
203 })?;
204 create_file(&service_path, &render_service(feature))?;
205
206 let mut created = vec![service_path];
207 if !mod_path.exists() {
208 create_file(
209 &mod_path,
210 &render_feature_mod(feature, FeatureModKind::Service),
211 )?;
212 created.push(mod_path);
213 }
214
215 Ok(format!(
216 "{}\n\n{}",
217 created_files(&created),
218 service_instructions(feature)
219 ))
220}
221
222fn generate_controller(feature: &FeatureName, cwd: &Path) -> Result<String> {
223 let feature_dir = src_dir(cwd).join(feature.module_name());
224 let service_path = feature_dir.join("service.rs");
225 let controller_path = feature_dir.join("controller.rs");
226 let mod_path = feature_dir.join("mod.rs");
227 let has_service = service_path.exists();
228
229 ensure_missing(&controller_path)?;
230 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
231 path: feature_dir.clone(),
232 source,
233 })?;
234 create_file(&controller_path, &render_controller(feature, has_service))?;
235
236 let mut created = vec![controller_path];
237 if !mod_path.exists() {
238 let kind = if has_service {
239 FeatureModKind::ControllerWithService
240 } else {
241 FeatureModKind::Controller
242 };
243 create_file(&mod_path, &render_feature_mod(feature, kind))?;
244 created.push(mod_path);
245 }
246
247 let mut output = format!(
248 "{}\n\n{}",
249 created_files(&created),
250 controller_instructions(feature, has_service)
251 );
252 if !has_service {
253 output.push_str(&format!(
254 "\n\nNote: src/{}/service.rs was not found, so {} was generated without a {} dependency.\n",
255 feature.module_name(),
256 feature.controller_type(),
257 feature.service_type()
258 ));
259 }
260 Ok(output)
261}
262
263fn generate_module(feature: &FeatureName, cwd: &Path) -> Result<String> {
264 let feature_dir = src_dir(cwd).join(feature.module_name());
265 let mod_path = feature_dir.join("mod.rs");
266 let service_path = feature_dir.join("service.rs");
267 let controller_path = feature_dir.join("controller.rs");
268
269 ensure_missing(&mod_path)?;
270 ensure_missing(&service_path)?;
271 ensure_missing(&controller_path)?;
272
273 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
274 path: feature_dir.clone(),
275 source,
276 })?;
277 create_file(&mod_path, &render_feature_module(feature))?;
278 create_file(&service_path, &render_service(feature))?;
279 create_file(&controller_path, &render_controller(feature, true))?;
280
281 Ok(format!(
282 "{}\n\n{}",
283 created_files(&[mod_path, service_path, controller_path]),
284 module_instructions(feature)
285 ))
286}
287
288fn src_dir(cwd: &Path) -> PathBuf {
289 cwd.join("src")
290}
291
292fn package_name_for_path(path: &Path, fallback: &str) -> Result<String> {
293 let name = path
294 .file_name()
295 .and_then(|name| name.to_str())
296 .unwrap_or(fallback)
297 .trim();
298
299 if name.is_empty() {
300 return Err(CliError::InvalidName(fallback.to_string()));
301 }
302
303 Ok(name.to_kebab_case())
304}
305
306pub fn render_app_cargo_toml(package_name: &str) -> String {
307 format!(
308 r#"[package]
309name = "{package_name}"
310version = "0.0.1"
311edition = "2024"
312
313[dependencies]
314actix-web = "4.14.0"
315caelix = "0.0.3"
316serde = {{ version = "1.0.228", features = ["derive"] }}
317"#
318 )
319}
320
321fn render_main_rs(crate_name: &str) -> String {
322 format!(
323 r#"use caelix::Application;
324use {crate_name}::AppModule;
325
326#[caelix::main]
327async fn main() -> std::io::Result<()> {{
328 Application::new::<AppModule>()
329 .await
330 .listen("127.0.0.1:8080")
331 .await
332}}
333"#,
334 crate_name = crate_name
335 )
336}
337
338fn render_lib_rs() -> &'static str {
339 "pub mod app;\n\npub use app::AppModule;\n"
340}
341
342fn render_app_rs() -> &'static str {
343 r#"use caelix::{Module, ModuleMetadata};
344
345pub struct AppModule;
346
347impl Module for AppModule {
348 fn register() -> ModuleMetadata {
349 ModuleMetadata::new()
350 }
351}
352"#
353}
354
355fn render_agents_md() -> &'static str {
356 r#"# Agent Instructions
357
358This is a Caelix application. Use this file as the quick working reference when changing generated app code.
359
360For fuller documentation, refer to https://ohanronnie.github.io/caelix/.
361
362## App Structure
363
364- `src/main.rs` starts the Actix runtime with `Application::new::<AppModule>()`.
365- `src/lib.rs` exports the root `AppModule` and should declare feature modules with `pub mod feature_name;`.
366- `src/app.rs` owns the root `AppModule`.
367- Feature folders usually contain `mod.rs`, `service.rs`, and `controller.rs`.
368- Prefer the Caelix CLI for new framework files: `caelix g module name`, `caelix g service name`, and `caelix g controller name`.
369
370## Registration Model
371
372Caelix uses explicit module metadata. Do not rely on filesystem discovery or hidden auto-registration.
373
374- A module implements `Module` and returns `ModuleMetadata`.
375- Add generated feature modules to `src/lib.rs`.
376- Import feature modules in `src/app.rs`.
377- Add `.import::<FeatureModule>()` inside `AppModule::register()`.
378- Register services with `.provider::<Service>()`.
379- Register controllers with `.controller::<Controller>()`.
380- Register async factory values with `.provider_async_factory::<T, _, _>(...)` when construction cannot be expressed as `#[injectable]`.
381
382Example:
383
384```rust
385use caelix::{Module, ModuleMetadata};
386use crate::users::UsersModule;
387
388pub struct AppModule;
389
390impl Module for AppModule {
391 fn register() -> ModuleMetadata {
392 ModuleMetadata::new().import::<UsersModule>()
393 }
394}
395```
396
397## Providers And Injection
398
399Prefer `#[injectable]` for services, controllers, guards, and interceptors.
400
401- Injectable fields must be `Arc<T>`.
402- `Arc<Logger>` is provided automatically with the struct name as context.
403- Unit structs are valid injectables.
404- Tuple structs are not supported by `#[injectable]`.
405- Manual `Injectable` implementations are acceptable for custom async construction.
406- Lifecycle hooks can be implemented on providers: `on_module_init`, `on_bootstrap`, and `on_shutdown`.
407
408Example:
409
410```rust
411use std::sync::Arc;
412use caelix::{injectable, Logger};
413
414#[injectable]
415pub struct UsersService {
416 logger: Arc<Logger>,
417}
418```
419
420## Controllers
421
422Use `#[controller("/base-path")]` on an impl block. Route handlers are async methods.
423
424- Supported route attributes: `#[get]`, `#[post]`, `#[patch]`, `#[put]`, `#[delete]`.
425- Supported extractor attributes: `#[param]`, `#[body]`, `#[query]`, `#[user]`.
426- Add `#[validate]` to extracted DTOs that implement `validator::Validate`.
427- `#[user]` reads from `RequestContext`; missing users become `UnauthorizedException`.
428
429Example:
430
431```rust
432use std::sync::Arc;
433use caelix::{controller, get, injectable, Result};
434use super::UsersService;
435
436#[injectable]
437pub struct UsersController {
438 service: Arc<UsersService>,
439}
440
441#[controller("/users")]
442impl UsersController {
443 #[get("/{id}")]
444 async fn find_one(&self, #[param] id: String) -> Result<String> {
445 Ok(self.service.find_one(id).await?)
446 }
447}
448```
449
450## Guards, Interceptors, And Context
451
452- Guards implement `Guard` and return whether a request may continue.
453- Interceptors implement `Interceptor` and can wrap handler execution.
454- Apply them with `#[use_guard(Type)]` or `#[use_interceptor(Type)]` at controller or method level.
455- Controller-level guards/interceptors apply before method-level ones.
456- Use `RequestContext` for request method, path, headers, and per-request values such as authenticated users.
457
458## Responses And Errors
459
460Handlers should return values that implement `IntoCaelixResponse`.
461
462- `Result<String>` returns `200 text/plain`.
463- `Result<Response<T>>` is the usual JSON response path.
464- `Response::Body(value)` returns `200` JSON.
465- `Response::WithStatus(status, value)` returns JSON with a custom status.
466- `Response::json`, `Response::text`, and `Response::bytes` return explicit raw payloads.
467- `Response::no_content()` returns `204`.
468- Use Caelix exception types such as `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, and `InternalServerErrorException` for errors.
469- Server error messages are intentionally hidden from HTTP responses.
470
471## Cache
472
473Cache support is explicit service-level caching.
474
475- Import `CacheModule` into a module that needs cache support.
476- Inject `Arc<Cache>` into services that need cache reads/writes.
477- Do not add automatic HTTP response caching.
478- If the app needs response caching, implement it with an interceptor that reads from and writes to `Cache`.
479
480## Checks
481
482- Run `cargo test` after code changes when feasible.
483- Keep public app code using the `caelix` facade (`use caelix::...`) instead of internal Caelix crate paths.
484- When using CLI-generated files, keep the manual registration steps in the command output aligned with `src/lib.rs` and `src/app.rs`.
485"#
486}
487
488pub fn render_service(feature: &FeatureName) -> String {
489 let service = feature.service_type();
490 format!(
491 r#"use caelix::injectable;
492
493#[injectable]
494pub struct {service};
495
496impl {service} {{
497 pub fn hello(&self) -> String {{
498 "Hello from {service}".to_string()
499 }}
500}}
501"#
502 )
503}
504
505pub fn render_controller(feature: &FeatureName, has_service: bool) -> String {
506 let controller = feature.controller_type();
507 let route = feature.route_path();
508
509 if has_service {
510 let service = feature.service_type();
511 format!(
512 r#"use std::sync::Arc;
513
514use caelix::{{controller, get, injectable, Result}};
515
516use super::{service};
517
518#[injectable]
519pub struct {controller} {{
520 service: Arc<{service}>,
521}}
522
523#[controller("/{route}")]
524impl {controller} {{
525 #[get("")]
526 pub async fn hello(&self) -> Result<String> {{
527 Ok(self.service.hello())
528 }}
529}}
530"#
531 )
532 } else {
533 format!(
534 r#"use caelix::{{controller, get, injectable, Result}};
535
536#[injectable]
537pub struct {controller};
538
539#[controller("/{route}")]
540impl {controller} {{
541 #[get("")]
542 pub async fn hello(&self) -> Result<String> {{
543 Ok("Hello from {controller}".to_string())
544 }}
545}}
546"#
547 )
548 }
549}
550
551#[derive(Clone, Copy)]
552enum FeatureModKind {
553 Service,
554 Controller,
555 ControllerWithService,
556}
557
558fn render_feature_mod(feature: &FeatureName, kind: FeatureModKind) -> String {
559 let service = feature.service_type();
560 let controller = feature.controller_type();
561
562 match kind {
563 FeatureModKind::Service => format!(
564 r#"pub mod service;
565
566pub use service::{service};
567"#
568 ),
569 FeatureModKind::Controller => format!(
570 r#"pub mod controller;
571
572pub use controller::{controller};
573"#
574 ),
575 FeatureModKind::ControllerWithService => format!(
576 r#"pub mod controller;
577pub mod service;
578
579pub use controller::{controller};
580pub use service::{service};
581"#
582 ),
583 }
584}
585
586pub fn render_feature_module(feature: &FeatureName) -> String {
587 let module = feature.module_type();
588 let service = feature.service_type();
589 let controller = feature.controller_type();
590
591 format!(
592 r#"pub mod controller;
593pub mod service;
594
595pub use controller::{controller};
596pub use service::{service};
597
598use caelix::{{Module, ModuleMetadata}};
599
600pub struct {module};
601
602impl Module for {module} {{
603 fn register() -> ModuleMetadata {{
604 ModuleMetadata::new()
605 .provider::<{service}>()
606 .controller::<{controller}>()
607 }}
608}}
609"#
610 )
611}
612
613fn service_instructions(feature: &FeatureName) -> String {
614 format!(
615 r#"Manual registration:
616- Ensure `src/{}/mod.rs` contains `pub mod service;` and `pub use service::{};`.
617- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
618- Add `use crate::{}::{};` to the module that should own the service.
619- Add `.provider::<{}>()` inside that module's `register()`."#,
620 feature.module_name(),
621 feature.service_type(),
622 feature.module_name(),
623 feature.module_name(),
624 feature.service_type(),
625 feature.service_type()
626 )
627}
628
629fn controller_instructions(feature: &FeatureName, has_service: bool) -> String {
630 let mut instructions = format!(
631 r#"Manual registration:
632- Ensure `src/{}/mod.rs` contains `pub mod controller;` and `pub use controller::{};`.
633- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
634- Add `use crate::{}::{};` to the module that should own the controller.
635- Add `.controller::<{}>()` inside that module's `register()`."#,
636 feature.module_name(),
637 feature.controller_type(),
638 feature.module_name(),
639 feature.module_name(),
640 feature.controller_type(),
641 feature.controller_type()
642 );
643
644 if has_service {
645 instructions.push_str(&format!(
646 "\n- Ensure `{}` is registered as a provider in the same module.",
647 feature.service_type()
648 ));
649 }
650
651 instructions
652}
653
654fn module_instructions(feature: &FeatureName) -> String {
655 format!(
656 r#"Manual registration:
657- Add `pub mod {};` to `src/lib.rs`.
658- Add `use crate::{}::{};` to `src/app.rs`.
659- Add `.import::<{}>()` inside `AppModule::register()`."#,
660 feature.module_name(),
661 feature.module_name(),
662 feature.module_type(),
663 feature.module_type()
664 )
665}
666
667fn created_files(paths: &[PathBuf]) -> String {
668 let mut output = String::from("Created files:\n");
669 for path in paths {
670 output.push_str(&format!("- {}\n", path.display()));
671 }
672 output
673}
674
675fn ensure_missing(path: &Path) -> Result<()> {
676 if path.exists() {
677 Err(CliError::AlreadyExists(path.to_path_buf()))
678 } else {
679 Ok(())
680 }
681}
682
683fn create_file(path: impl AsRef<Path>, contents: impl AsRef<str>) -> Result<()> {
684 let path = path.as_ref();
685 ensure_missing(path)?;
686 fs::write(path, contents.as_ref()).map_err(|source| CliError::Io {
687 path: path.to_path_buf(),
688 source,
689 })
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn feature_name_converts_to_rust_and_route_names() {
698 let feature = FeatureName::parse("auth-session").unwrap();
699
700 assert_eq!(feature.module_name(), "auth_session");
701 assert_eq!(feature.route_path(), "auth-session");
702 assert_eq!(feature.service_type(), "AuthSessionService");
703 assert_eq!(feature.controller_type(), "AuthSessionController");
704 assert_eq!(feature.module_type(), "AuthSessionModule");
705 }
706
707 #[test]
708 fn service_template_uses_injectable_struct() {
709 let feature = FeatureName::parse("users").unwrap();
710 let rendered = render_service(&feature);
711
712 assert!(rendered.contains("#[injectable]\npub struct UsersService;"));
713 assert!(rendered.contains("Hello from UsersService"));
714 }
715
716 #[test]
717 fn controller_template_omits_service_when_missing() {
718 let feature = FeatureName::parse("users").unwrap();
719 let rendered = render_controller(&feature, false);
720
721 assert!(rendered.contains("pub struct UsersController;"));
722 assert!(!rendered.contains("Arc<UsersService>"));
723 assert!(rendered.contains("#[controller(\"/users\")]"));
724 }
725
726 #[test]
727 fn module_template_registers_provider_and_controller() {
728 let feature = FeatureName::parse("users").unwrap();
729 let rendered = render_feature_module(&feature);
730
731 assert!(rendered.contains("pub struct UsersModule;"));
732 assert!(rendered.contains(".provider::<UsersService>()"));
733 assert!(rendered.contains(".controller::<UsersController>()"));
734 }
735}