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