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