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