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