Skip to main content

caelix_cli/
lib.rs

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