Skip to main content

caelix_cli/
lib.rs

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