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