Skip to main content

caelix_cli/
lib.rs

1use std::{
2    env, fmt, fs, io,
3    path::{Path, PathBuf},
4    process::Command as ProcessCommand,
5};
6
7use clap::{Args, Parser, Subcommand};
8use heck::{ToKebabCase, ToPascalCase, ToSnakeCase};
9use toml_edit::{DocumentMut, Item, Value};
10
11pub type Result<T> = std::result::Result<T, CliError>;
12
13#[derive(Debug)]
14pub enum CliError {
15    Io {
16        path: PathBuf,
17        source: io::Error,
18    },
19    AlreadyExists(PathBuf),
20    InvalidName(String),
21    TomlParse {
22        path: PathBuf,
23        source: toml_edit::TomlError,
24    },
25    MissingDependency(String),
26    UnsupportedDependencyFormat(String),
27    CratesIo(reqwest::Error),
28    CratesIoResponse,
29    CargoUpdateFailed(Option<i32>),
30}
31
32impl fmt::Display for CliError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
36            Self::AlreadyExists(path) => {
37                write!(
38                    f,
39                    "{} already exists; refusing to overwrite",
40                    path.display()
41                )
42            }
43            Self::InvalidName(name) => write!(f, "invalid project or feature name `{name}`"),
44            Self::TomlParse { path, source } => write!(f, "{}: {source}", path.display()),
45            Self::MissingDependency(name) => {
46                write!(f, "`{name}` dependency was not found in Cargo.toml")
47            }
48            Self::UnsupportedDependencyFormat(name) => {
49                write!(
50                    f,
51                    "`{name}` dependency uses an unsupported Cargo.toml format"
52                )
53            }
54            Self::CratesIo(source) => write!(f, "failed to fetch latest caelix version: {source}"),
55            Self::CratesIoResponse => write!(f, "crates.io response did not include max_version"),
56            Self::CargoUpdateFailed(code) => match code {
57                Some(code) => write!(f, "`cargo update -p caelix` failed with exit code {code}"),
58                None => write!(f, "`cargo update -p caelix` was terminated by a signal"),
59            },
60        }
61    }
62}
63
64impl std::error::Error for CliError {}
65
66#[derive(Parser, Debug)]
67#[command(
68    name = "caelix",
69    version,
70    about = "Generate Caelix applications and features"
71)]
72struct Cli {
73    #[command(subcommand)]
74    command: Command,
75}
76
77#[derive(Subcommand, Debug)]
78enum Command {
79    /// Create a new Caelix application
80    New(NewArgs),
81    /// Generate a Caelix service, controller, or module
82    #[command(alias = "g")]
83    Generate(GenerateArgs),
84    /// Update the caelix dependency in the current Cargo.toml
85    Update,
86}
87
88#[derive(Args, Debug)]
89struct NewArgs {
90    name: String,
91}
92
93#[derive(Args, Debug)]
94struct GenerateArgs {
95    #[command(subcommand)]
96    kind: GenerateKind,
97}
98
99#[derive(Subcommand, Debug)]
100enum GenerateKind {
101    Service(NameArgs),
102    Controller(NameArgs),
103    Module(NameArgs),
104}
105
106#[derive(Args, Debug)]
107struct NameArgs {
108    name: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct FeatureName {
113    raw: String,
114    module_name: String,
115    route_path: String,
116    type_prefix: String,
117}
118
119impl FeatureName {
120    pub fn parse(name: impl Into<String>) -> Result<Self> {
121        let raw = name.into();
122        let trimmed = raw.trim();
123        if trimmed.is_empty() || trimmed.contains(['/', '\\']) {
124            return Err(CliError::InvalidName(raw));
125        }
126
127        let module_name = trimmed.to_snake_case();
128        let route_path = trimmed.to_kebab_case();
129        let type_prefix = trimmed.to_pascal_case();
130
131        if module_name.is_empty()
132            || route_path.is_empty()
133            || type_prefix.is_empty()
134            || module_name
135                .chars()
136                .next()
137                .is_some_and(|ch| ch.is_ascii_digit())
138        {
139            return Err(CliError::InvalidName(trimmed.to_string()));
140        }
141
142        Ok(Self {
143            raw: trimmed.to_string(),
144            module_name,
145            route_path,
146            type_prefix,
147        })
148    }
149
150    pub fn module_name(&self) -> &str {
151        &self.module_name
152    }
153
154    pub fn route_path(&self) -> &str {
155        &self.route_path
156    }
157
158    pub fn service_type(&self) -> String {
159        format!("{}Service", self.type_prefix)
160    }
161
162    pub fn controller_type(&self) -> String {
163        format!("{}Controller", self.type_prefix)
164    }
165
166    pub fn module_type(&self) -> String {
167        format!("{}Module", self.type_prefix)
168    }
169}
170
171pub fn run_from_env() -> Result<String> {
172    let cwd = env::current_dir().map_err(|source| CliError::Io {
173        path: PathBuf::from("."),
174        source,
175    })?;
176    run_from(env::args_os(), cwd)
177}
178
179pub fn run_from<I, T>(args: I, cwd: impl AsRef<Path>) -> Result<String>
180where
181    I: IntoIterator<Item = T>,
182    T: Into<std::ffi::OsString> + Clone,
183{
184    let cli = Cli::parse_from(args);
185    run(cli, cwd.as_ref())
186}
187
188fn run(cli: Cli, cwd: &Path) -> Result<String> {
189    match cli.command {
190        Command::New(args) => generate_new(args, cwd),
191        Command::Generate(args) => match args.kind {
192            GenerateKind::Service(args) => generate_service(&FeatureName::parse(args.name)?, cwd),
193            GenerateKind::Controller(args) => {
194                generate_controller(&FeatureName::parse(args.name)?, cwd)
195            }
196            GenerateKind::Module(args) => generate_module(&FeatureName::parse(args.name)?, cwd),
197        },
198        Command::Update => update_caelix_dependency(cwd),
199    }
200}
201
202fn update_caelix_dependency(cwd: &Path) -> Result<String> {
203    let cargo_toml_path = cwd.join("Cargo.toml");
204    let latest = fetch_latest_caelix_version()?;
205    let outcome = update_caelix_version(&cargo_toml_path, &latest)?;
206
207    match outcome {
208        UpdateOutcome::AlreadyLatest { current } => {
209            Ok(format!("Already on the latest version ({current}).\n"))
210        }
211        UpdateOutcome::Updated { previous, latest } => {
212            run_cargo_update(cwd)?;
213            Ok(format!(
214                "caelix {previous} -> {latest}\nUpdated Cargo.toml. Ran `cargo update -p caelix`.\n"
215            ))
216        }
217    }
218}
219
220fn fetch_latest_caelix_version() -> Result<String> {
221    let response = reqwest::blocking::Client::new()
222        .get("https://crates.io/api/v1/crates/caelix")
223        .header(reqwest::header::USER_AGENT, "caelix-cli")
224        .send()
225        .map_err(CliError::CratesIo)?
226        .error_for_status()
227        .map_err(CliError::CratesIo)?;
228    let json: serde_json::Value = response.json().map_err(CliError::CratesIo)?;
229
230    json["crate"]["max_version"]
231        .as_str()
232        .map(ToOwned::to_owned)
233        .ok_or(CliError::CratesIoResponse)
234}
235
236fn run_cargo_update(cwd: &Path) -> Result<()> {
237    let status = ProcessCommand::new("cargo")
238        .args(["update", "-p", "caelix"])
239        .current_dir(cwd)
240        .status()
241        .map_err(|source| CliError::Io {
242            path: cwd.join("Cargo.toml"),
243            source,
244        })?;
245
246    if status.success() {
247        Ok(())
248    } else {
249        Err(CliError::CargoUpdateFailed(status.code()))
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254enum UpdateOutcome {
255    AlreadyLatest { current: String },
256    Updated { previous: String, latest: String },
257}
258
259fn update_caelix_version(cargo_toml_path: &Path, latest: &str) -> Result<UpdateOutcome> {
260    let content = fs::read_to_string(cargo_toml_path).map_err(|source| CliError::Io {
261        path: cargo_toml_path.to_path_buf(),
262        source,
263    })?;
264    let mut doc = content
265        .parse::<DocumentMut>()
266        .map_err(|source| CliError::TomlParse {
267            path: cargo_toml_path.to_path_buf(),
268            source,
269        })?;
270
271    let current = read_caelix_version(&doc)?;
272    if current == latest {
273        return Ok(UpdateOutcome::AlreadyLatest { current });
274    }
275
276    write_caelix_version(&mut doc, latest)?;
277    fs::write(cargo_toml_path, doc.to_string()).map_err(|source| CliError::Io {
278        path: cargo_toml_path.to_path_buf(),
279        source,
280    })?;
281
282    Ok(UpdateOutcome::Updated {
283        previous: current,
284        latest: latest.to_string(),
285    })
286}
287
288fn read_caelix_version(doc: &DocumentMut) -> Result<String> {
289    let dependency =
290        find_caelix_dependency(doc).ok_or_else(|| CliError::MissingDependency("caelix".into()))?;
291
292    if let Some(version) = dependency.as_str() {
293        return Ok(version.to_string());
294    }
295
296    if let Item::Value(Value::InlineTable(table)) = dependency {
297        return table
298            .get("version")
299            .and_then(Value::as_str)
300            .map(ToOwned::to_owned)
301            .ok_or_else(|| CliError::UnsupportedDependencyFormat("caelix".into()));
302    }
303
304    dependency["version"]
305        .as_str()
306        .map(ToOwned::to_owned)
307        .ok_or_else(|| CliError::UnsupportedDependencyFormat("caelix".into()))
308}
309
310fn write_caelix_version(doc: &mut DocumentMut, latest: &str) -> Result<()> {
311    let dependency = find_caelix_dependency_mut(doc)
312        .ok_or_else(|| CliError::MissingDependency("caelix".into()))?;
313
314    if dependency.as_str().is_some() {
315        replace_string_value_preserving_decor(dependency, latest)?;
316        return Ok(());
317    }
318
319    if let Item::Value(Value::InlineTable(table)) = dependency {
320        if table.get("version").is_some() {
321            table.insert("version", Value::from(latest));
322            return Ok(());
323        }
324    }
325
326    if dependency["version"].as_str().is_some() {
327        replace_string_value_preserving_decor(&mut dependency["version"], latest)?;
328        return Ok(());
329    }
330
331    Err(CliError::UnsupportedDependencyFormat("caelix".into()))
332}
333
334fn replace_string_value_preserving_decor(item: &mut Item, value: &str) -> Result<()> {
335    let Some(current) = item.as_value_mut() else {
336        return Err(CliError::UnsupportedDependencyFormat("caelix".into()));
337    };
338
339    if !current.is_str() {
340        return Err(CliError::UnsupportedDependencyFormat("caelix".into()));
341    }
342
343    let decor = current.decor().clone();
344    let mut replacement = Value::from(value);
345    *replacement.decor_mut() = decor;
346    *current = replacement;
347    Ok(())
348}
349
350fn find_caelix_dependency(doc: &DocumentMut) -> Option<&Item> {
351    doc.get("dependencies")
352        .and_then(|dependencies| dependencies.get("caelix"))
353        .or_else(|| {
354            doc.get("workspace")
355                .and_then(|workspace| workspace.get("dependencies"))
356                .and_then(|dependencies| dependencies.get("caelix"))
357        })
358}
359
360fn find_caelix_dependency_mut(doc: &mut DocumentMut) -> Option<&mut Item> {
361    let has_normal_dependency = doc
362        .get("dependencies")
363        .and_then(|dependencies| dependencies.get("caelix"))
364        .is_some();
365
366    if has_normal_dependency {
367        return doc
368            .get_mut("dependencies")
369            .and_then(|dependencies| dependencies.get_mut("caelix"));
370    }
371
372    doc.get_mut("workspace")
373        .and_then(|workspace| workspace.get_mut("dependencies"))
374        .and_then(|dependencies| dependencies.get_mut("caelix"))
375}
376
377fn generate_new(args: NewArgs, cwd: &Path) -> Result<String> {
378    let target_dir = cwd.join(&args.name);
379    ensure_missing(&target_dir)?;
380
381    let package_name = package_name_for_path(&target_dir, &args.name)?;
382    let crate_name = package_name.to_snake_case();
383
384    fs::create_dir_all(target_dir.join("src")).map_err(|source| CliError::Io {
385        path: target_dir.join("src"),
386        source,
387    })?;
388
389    let cargo_toml = render_app_cargo_toml(&package_name);
390    create_file(target_dir.join("Cargo.toml"), &cargo_toml)?;
391    create_file(target_dir.join("AGENTS.md"), render_agents_md())?;
392    create_file(target_dir.join("src/main.rs"), &render_main_rs(&crate_name))?;
393    create_file(target_dir.join("src/lib.rs"), render_lib_rs())?;
394    create_file(target_dir.join("src/app.rs"), render_app_rs())?;
395
396    Ok(format!(
397        "Created Caelix application `{}` in {}\n\nNext steps:\n- cd {}\n- cargo run\n",
398        package_name,
399        target_dir.display(),
400        target_dir.display()
401    ))
402}
403
404fn generate_service(feature: &FeatureName, cwd: &Path) -> Result<String> {
405    let feature_dir = src_dir(cwd).join(feature.module_name());
406    let service_path = feature_dir.join("service.rs");
407    let mod_path = feature_dir.join("mod.rs");
408
409    ensure_missing(&service_path)?;
410    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
411        path: feature_dir.clone(),
412        source,
413    })?;
414    create_file(&service_path, &render_service(feature))?;
415
416    let mut created = vec![service_path];
417    if !mod_path.exists() {
418        create_file(
419            &mod_path,
420            &render_feature_mod(feature, FeatureModKind::Service),
421        )?;
422        created.push(mod_path);
423    }
424
425    Ok(format!(
426        "{}\n\n{}",
427        created_files(&created),
428        service_instructions(feature)
429    ))
430}
431
432fn generate_controller(feature: &FeatureName, cwd: &Path) -> Result<String> {
433    let feature_dir = src_dir(cwd).join(feature.module_name());
434    let service_path = feature_dir.join("service.rs");
435    let controller_path = feature_dir.join("controller.rs");
436    let mod_path = feature_dir.join("mod.rs");
437    let has_service = service_path.exists();
438
439    ensure_missing(&controller_path)?;
440    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
441        path: feature_dir.clone(),
442        source,
443    })?;
444    create_file(&controller_path, &render_controller(feature, has_service))?;
445
446    let mut created = vec![controller_path];
447    if !mod_path.exists() {
448        let kind = if has_service {
449            FeatureModKind::ControllerWithService
450        } else {
451            FeatureModKind::Controller
452        };
453        create_file(&mod_path, &render_feature_mod(feature, kind))?;
454        created.push(mod_path);
455    }
456
457    let mut output = format!(
458        "{}\n\n{}",
459        created_files(&created),
460        controller_instructions(feature, has_service)
461    );
462    if !has_service {
463        output.push_str(&format!(
464            "\n\nNote: src/{}/service.rs was not found, so {} was generated without a {} dependency.\n",
465            feature.module_name(),
466            feature.controller_type(),
467            feature.service_type()
468        ));
469    }
470    Ok(output)
471}
472
473fn generate_module(feature: &FeatureName, cwd: &Path) -> Result<String> {
474    let feature_dir = src_dir(cwd).join(feature.module_name());
475    let mod_path = feature_dir.join("mod.rs");
476    let service_path = feature_dir.join("service.rs");
477    let controller_path = feature_dir.join("controller.rs");
478
479    ensure_missing(&mod_path)?;
480    ensure_missing(&service_path)?;
481    ensure_missing(&controller_path)?;
482
483    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
484        path: feature_dir.clone(),
485        source,
486    })?;
487    create_file(&mod_path, &render_feature_module(feature))?;
488    create_file(&service_path, &render_service(feature))?;
489    create_file(&controller_path, &render_controller(feature, true))?;
490
491    Ok(format!(
492        "{}\n\n{}",
493        created_files(&[mod_path, service_path, controller_path]),
494        module_instructions(feature)
495    ))
496}
497
498fn src_dir(cwd: &Path) -> PathBuf {
499    cwd.join("src")
500}
501
502fn package_name_for_path(path: &Path, fallback: &str) -> Result<String> {
503    let name = path
504        .file_name()
505        .and_then(|name| name.to_str())
506        .unwrap_or(fallback)
507        .trim();
508
509    if name.is_empty() {
510        return Err(CliError::InvalidName(fallback.to_string()));
511    }
512
513    Ok(name.to_kebab_case())
514}
515
516pub fn render_app_cargo_toml(package_name: &str) -> String {
517    format!(
518        r#"[package]
519name = "{package_name}"
520version = "0.0.1"
521edition = "2024"
522
523[dependencies]
524actix-web = "4.14.0"
525caelix = "0.0.3"
526serde = {{ version = "1.0.228", features = ["derive"] }}
527"#
528    )
529}
530
531fn render_main_rs(crate_name: &str) -> String {
532    format!(
533        r#"use caelix::Application;
534use {crate_name}::AppModule;
535
536#[caelix::main]
537async fn main() -> std::io::Result<()> {{
538    Application::new::<AppModule>()
539        .await
540        .listen("127.0.0.1:8080")
541        .await
542}}
543"#,
544        crate_name = crate_name
545    )
546}
547
548fn render_lib_rs() -> &'static str {
549    "pub mod app;\n\npub use app::AppModule;\n"
550}
551
552fn render_app_rs() -> &'static str {
553    r#"use caelix::{Module, ModuleMetadata};
554
555pub struct AppModule;
556
557impl Module for AppModule {
558    fn register() -> ModuleMetadata {
559        ModuleMetadata::new()
560    }
561}
562"#
563}
564
565fn render_agents_md() -> &'static str {
566    r#"# Agent Instructions
567
568This is a Caelix application. Use this file as the quick working reference when changing generated app code.
569
570For fuller documentation, refer to https://ohanronnie.github.io/caelix/.
571
572## App Structure
573
574- `src/main.rs` starts the Actix runtime with `Application::new::<AppModule>()`.
575- `src/lib.rs` exports the root `AppModule` and should declare feature modules with `pub mod feature_name;`.
576- `src/app.rs` owns the root `AppModule`.
577- Feature folders usually contain `mod.rs`, `service.rs`, and `controller.rs`.
578- Prefer the Caelix CLI for new framework files: `caelix g module name`, `caelix g service name`, and `caelix g controller name`.
579
580## Registration Model
581
582Caelix uses explicit module metadata. Do not rely on filesystem discovery or hidden auto-registration.
583
584- A module implements `Module` and returns `ModuleMetadata`.
585- Add generated feature modules to `src/lib.rs`.
586- Import feature modules in `src/app.rs`.
587- Add `.import::<FeatureModule>()` inside `AppModule::register()`.
588- Register services with `.provider::<Service>()`.
589- Register controllers with `.controller::<Controller>()`.
590- Register async factory values with `.provider_async_factory::<T, _, _>(...)` when construction cannot be expressed as `#[injectable]`.
591
592Example:
593
594```rust
595use caelix::{Module, ModuleMetadata};
596use crate::users::UsersModule;
597
598pub struct AppModule;
599
600impl Module for AppModule {
601    fn register() -> ModuleMetadata {
602        ModuleMetadata::new().import::<UsersModule>()
603    }
604}
605```
606
607## Providers And Injection
608
609Prefer `#[injectable]` for services, controllers, guards, and interceptors.
610
611- Injectable fields must be `Arc<T>`.
612- `Arc<Logger>` is provided automatically with the struct name as context.
613- Unit structs are valid injectables.
614- Tuple structs are not supported by `#[injectable]`.
615- Manual `Injectable` implementations are acceptable for custom async construction.
616- Lifecycle hooks can be implemented on providers: `on_module_init`, `on_bootstrap`, and `on_shutdown`.
617
618Example:
619
620```rust
621use std::sync::Arc;
622use caelix::{injectable, Logger};
623
624#[injectable]
625pub struct UsersService {
626    logger: Arc<Logger>,
627}
628```
629
630## Controllers
631
632Use `#[controller("/base-path")]` on an impl block. Route handlers are async methods.
633
634- Supported route attributes: `#[get]`, `#[post]`, `#[patch]`, `#[put]`, `#[delete]`.
635- Supported extractor attributes: `#[param]`, `#[body]`, `#[query]`, `#[user]`.
636- Add `#[validate]` to extracted DTOs that implement `validator::Validate`.
637- `#[user]` reads from `RequestContext`; missing users become `UnauthorizedException`.
638
639Example:
640
641```rust
642use std::sync::Arc;
643use caelix::{controller, get, injectable, Result};
644use super::UsersService;
645
646#[injectable]
647pub struct UsersController {
648    service: Arc<UsersService>,
649}
650
651#[controller("/users")]
652impl UsersController {
653    #[get("/{id}")]
654    async fn find_one(&self, #[param] id: String) -> Result<String> {
655        Ok(self.service.find_one(id).await?)
656    }
657}
658```
659
660## Guards, Interceptors, And Context
661
662- Guards implement `Guard` and return whether a request may continue.
663- Interceptors implement `Interceptor` and can wrap handler execution.
664- Apply them with `#[use_guard(Type)]` or `#[use_interceptor(Type)]` at controller or method level.
665- Controller-level guards/interceptors apply before method-level ones.
666- Use `RequestContext` for request method, path, headers, and per-request values such as authenticated users.
667
668## Responses And Errors
669
670Handlers should return values that implement `IntoCaelixResponse`.
671
672- `Result<String>` returns `200 text/plain`.
673- `Result<Response<T>>` is the usual JSON response path.
674- `Response::Body(value)` returns `200` JSON.
675- `Response::WithStatus(status, value)` returns JSON with a custom status.
676- `Response::json`, `Response::text`, and `Response::bytes` return explicit raw payloads.
677- `Response::no_content()` returns `204`.
678- Use Caelix exception types such as `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, and `InternalServerErrorException` for errors.
679- Server error messages are intentionally hidden from HTTP responses.
680
681## Cache
682
683Cache support is explicit service-level caching.
684
685- Import `CacheModule` into a module that needs cache support.
686- Inject `Arc<Cache>` into services that need cache reads/writes.
687- Do not add automatic HTTP response caching.
688- If the app needs response caching, implement it with an interceptor that reads from and writes to `Cache`.
689
690## Checks
691
692- Run `cargo test` after code changes when feasible.
693- Keep public app code using the `caelix` facade (`use caelix::...`) instead of internal Caelix crate paths.
694- When using CLI-generated files, keep the manual registration steps in the command output aligned with `src/lib.rs` and `src/app.rs`.
695"#
696}
697
698pub fn render_service(feature: &FeatureName) -> String {
699    let service = feature.service_type();
700    format!(
701        r#"use caelix::injectable;
702
703#[injectable]
704pub struct {service};
705
706impl {service} {{
707    pub fn hello(&self) -> String {{
708        "Hello from {service}".to_string()
709    }}
710}}
711"#
712    )
713}
714
715pub fn render_controller(feature: &FeatureName, has_service: bool) -> String {
716    let controller = feature.controller_type();
717    let route = feature.route_path();
718
719    if has_service {
720        let service = feature.service_type();
721        format!(
722            r#"use std::sync::Arc;
723
724use caelix::{{controller, get, injectable, Result}};
725
726use super::{service};
727
728#[injectable]
729pub struct {controller} {{
730    service: Arc<{service}>,
731}}
732
733#[controller("/{route}")]
734impl {controller} {{
735    #[get("")]
736    pub async fn hello(&self) -> Result<String> {{
737        Ok(self.service.hello())
738    }}
739}}
740"#
741        )
742    } else {
743        format!(
744            r#"use caelix::{{controller, get, injectable, Result}};
745
746#[injectable]
747pub struct {controller};
748
749#[controller("/{route}")]
750impl {controller} {{
751    #[get("")]
752    pub async fn hello(&self) -> Result<String> {{
753        Ok("Hello from {controller}".to_string())
754    }}
755}}
756"#
757        )
758    }
759}
760
761#[derive(Clone, Copy)]
762enum FeatureModKind {
763    Service,
764    Controller,
765    ControllerWithService,
766}
767
768fn render_feature_mod(feature: &FeatureName, kind: FeatureModKind) -> String {
769    let service = feature.service_type();
770    let controller = feature.controller_type();
771
772    match kind {
773        FeatureModKind::Service => format!(
774            r#"pub mod service;
775
776pub use service::{service};
777"#
778        ),
779        FeatureModKind::Controller => format!(
780            r#"pub mod controller;
781
782pub use controller::{controller};
783"#
784        ),
785        FeatureModKind::ControllerWithService => format!(
786            r#"pub mod controller;
787pub mod service;
788
789pub use controller::{controller};
790pub use service::{service};
791"#
792        ),
793    }
794}
795
796pub fn render_feature_module(feature: &FeatureName) -> String {
797    let module = feature.module_type();
798    let service = feature.service_type();
799    let controller = feature.controller_type();
800
801    format!(
802        r#"pub mod controller;
803pub mod service;
804
805pub use controller::{controller};
806pub use service::{service};
807
808use caelix::{{Module, ModuleMetadata}};
809
810pub struct {module};
811
812impl Module for {module} {{
813    fn register() -> ModuleMetadata {{
814        ModuleMetadata::new()
815            .provider::<{service}>()
816            .controller::<{controller}>()
817    }}
818}}
819"#
820    )
821}
822
823fn service_instructions(feature: &FeatureName) -> String {
824    format!(
825        r#"Manual registration:
826- Ensure `src/{}/mod.rs` contains `pub mod service;` and `pub use service::{};`.
827- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
828- Add `use crate::{}::{};` to the module that should own the service.
829- Add `.provider::<{}>()` inside that module's `register()`."#,
830        feature.module_name(),
831        feature.service_type(),
832        feature.module_name(),
833        feature.module_name(),
834        feature.service_type(),
835        feature.service_type()
836    )
837}
838
839fn controller_instructions(feature: &FeatureName, has_service: bool) -> String {
840    let mut instructions = format!(
841        r#"Manual registration:
842- Ensure `src/{}/mod.rs` contains `pub mod controller;` and `pub use controller::{};`.
843- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
844- Add `use crate::{}::{};` to the module that should own the controller.
845- Add `.controller::<{}>()` inside that module's `register()`."#,
846        feature.module_name(),
847        feature.controller_type(),
848        feature.module_name(),
849        feature.module_name(),
850        feature.controller_type(),
851        feature.controller_type()
852    );
853
854    if has_service {
855        instructions.push_str(&format!(
856            "\n- Ensure `{}` is registered as a provider in the same module.",
857            feature.service_type()
858        ));
859    }
860
861    instructions
862}
863
864fn module_instructions(feature: &FeatureName) -> String {
865    format!(
866        r#"Manual registration:
867- Add `pub mod {};` to `src/lib.rs`.
868- Add `use crate::{}::{};` to `src/app.rs`.
869- Add `.import::<{}>()` inside `AppModule::register()`."#,
870        feature.module_name(),
871        feature.module_name(),
872        feature.module_type(),
873        feature.module_type()
874    )
875}
876
877fn created_files(paths: &[PathBuf]) -> String {
878    let mut output = String::from("Created files:\n");
879    for path in paths {
880        output.push_str(&format!("- {}\n", path.display()));
881    }
882    output
883}
884
885fn ensure_missing(path: &Path) -> Result<()> {
886    if path.exists() {
887        Err(CliError::AlreadyExists(path.to_path_buf()))
888    } else {
889        Ok(())
890    }
891}
892
893fn create_file(path: impl AsRef<Path>, contents: impl AsRef<str>) -> Result<()> {
894    let path = path.as_ref();
895    ensure_missing(path)?;
896    fs::write(path, contents.as_ref()).map_err(|source| CliError::Io {
897        path: path.to_path_buf(),
898        source,
899    })
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use tempfile::tempdir;
906
907    #[test]
908    fn feature_name_converts_to_rust_and_route_names() {
909        let feature = FeatureName::parse("auth-session").unwrap();
910
911        assert_eq!(feature.module_name(), "auth_session");
912        assert_eq!(feature.route_path(), "auth-session");
913        assert_eq!(feature.service_type(), "AuthSessionService");
914        assert_eq!(feature.controller_type(), "AuthSessionController");
915        assert_eq!(feature.module_type(), "AuthSessionModule");
916    }
917
918    #[test]
919    fn service_template_uses_injectable_struct() {
920        let feature = FeatureName::parse("users").unwrap();
921        let rendered = render_service(&feature);
922
923        assert!(rendered.contains("#[injectable]\npub struct UsersService;"));
924        assert!(rendered.contains("Hello from UsersService"));
925    }
926
927    #[test]
928    fn controller_template_omits_service_when_missing() {
929        let feature = FeatureName::parse("users").unwrap();
930        let rendered = render_controller(&feature, false);
931
932        assert!(rendered.contains("pub struct UsersController;"));
933        assert!(!rendered.contains("Arc<UsersService>"));
934        assert!(rendered.contains("#[controller(\"/users\")]"));
935    }
936
937    #[test]
938    fn module_template_registers_provider_and_controller() {
939        let feature = FeatureName::parse("users").unwrap();
940        let rendered = render_feature_module(&feature);
941
942        assert!(rendered.contains("pub struct UsersModule;"));
943        assert!(rendered.contains(".provider::<UsersService>()"));
944        assert!(rendered.contains(".controller::<UsersController>()"));
945    }
946
947    #[test]
948    fn update_caelix_version_preserves_cargo_toml_comments_and_other_dependencies() {
949        let tmp = tempdir().unwrap();
950        let path = tmp.path().join("Cargo.toml");
951        fs::write(
952            &path,
953            r#"[package]
954name = "demo"
955version = "0.0.1"
956
957# Keep this dependency comment.
958[dependencies]
959actix-web = "4.14.0"
960caelix = "0.0.2" # framework
961serde = { version = "1.0", features = ["derive"] }
962"#,
963        )
964        .unwrap();
965
966        let outcome = update_caelix_version(&path, "0.0.3").unwrap();
967        let updated = fs::read_to_string(path).unwrap();
968
969        assert_eq!(
970            outcome,
971            UpdateOutcome::Updated {
972                previous: "0.0.2".into(),
973                latest: "0.0.3".into()
974            }
975        );
976        assert!(updated.contains("# Keep this dependency comment."));
977        assert!(updated.contains(r#"caelix = "0.0.3" # framework"#));
978        assert!(updated.contains(r#"serde = { version = "1.0", features = ["derive"] }"#));
979    }
980
981    #[test]
982    fn update_caelix_version_preserves_inline_table_features() {
983        let tmp = tempdir().unwrap();
984        let path = tmp.path().join("Cargo.toml");
985        fs::write(
986            &path,
987            r#"[package]
988name = "demo"
989version = "0.0.1"
990
991[dependencies]
992caelix = { version = "0.0.2", default-features = false, features = ["actix"] }
993"#,
994        )
995        .unwrap();
996
997        update_caelix_version(&path, "0.0.3").unwrap();
998        let updated = fs::read_to_string(path).unwrap();
999
1000        assert!(updated.contains(
1001            r#"caelix = { version = "0.0.3", default-features = false, features = ["actix"] }"#
1002        ));
1003    }
1004
1005    #[test]
1006    fn update_caelix_version_reports_already_latest_without_writing() {
1007        let tmp = tempdir().unwrap();
1008        let path = tmp.path().join("Cargo.toml");
1009        let content = r#"[dependencies]
1010caelix = "0.0.3"
1011"#;
1012        fs::write(&path, content).unwrap();
1013
1014        let outcome = update_caelix_version(&path, "0.0.3").unwrap();
1015
1016        assert_eq!(
1017            outcome,
1018            UpdateOutcome::AlreadyLatest {
1019                current: "0.0.3".into()
1020            }
1021        );
1022        assert_eq!(fs::read_to_string(path).unwrap(), content);
1023    }
1024}