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