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