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 validate_project_name(&args.name)?;
616 let target_dir = cwd.join(&args.name);
617 ensure_missing(&target_dir)?;
618
619 let package_name = package_name_for_path(&target_dir, &args.name)?;
620 let crate_name = package_name.to_snake_case();
621
622 fs::create_dir_all(target_dir.join("src")).map_err(|source| CliError::Io {
623 path: target_dir.join("src"),
624 source,
625 })?;
626
627 let cargo_toml = render_app_cargo_toml_for_backend(&package_name, args.backend);
628 create_file(target_dir.join("Cargo.toml"), &cargo_toml)?;
629 create_file(target_dir.join("AGENTS.md"), render_agents_md())?;
630 create_file(target_dir.join("src/main.rs"), &render_main_rs(&crate_name))?;
631 create_file(target_dir.join("src/lib.rs"), render_lib_rs())?;
632 create_file(target_dir.join("src/app.rs"), render_app_rs())?;
633
634 Ok(format!(
635 "Created Caelix application `{}` in {}\n\nNext steps:\n- cd {}\n- caelix run\n",
636 package_name,
637 target_dir.display(),
638 target_dir.display()
639 ))
640}
641
642fn validate_project_name(name: &str) -> Result<()> {
646 const RUST_KEYWORDS: &[&str] = &[
647 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
648 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
649 "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
650 "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do",
651 "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
652 ];
653
654 let valid = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
655 && name
656 .bytes()
657 .skip(1)
658 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
659 && !RUST_KEYWORDS.contains(&name);
660 if valid {
661 Ok(())
662 } else {
663 Err(CliError::InvalidName(name.to_owned()))
664 }
665}
666
667fn generate_service(feature: &FeatureName, cwd: &Path) -> Result<String> {
668 let feature_dir = src_dir(cwd).join(feature.module_name());
669 let service_path = feature_dir.join("service.rs");
670 let mod_path = feature_dir.join("mod.rs");
671
672 ensure_missing(&service_path)?;
673 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
674 path: feature_dir.clone(),
675 source,
676 })?;
677 create_file(&service_path, &render_service(feature))?;
678
679 let mut created = vec![service_path];
680 if !mod_path.exists() {
681 create_file(
682 &mod_path,
683 &render_feature_mod(feature, FeatureModKind::Service),
684 )?;
685 created.push(mod_path);
686 }
687
688 Ok(format!(
689 "{}\n\n{}",
690 created_files(&created),
691 service_instructions(feature)
692 ))
693}
694
695fn generate_controller(feature: &FeatureName, cwd: &Path) -> Result<String> {
696 let feature_dir = src_dir(cwd).join(feature.module_name());
697 let service_path = feature_dir.join("service.rs");
698 let controller_path = feature_dir.join("controller.rs");
699 let mod_path = feature_dir.join("mod.rs");
700 let has_service = service_path.exists();
701
702 ensure_missing(&controller_path)?;
703 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
704 path: feature_dir.clone(),
705 source,
706 })?;
707 create_file(&controller_path, &render_controller(feature, has_service))?;
708
709 let mut created = vec![controller_path];
710 if !mod_path.exists() {
711 let kind = if has_service {
712 FeatureModKind::ControllerWithService
713 } else {
714 FeatureModKind::Controller
715 };
716 create_file(&mod_path, &render_feature_mod(feature, kind))?;
717 created.push(mod_path);
718 }
719
720 let mut output = format!(
721 "{}\n\n{}",
722 created_files(&created),
723 controller_instructions(feature, has_service)
724 );
725 if !has_service {
726 output.push_str(&format!(
727 "\n\nNote: src/{}/service.rs was not found, so {} was generated without a {} dependency.\n",
728 feature.module_name(),
729 feature.controller_type(),
730 feature.service_type()
731 ));
732 }
733 Ok(output)
734}
735
736fn generate_module(feature: &FeatureName, cwd: &Path) -> Result<String> {
737 let feature_dir = src_dir(cwd).join(feature.module_name());
738 let mod_path = feature_dir.join("mod.rs");
739 let service_path = feature_dir.join("service.rs");
740 let controller_path = feature_dir.join("controller.rs");
741
742 ensure_missing(&mod_path)?;
743 ensure_missing(&service_path)?;
744 ensure_missing(&controller_path)?;
745
746 fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
747 path: feature_dir.clone(),
748 source,
749 })?;
750 create_file(&mod_path, &render_feature_module(feature))?;
751 create_file(&service_path, &render_service(feature))?;
752 create_file(&controller_path, &render_controller(feature, true))?;
753
754 Ok(format!(
755 "{}\n\n{}",
756 created_files(&[mod_path, service_path, controller_path]),
757 module_instructions(feature)
758 ))
759}
760
761fn src_dir(cwd: &Path) -> PathBuf {
762 cwd.join("src")
763}
764
765fn package_name_for_path(path: &Path, fallback: &str) -> Result<String> {
766 let name = path
767 .file_name()
768 .and_then(|name| name.to_str())
769 .unwrap_or(fallback)
770 .trim();
771
772 if name.is_empty() {
773 return Err(CliError::InvalidName(fallback.to_string()));
774 }
775
776 Ok(name.to_kebab_case())
777}
778
779pub fn render_app_cargo_toml(package_name: &str) -> String {
780 render_app_cargo_toml_for_backend(package_name, BackendChoice::Actix)
781}
782
783fn render_app_cargo_toml_for_backend(package_name: &str, backend: BackendChoice) -> String {
784 let backend_dependencies = match backend {
785 BackendChoice::Actix => "actix-web = \"4.14.0\"\ncaelix = \"0.0.16\"",
786 BackendChoice::Axum => {
787 "caelix = { version = \"0.0.16\", default-features = false, features = [\"axum\", \"sqlx\", \"validator\"] }\ntower-http = { version = \"0.6\", features = [\"trace\", \"compression-full\"] }"
788 }
789 };
790 format!(
791 r#"[package]
792name = "{package_name}"
793version = "0.0.1"
794edition = "2024"
795
796[dependencies]
797{backend_dependencies}
798serde = {{ version = "1.0.228", features = ["derive"] }}
799"#
800 )
801}
802
803fn render_main_rs(crate_name: &str) -> String {
804 format!(
805 r#"use caelix::Application;
806use {crate_name}::AppModule;
807
808#[caelix::main]
809async fn main() -> std::io::Result<()> {{
810 Application::new::<AppModule>()
811 .await
812 .map_err(|err| std::io::Error::other(err.message))?
813 .listen("127.0.0.1:8080")
814 .await
815}}
816"#,
817 crate_name = crate_name
818 )
819}
820
821fn render_lib_rs() -> &'static str {
822 "pub mod app;\n\npub use app::AppModule;\n"
823}
824
825fn render_app_rs() -> &'static str {
826 r#"use caelix::{Module, ModuleMetadata};
827
828pub struct AppModule;
829
830impl Module for AppModule {
831 fn register() -> ModuleMetadata {
832 ModuleMetadata::new()
833 }
834}
835"#
836}
837
838fn render_agents_md() -> &'static str {
839 r#"# Agent Instructions
840
841This is a Caelix application. Use this file as the quick working reference when changing generated app code.
842
843For fuller documentation, refer to https://ohanronnie.github.io/caelix/.
844
845## App Structure
846
847- `src/main.rs` starts the selected Caelix runtime with `Application::new::<AppModule>()`.
848- `src/lib.rs` exports the root `AppModule` and should declare feature modules with `pub mod feature_name;`.
849- `src/app.rs` owns the root `AppModule`.
850- Feature folders usually contain `mod.rs`, `service.rs`, and `controller.rs`.
851- Prefer the Caelix CLI for new framework files: `caelix g module name`, `caelix g service name`, and `caelix g controller name`.
852
853## Registration Model
854
855Caelix uses explicit module metadata. Do not rely on filesystem discovery or hidden auto-registration.
856
857- A module implements `Module` and returns `ModuleMetadata`.
858- Add generated feature modules to `src/lib.rs`.
859- Import feature modules in `src/app.rs`.
860- Add `.import::<FeatureModule>()` inside `AppModule::register()`.
861- Register services with `.provider::<Service>()`.
862- Register controllers with `.controller::<Controller>()`.
863- Register async factory values with `.provider_async_factory::<T, _, _>(provider_dependencies![...], ...)` when construction cannot be expressed as `#[injectable]`.
864- 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.
865
866Example:
867
868```rust
869use caelix::{Module, ModuleMetadata};
870use crate::users::UsersModule;
871
872pub struct AppModule;
873
874impl Module for AppModule {
875 fn register() -> ModuleMetadata {
876 ModuleMetadata::new().import::<UsersModule>()
877 }
878}
879```
880
881## Providers And Injection
882
883Prefer `#[injectable]` for services, controllers, guards, and interceptors.
884
885- Injectable fields must be `Arc<T>`.
886- `Arc<Logger>` is provided automatically with the struct name as context.
887- Unit structs are valid injectables.
888- Tuple structs are not supported by `#[injectable]`.
889- Manual `Injectable` implementations are acceptable for custom async construction.
890- Lifecycle hooks can be implemented on providers: `on_module_init`, `on_bootstrap`, and `on_shutdown`.
891
892Example:
893
894```rust
895use std::sync::Arc;
896use caelix::{injectable, Logger};
897
898#[injectable]
899pub struct UsersService {
900 logger: Arc<Logger>,
901}
902```
903
904## Controllers
905
906Use `#[controller("/base-path")]` on an impl block. Route handlers are async methods.
907
908- Supported route attributes: `#[get]`, `#[post]`, `#[patch]`, `#[put]`, `#[delete]`.
909- Supported extractor attributes: `#[param]`, `#[body]`, `#[query]`, `#[user]`.
910- Add `#[validate]` to extracted DTOs that implement `validator::Validate`.
911- `#[user]` reads from `RequestContext`; missing users become `UnauthorizedException`.
912
913Example:
914
915```rust
916use std::sync::Arc;
917use caelix::{controller, get, injectable, Result};
918use super::UsersService;
919
920#[injectable]
921pub struct UsersController {
922 service: Arc<UsersService>,
923}
924
925#[controller("/users")]
926impl UsersController {
927 #[get("/{id}")]
928 async fn find_one(&self, #[param] id: String) -> Result<String> {
929 Ok(self.service.find_one(id).await?)
930 }
931}
932```
933
934## Guards, Interceptors, And Context
935
936- Guards implement `Guard` and return whether a request may continue.
937- Interceptors implement `Interceptor` and can wrap handler execution.
938- Apply them with `#[use_guard(Type)]` or `#[use_interceptor(Type)]` at controller or method level.
939- Controller-level guards/interceptors apply before method-level ones.
940- Use `RequestContext` for request method, path, headers, and per-request values such as authenticated users.
941
942## Responses And Errors
943
944Handlers should return values that implement `IntoCaelixResponse`.
945
946- `Result<String>` returns `200 text/plain`.
947- `Result<Response<T>>` is the usual JSON response path.
948- `Response::Body(value)` returns `200` JSON.
949- `Response::WithStatus(status, value)` returns JSON with a custom status.
950- `Response::json`, `Response::text`, and `Response::bytes` return explicit raw payloads.
951- `Response::stream`, `Response::sse`, and `Response::file` return streaming `HttpResponse` values.
952- `Response::no_content()` returns `204`.
953- Use Caelix exception types such as `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, and `InternalServerErrorException` for errors.
954- Server error messages are intentionally hidden from HTTP responses.
955
956## Cache
957
958Cache support is explicit service-level caching.
959
960- Import `CacheModule` into a module that needs cache support.
961- Inject `Arc<Cache>` into services that need cache reads/writes.
962- Do not add automatic HTTP response caching.
963- If the app needs response caching, implement it with an interceptor that reads from and writes to `Cache`.
964
965## Checks
966
967- Run `cargo test` after code changes when feasible.
968- Keep public app code using the `caelix` facade (`use caelix::...`) instead of internal Caelix crate paths.
969- When using CLI-generated files, keep the manual registration steps in the command output aligned with `src/lib.rs` and `src/app.rs`.
970"#
971}
972
973pub fn render_service(feature: &FeatureName) -> String {
974 let service = feature.service_type();
975 format!(
976 r#"use caelix::injectable;
977
978#[injectable]
979pub struct {service};
980
981impl {service} {{
982 pub fn hello(&self) -> String {{
983 "Hello from {service}".to_string()
984 }}
985}}
986"#
987 )
988}
989
990pub fn render_controller(feature: &FeatureName, has_service: bool) -> String {
991 let controller = feature.controller_type();
992 let route = feature.route_path();
993
994 if has_service {
995 let service = feature.service_type();
996 format!(
997 r#"use std::sync::Arc;
998
999use caelix::{{controller, get, injectable, Result}};
1000
1001use super::{service};
1002
1003#[injectable]
1004pub struct {controller} {{
1005 service: Arc<{service}>,
1006}}
1007
1008#[controller("/{route}")]
1009impl {controller} {{
1010 #[get("")]
1011 pub async fn hello(&self) -> Result<String> {{
1012 Ok(self.service.hello())
1013 }}
1014}}
1015"#
1016 )
1017 } else {
1018 format!(
1019 r#"use caelix::{{controller, get, injectable, Result}};
1020
1021#[injectable]
1022pub struct {controller};
1023
1024#[controller("/{route}")]
1025impl {controller} {{
1026 #[get("")]
1027 pub async fn hello(&self) -> Result<String> {{
1028 Ok("Hello from {controller}".to_string())
1029 }}
1030}}
1031"#
1032 )
1033 }
1034}
1035
1036#[derive(Clone, Copy)]
1037enum FeatureModKind {
1038 Service,
1039 Controller,
1040 ControllerWithService,
1041}
1042
1043fn render_feature_mod(feature: &FeatureName, kind: FeatureModKind) -> String {
1044 let service = feature.service_type();
1045 let controller = feature.controller_type();
1046
1047 match kind {
1048 FeatureModKind::Service => format!(
1049 r#"pub mod service;
1050
1051pub use service::{service};
1052"#
1053 ),
1054 FeatureModKind::Controller => format!(
1055 r#"pub mod controller;
1056
1057pub use controller::{controller};
1058"#
1059 ),
1060 FeatureModKind::ControllerWithService => format!(
1061 r#"pub mod controller;
1062pub mod service;
1063
1064pub use controller::{controller};
1065pub use service::{service};
1066"#
1067 ),
1068 }
1069}
1070
1071pub fn render_feature_module(feature: &FeatureName) -> String {
1072 let module = feature.module_type();
1073 let service = feature.service_type();
1074 let controller = feature.controller_type();
1075
1076 format!(
1077 r#"pub mod controller;
1078pub mod service;
1079
1080pub use controller::{controller};
1081pub use service::{service};
1082
1083use caelix::{{Module, ModuleMetadata}};
1084
1085pub struct {module};
1086
1087impl Module for {module} {{
1088 fn register() -> ModuleMetadata {{
1089 ModuleMetadata::new()
1090 .provider::<{service}>()
1091 .controller::<{controller}>()
1092 }}
1093}}
1094"#
1095 )
1096}
1097
1098fn service_instructions(feature: &FeatureName) -> String {
1099 format!(
1100 r#"Manual registration:
1101- Ensure `src/{}/mod.rs` contains `pub mod service;` and `pub use service::{};`.
1102- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
1103- Add `use crate::{}::{};` to the module that should own the service.
1104- Add `.provider::<{}>()` inside that module's `register()`."#,
1105 feature.module_name(),
1106 feature.service_type(),
1107 feature.module_name(),
1108 feature.module_name(),
1109 feature.service_type(),
1110 feature.service_type()
1111 )
1112}
1113
1114fn controller_instructions(feature: &FeatureName, has_service: bool) -> String {
1115 let mut instructions = format!(
1116 r#"Manual registration:
1117- Ensure `src/{}/mod.rs` contains `pub mod controller;` and `pub use controller::{};`.
1118- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
1119- Add `use crate::{}::{};` to the module that should own the controller.
1120- Add `.controller::<{}>()` inside that module's `register()`."#,
1121 feature.module_name(),
1122 feature.controller_type(),
1123 feature.module_name(),
1124 feature.module_name(),
1125 feature.controller_type(),
1126 feature.controller_type()
1127 );
1128
1129 if has_service {
1130 instructions.push_str(&format!(
1131 "\n- Ensure `{}` is registered as a provider in the same module.",
1132 feature.service_type()
1133 ));
1134 }
1135
1136 instructions
1137}
1138
1139fn module_instructions(feature: &FeatureName) -> String {
1140 format!(
1141 r#"Manual registration:
1142- Add `pub mod {};` to `src/lib.rs`.
1143- Add `use crate::{}::{};` to `src/app.rs`.
1144- Add `.import::<{}>()` inside `AppModule::register()`."#,
1145 feature.module_name(),
1146 feature.module_name(),
1147 feature.module_type(),
1148 feature.module_type()
1149 )
1150}
1151
1152fn created_files(paths: &[PathBuf]) -> String {
1153 let mut output = String::from("Created files:\n");
1154 for path in paths {
1155 output.push_str(&format!("- {}\n", path.display()));
1156 }
1157 output
1158}
1159
1160fn ensure_missing(path: &Path) -> Result<()> {
1161 if path.exists() {
1162 Err(CliError::AlreadyExists(path.to_path_buf()))
1163 } else {
1164 Ok(())
1165 }
1166}
1167
1168fn create_file(path: impl AsRef<Path>, contents: impl AsRef<str>) -> Result<()> {
1169 let path = path.as_ref();
1170 ensure_missing(path)?;
1171 fs::write(path, contents.as_ref()).map_err(|source| CliError::Io {
1172 path: path.to_path_buf(),
1173 source,
1174 })
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180 use tempfile::tempdir;
1181
1182 #[test]
1183 fn feature_name_converts_to_rust_and_route_names() {
1184 let feature = FeatureName::parse("auth-session").unwrap();
1185
1186 assert_eq!(feature.module_name(), "auth_session");
1187 assert_eq!(feature.route_path(), "auth-session");
1188 assert_eq!(feature.service_type(), "AuthSessionService");
1189 assert_eq!(feature.controller_type(), "AuthSessionController");
1190 assert_eq!(feature.module_type(), "AuthSessionModule");
1191 }
1192
1193 #[test]
1194 fn service_template_uses_injectable_struct() {
1195 let feature = FeatureName::parse("users").unwrap();
1196 let rendered = render_service(&feature);
1197
1198 assert!(rendered.contains("#[injectable]\npub struct UsersService;"));
1199 assert!(rendered.contains("Hello from UsersService"));
1200 }
1201
1202 #[test]
1203 fn controller_template_omits_service_when_missing() {
1204 let feature = FeatureName::parse("users").unwrap();
1205 let rendered = render_controller(&feature, false);
1206
1207 assert!(rendered.contains("pub struct UsersController;"));
1208 assert!(!rendered.contains("Arc<UsersService>"));
1209 assert!(rendered.contains("#[controller(\"/users\")]"));
1210 }
1211
1212 #[test]
1213 fn module_template_registers_provider_and_controller() {
1214 let feature = FeatureName::parse("users").unwrap();
1215 let rendered = render_feature_module(&feature);
1216
1217 assert!(rendered.contains("pub struct UsersModule;"));
1218 assert!(rendered.contains(".provider::<UsersService>()"));
1219 assert!(rendered.contains(".controller::<UsersController>()"));
1220 }
1221
1222 #[test]
1223 fn run_command_passes_app_args_after_separator() {
1224 let output = run_from(
1225 [
1226 "caelix",
1227 "run",
1228 "--watch",
1229 "--",
1230 "--rubbish-tes",
1231 "true",
1232 "--hshsh",
1233 "jsj",
1234 "--shshs",
1235 "q",
1236 "-h",
1237 "nnsjs",
1238 "dyg?",
1239 ],
1240 ".",
1241 )
1242 .unwrap();
1243
1244 assert_eq!(
1245 output,
1246 "cargo run -- --rubbish-tes true --hshsh jsj --shshs q -h nnsjs dyg?\n"
1247 );
1248 }
1249
1250 #[test]
1251 fn run_command_without_app_args_delegates_to_cargo_run() {
1252 let output = run_from(["caelix", "run"], ".").unwrap();
1253
1254 assert_eq!(output, "cargo run\n");
1255 }
1256
1257 #[test]
1258 fn update_caelix_version_preserves_cargo_toml_comments_and_other_dependencies() {
1259 let tmp = tempdir().unwrap();
1260 let path = tmp.path().join("Cargo.toml");
1261 fs::write(
1262 &path,
1263 r#"[package]
1264name = "demo"
1265version = "0.0.1"
1266
1267# Keep this dependency comment.
1268[dependencies]
1269actix-web = "4.14.0"
1270caelix = "0.0.2" # framework
1271serde = { version = "1.0", features = ["derive"] }
1272"#,
1273 )
1274 .unwrap();
1275
1276 let outcome = update_caelix_version(&path, "0.0.3").unwrap();
1277 let updated = fs::read_to_string(path).unwrap();
1278
1279 assert_eq!(
1280 outcome,
1281 UpdateOutcome::Updated {
1282 previous: "0.0.2".into(),
1283 latest: "0.0.3".into()
1284 }
1285 );
1286 assert!(updated.contains("# Keep this dependency comment."));
1287 assert!(updated.contains(r#"caelix = "0.0.3" # framework"#));
1288 assert!(updated.contains(r#"serde = { version = "1.0", features = ["derive"] }"#));
1289 }
1290
1291 #[test]
1292 fn update_caelix_version_preserves_inline_table_features() {
1293 let tmp = tempdir().unwrap();
1294 let path = tmp.path().join("Cargo.toml");
1295 fs::write(
1296 &path,
1297 r#"[package]
1298name = "demo"
1299version = "0.0.1"
1300
1301[dependencies]
1302caelix = { version = "0.0.2", default-features = false, features = ["actix"] }
1303"#,
1304 )
1305 .unwrap();
1306
1307 update_caelix_version(&path, "0.0.3").unwrap();
1308 let updated = fs::read_to_string(path).unwrap();
1309
1310 assert!(updated.contains(
1311 r#"caelix = { version = "0.0.3", default-features = false, features = ["actix"] }"#
1312 ));
1313 }
1314
1315 #[test]
1316 fn update_caelix_version_reports_already_latest_without_writing() {
1317 let tmp = tempdir().unwrap();
1318 let path = tmp.path().join("Cargo.toml");
1319 let content = r#"[dependencies]
1320caelix = "0.0.3"
1321"#;
1322 fs::write(&path, content).unwrap();
1323
1324 let outcome = update_caelix_version(&path, "0.0.3").unwrap();
1325
1326 assert_eq!(
1327 outcome,
1328 UpdateOutcome::AlreadyLatest {
1329 current: "0.0.3".into()
1330 }
1331 );
1332 assert_eq!(fs::read_to_string(path).unwrap(), content);
1333 }
1334}