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