Skip to main content

caelix_cli/
lib.rs

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