Skip to main content

caelix_cli/
lib.rs

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.2"
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}