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("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}