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 New(NewArgs),
81 #[command(alias = "g")]
83 Generate(GenerateArgs),
84 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}