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