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