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