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