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