Skip to main content

caelix_cli/
lib.rs

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