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    validate_project_name(&args.name)?;
616    let target_dir = cwd.join(&args.name);
617    ensure_missing(&target_dir)?;
618
619    let package_name = package_name_for_path(&target_dir, &args.name)?;
620    let crate_name = package_name.to_snake_case();
621
622    fs::create_dir_all(target_dir.join("src")).map_err(|source| CliError::Io {
623        path: target_dir.join("src"),
624        source,
625    })?;
626
627    let cargo_toml = render_app_cargo_toml_for_backend(&package_name, args.backend);
628    create_file(target_dir.join("Cargo.toml"), &cargo_toml)?;
629    create_file(target_dir.join("AGENTS.md"), render_agents_md())?;
630    create_file(target_dir.join("src/main.rs"), &render_main_rs(&crate_name))?;
631    create_file(target_dir.join("src/lib.rs"), render_lib_rs())?;
632    create_file(target_dir.join("src/app.rs"), render_app_rs())?;
633
634    Ok(format!(
635        "Created Caelix application `{}` in {}\n\nNext steps:\n- cd {}\n- caelix run\n",
636        package_name,
637        target_dir.display(),
638        target_dir.display()
639    ))
640}
641
642/// `caelix new` deliberately accepts a project *component*, never a path.
643/// Keeping this narrower than Cargo's package syntax prevents traversal and
644/// avoids generating identifiers that the generated Rust source cannot use.
645fn validate_project_name(name: &str) -> Result<()> {
646    const RUST_KEYWORDS: &[&str] = &[
647        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
648        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
649        "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
650        "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do",
651        "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
652    ];
653
654    let valid = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
655        && name
656            .bytes()
657            .skip(1)
658            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
659        && !RUST_KEYWORDS.contains(&name);
660    if valid {
661        Ok(())
662    } else {
663        Err(CliError::InvalidName(name.to_owned()))
664    }
665}
666
667fn generate_service(feature: &FeatureName, cwd: &Path) -> Result<String> {
668    let feature_dir = src_dir(cwd).join(feature.module_name());
669    let service_path = feature_dir.join("service.rs");
670    let mod_path = feature_dir.join("mod.rs");
671
672    ensure_missing(&service_path)?;
673    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
674        path: feature_dir.clone(),
675        source,
676    })?;
677    create_file(&service_path, &render_service(feature))?;
678
679    let mut created = vec![service_path];
680    if !mod_path.exists() {
681        create_file(
682            &mod_path,
683            &render_feature_mod(feature, FeatureModKind::Service),
684        )?;
685        created.push(mod_path);
686    }
687
688    Ok(format!(
689        "{}\n\n{}",
690        created_files(&created),
691        service_instructions(feature)
692    ))
693}
694
695fn generate_controller(feature: &FeatureName, cwd: &Path) -> Result<String> {
696    let feature_dir = src_dir(cwd).join(feature.module_name());
697    let service_path = feature_dir.join("service.rs");
698    let controller_path = feature_dir.join("controller.rs");
699    let mod_path = feature_dir.join("mod.rs");
700    let has_service = service_path.exists();
701
702    ensure_missing(&controller_path)?;
703    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
704        path: feature_dir.clone(),
705        source,
706    })?;
707    create_file(&controller_path, &render_controller(feature, has_service))?;
708
709    let mut created = vec![controller_path];
710    if !mod_path.exists() {
711        let kind = if has_service {
712            FeatureModKind::ControllerWithService
713        } else {
714            FeatureModKind::Controller
715        };
716        create_file(&mod_path, &render_feature_mod(feature, kind))?;
717        created.push(mod_path);
718    }
719
720    let mut output = format!(
721        "{}\n\n{}",
722        created_files(&created),
723        controller_instructions(feature, has_service)
724    );
725    if !has_service {
726        output.push_str(&format!(
727            "\n\nNote: src/{}/service.rs was not found, so {} was generated without a {} dependency.\n",
728            feature.module_name(),
729            feature.controller_type(),
730            feature.service_type()
731        ));
732    }
733    Ok(output)
734}
735
736fn generate_module(feature: &FeatureName, cwd: &Path) -> Result<String> {
737    let feature_dir = src_dir(cwd).join(feature.module_name());
738    let mod_path = feature_dir.join("mod.rs");
739    let service_path = feature_dir.join("service.rs");
740    let controller_path = feature_dir.join("controller.rs");
741
742    ensure_missing(&mod_path)?;
743    ensure_missing(&service_path)?;
744    ensure_missing(&controller_path)?;
745
746    fs::create_dir_all(&feature_dir).map_err(|source| CliError::Io {
747        path: feature_dir.clone(),
748        source,
749    })?;
750    create_file(&mod_path, &render_feature_module(feature))?;
751    create_file(&service_path, &render_service(feature))?;
752    create_file(&controller_path, &render_controller(feature, true))?;
753
754    Ok(format!(
755        "{}\n\n{}",
756        created_files(&[mod_path, service_path, controller_path]),
757        module_instructions(feature)
758    ))
759}
760
761fn src_dir(cwd: &Path) -> PathBuf {
762    cwd.join("src")
763}
764
765fn package_name_for_path(path: &Path, fallback: &str) -> Result<String> {
766    let name = path
767        .file_name()
768        .and_then(|name| name.to_str())
769        .unwrap_or(fallback)
770        .trim();
771
772    if name.is_empty() {
773        return Err(CliError::InvalidName(fallback.to_string()));
774    }
775
776    Ok(name.to_kebab_case())
777}
778
779pub fn render_app_cargo_toml(package_name: &str) -> String {
780    render_app_cargo_toml_for_backend(package_name, BackendChoice::Actix)
781}
782
783fn render_app_cargo_toml_for_backend(package_name: &str, backend: BackendChoice) -> String {
784    let backend_dependencies = match backend {
785        BackendChoice::Actix => "actix-web = \"4.14.0\"\ncaelix = \"0.0.17\"",
786        BackendChoice::Axum => {
787            "caelix = { version = \"0.0.17\", default-features = false, features = [\"axum\", \"sqlx\", \"validator\"] }\ntower-http = { version = \"0.6\", features = [\"trace\", \"compression-full\"] }"
788        }
789    };
790    format!(
791        r#"[package]
792name = "{package_name}"
793version = "0.0.1"
794edition = "2024"
795
796[dependencies]
797{backend_dependencies}
798serde = {{ version = "1.0.228", features = ["derive"] }}
799"#
800    )
801}
802
803fn render_main_rs(crate_name: &str) -> String {
804    format!(
805        r#"use caelix::Application;
806use {crate_name}::AppModule;
807
808#[caelix::main]
809async fn main() -> std::io::Result<()> {{
810    Application::new::<AppModule>()
811        .await
812        .map_err(|err| std::io::Error::other(err.message))?
813        .listen("127.0.0.1:8080")
814        .await
815}}
816"#,
817        crate_name = crate_name
818    )
819}
820
821fn render_lib_rs() -> &'static str {
822    "pub mod app;\n\npub use app::AppModule;\n"
823}
824
825fn render_app_rs() -> &'static str {
826    r#"use caelix::{Module, ModuleMetadata};
827
828pub struct AppModule;
829
830impl Module for AppModule {
831    fn register() -> ModuleMetadata {
832        ModuleMetadata::new()
833    }
834}
835"#
836}
837
838fn render_agents_md() -> &'static str {
839    r#"# Agent Instructions
840
841This is a Caelix application. Use this file as the quick working reference when changing generated app code.
842
843For fuller documentation, refer to https://ohanronnie.github.io/caelix/.
844
845## App Structure
846
847- `src/main.rs` starts the selected Caelix runtime with `Application::new::<AppModule>()`.
848- `src/lib.rs` exports the root `AppModule` and should declare feature modules with `pub mod feature_name;`.
849- `src/app.rs` owns the root `AppModule`.
850- Feature folders usually contain `mod.rs`, `service.rs`, and `controller.rs`.
851- Prefer the Caelix CLI for new framework files: `caelix g module name`, `caelix g service name`, and `caelix g controller name`.
852
853## Registration Model
854
855Caelix uses explicit module metadata. Do not rely on filesystem discovery or hidden auto-registration.
856
857- A module implements `Module` and returns `ModuleMetadata`.
858- Add generated feature modules to `src/lib.rs`.
859- Import feature modules in `src/app.rs`.
860- Add `.import::<FeatureModule>()` inside `AppModule::register()`.
861- Register services with `.provider::<Service>()`.
862- Register controllers with `.controller::<Controller>()`.
863- Register async factory values with `.provider_async_factory::<T, _, _>(provider_dependencies![...], ...)` when construction cannot be expressed as `#[injectable]`.
864- 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.
865
866Example:
867
868```rust
869use caelix::{Module, ModuleMetadata};
870use crate::users::UsersModule;
871
872pub struct AppModule;
873
874impl Module for AppModule {
875    fn register() -> ModuleMetadata {
876        ModuleMetadata::new().import::<UsersModule>()
877    }
878}
879```
880
881## Providers And Injection
882
883Prefer `#[injectable]` for services, controllers, guards, and interceptors.
884
885- Injectable fields must be `Arc<T>`.
886- `Arc<Logger>` is provided automatically with the struct name as context.
887- Unit structs are valid injectables.
888- Tuple structs are not supported by `#[injectable]`.
889- Manual `Injectable` implementations are acceptable for custom async construction.
890- Lifecycle hooks can be implemented on providers: `on_module_init`, `on_bootstrap`, and `on_shutdown`.
891
892Example:
893
894```rust
895use std::sync::Arc;
896use caelix::{injectable, Logger};
897
898#[injectable]
899pub struct UsersService {
900    logger: Arc<Logger>,
901}
902```
903
904## Controllers
905
906Use `#[controller("/base-path")]` on an impl block. Route handlers are async methods.
907
908- Supported route attributes: `#[get]`, `#[post]`, `#[patch]`, `#[put]`, `#[delete]`.
909- Supported extractor attributes: `#[param]`, `#[body]`, `#[query]`, `#[user]`.
910- Add `#[validate]` to extracted DTOs that implement `validator::Validate`.
911- `#[user]` reads from `RequestContext`; missing users become `UnauthorizedException`.
912
913Example:
914
915```rust
916use std::sync::Arc;
917use caelix::{controller, get, injectable, Result};
918use super::UsersService;
919
920#[injectable]
921pub struct UsersController {
922    service: Arc<UsersService>,
923}
924
925#[controller("/users")]
926impl UsersController {
927    #[get("/{id}")]
928    async fn find_one(&self, #[param] id: String) -> Result<String> {
929        Ok(self.service.find_one(id).await?)
930    }
931}
932```
933
934## Guards, Interceptors, And Context
935
936- Guards implement `Guard` and return whether a request may continue.
937- Interceptors implement `Interceptor` and can wrap handler execution.
938- Apply them with `#[use_guard(Type)]` or `#[use_interceptor(Type)]` at controller or method level.
939- Controller-level guards/interceptors apply before method-level ones.
940- Use `RequestContext` for request method, path, headers, and per-request values such as authenticated users.
941
942## Responses And Errors
943
944Handlers should return values that implement `IntoCaelixResponse`.
945
946- `Result<String>` returns `200 text/plain`.
947- `Result<Response<T>>` is the usual JSON response path.
948- `Response::Body(value)` returns `200` JSON.
949- `Response::WithStatus(status, value)` returns JSON with a custom status.
950- `Response::json`, `Response::text`, and `Response::bytes` return explicit raw payloads.
951- `Response::stream`, `Response::sse`, and `Response::file` return streaming `HttpResponse` values.
952- `Response::no_content()` returns `204`.
953- Use Caelix exception types such as `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, and `InternalServerErrorException` for errors.
954- Server error messages are intentionally hidden from HTTP responses.
955
956## Cache
957
958Cache support is explicit service-level caching.
959
960- Import `CacheModule` into a module that needs cache support.
961- Inject `Arc<Cache>` into services that need cache reads/writes.
962- Do not add automatic HTTP response caching.
963- If the app needs response caching, implement it with an interceptor that reads from and writes to `Cache`.
964
965## Checks
966
967- Run `cargo test` after code changes when feasible.
968- Keep public app code using the `caelix` facade (`use caelix::...`) instead of internal Caelix crate paths.
969- When using CLI-generated files, keep the manual registration steps in the command output aligned with `src/lib.rs` and `src/app.rs`.
970"#
971}
972
973pub fn render_service(feature: &FeatureName) -> String {
974    let service = feature.service_type();
975    format!(
976        r#"use caelix::injectable;
977
978#[injectable]
979pub struct {service};
980
981impl {service} {{
982    pub fn hello(&self) -> String {{
983        "Hello from {service}".to_string()
984    }}
985}}
986"#
987    )
988}
989
990pub fn render_controller(feature: &FeatureName, has_service: bool) -> String {
991    let controller = feature.controller_type();
992    let route = feature.route_path();
993
994    if has_service {
995        let service = feature.service_type();
996        format!(
997            r#"use std::sync::Arc;
998
999use caelix::{{controller, get, injectable, Result}};
1000
1001use super::{service};
1002
1003#[injectable]
1004pub struct {controller} {{
1005    service: Arc<{service}>,
1006}}
1007
1008#[controller("/{route}")]
1009impl {controller} {{
1010    #[get("")]
1011    pub async fn hello(&self) -> Result<String> {{
1012        Ok(self.service.hello())
1013    }}
1014}}
1015"#
1016        )
1017    } else {
1018        format!(
1019            r#"use caelix::{{controller, get, injectable, Result}};
1020
1021#[injectable]
1022pub struct {controller};
1023
1024#[controller("/{route}")]
1025impl {controller} {{
1026    #[get("")]
1027    pub async fn hello(&self) -> Result<String> {{
1028        Ok("Hello from {controller}".to_string())
1029    }}
1030}}
1031"#
1032        )
1033    }
1034}
1035
1036#[derive(Clone, Copy)]
1037enum FeatureModKind {
1038    Service,
1039    Controller,
1040    ControllerWithService,
1041}
1042
1043fn render_feature_mod(feature: &FeatureName, kind: FeatureModKind) -> String {
1044    let service = feature.service_type();
1045    let controller = feature.controller_type();
1046
1047    match kind {
1048        FeatureModKind::Service => format!(
1049            r#"pub mod service;
1050
1051pub use service::{service};
1052"#
1053        ),
1054        FeatureModKind::Controller => format!(
1055            r#"pub mod controller;
1056
1057pub use controller::{controller};
1058"#
1059        ),
1060        FeatureModKind::ControllerWithService => format!(
1061            r#"pub mod controller;
1062pub mod service;
1063
1064pub use controller::{controller};
1065pub use service::{service};
1066"#
1067        ),
1068    }
1069}
1070
1071pub fn render_feature_module(feature: &FeatureName) -> String {
1072    let module = feature.module_type();
1073    let service = feature.service_type();
1074    let controller = feature.controller_type();
1075
1076    format!(
1077        r#"pub mod controller;
1078pub mod service;
1079
1080pub use controller::{controller};
1081pub use service::{service};
1082
1083use caelix::{{Module, ModuleMetadata}};
1084
1085pub struct {module};
1086
1087impl Module for {module} {{
1088    fn register() -> ModuleMetadata {{
1089        ModuleMetadata::new()
1090            .provider::<{service}>()
1091            .controller::<{controller}>()
1092    }}
1093}}
1094"#
1095    )
1096}
1097
1098fn service_instructions(feature: &FeatureName) -> String {
1099    format!(
1100        r#"Manual registration:
1101- Ensure `src/{}/mod.rs` contains `pub mod service;` and `pub use service::{};`.
1102- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
1103- Add `use crate::{}::{};` to the module that should own the service.
1104- Add `.provider::<{}>()` inside that module's `register()`."#,
1105        feature.module_name(),
1106        feature.service_type(),
1107        feature.module_name(),
1108        feature.module_name(),
1109        feature.service_type(),
1110        feature.service_type()
1111    )
1112}
1113
1114fn controller_instructions(feature: &FeatureName, has_service: bool) -> String {
1115    let mut instructions = format!(
1116        r#"Manual registration:
1117- Ensure `src/{}/mod.rs` contains `pub mod controller;` and `pub use controller::{};`.
1118- Add `pub mod {};` to `src/lib.rs` if it is not already declared.
1119- Add `use crate::{}::{};` to the module that should own the controller.
1120- Add `.controller::<{}>()` inside that module's `register()`."#,
1121        feature.module_name(),
1122        feature.controller_type(),
1123        feature.module_name(),
1124        feature.module_name(),
1125        feature.controller_type(),
1126        feature.controller_type()
1127    );
1128
1129    if has_service {
1130        instructions.push_str(&format!(
1131            "\n- Ensure `{}` is registered as a provider in the same module.",
1132            feature.service_type()
1133        ));
1134    }
1135
1136    instructions
1137}
1138
1139fn module_instructions(feature: &FeatureName) -> String {
1140    format!(
1141        r#"Manual registration:
1142- Add `pub mod {};` to `src/lib.rs`.
1143- Add `use crate::{}::{};` to `src/app.rs`.
1144- Add `.import::<{}>()` inside `AppModule::register()`."#,
1145        feature.module_name(),
1146        feature.module_name(),
1147        feature.module_type(),
1148        feature.module_type()
1149    )
1150}
1151
1152fn created_files(paths: &[PathBuf]) -> String {
1153    let mut output = String::from("Created files:\n");
1154    for path in paths {
1155        output.push_str(&format!("- {}\n", path.display()));
1156    }
1157    output
1158}
1159
1160fn ensure_missing(path: &Path) -> Result<()> {
1161    if path.exists() {
1162        Err(CliError::AlreadyExists(path.to_path_buf()))
1163    } else {
1164        Ok(())
1165    }
1166}
1167
1168fn create_file(path: impl AsRef<Path>, contents: impl AsRef<str>) -> Result<()> {
1169    let path = path.as_ref();
1170    ensure_missing(path)?;
1171    fs::write(path, contents.as_ref()).map_err(|source| CliError::Io {
1172        path: path.to_path_buf(),
1173        source,
1174    })
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180    use tempfile::tempdir;
1181
1182    #[test]
1183    fn feature_name_converts_to_rust_and_route_names() {
1184        let feature = FeatureName::parse("auth-session").unwrap();
1185
1186        assert_eq!(feature.module_name(), "auth_session");
1187        assert_eq!(feature.route_path(), "auth-session");
1188        assert_eq!(feature.service_type(), "AuthSessionService");
1189        assert_eq!(feature.controller_type(), "AuthSessionController");
1190        assert_eq!(feature.module_type(), "AuthSessionModule");
1191    }
1192
1193    #[test]
1194    fn service_template_uses_injectable_struct() {
1195        let feature = FeatureName::parse("users").unwrap();
1196        let rendered = render_service(&feature);
1197
1198        assert!(rendered.contains("#[injectable]\npub struct UsersService;"));
1199        assert!(rendered.contains("Hello from UsersService"));
1200    }
1201
1202    #[test]
1203    fn controller_template_omits_service_when_missing() {
1204        let feature = FeatureName::parse("users").unwrap();
1205        let rendered = render_controller(&feature, false);
1206
1207        assert!(rendered.contains("pub struct UsersController;"));
1208        assert!(!rendered.contains("Arc<UsersService>"));
1209        assert!(rendered.contains("#[controller(\"/users\")]"));
1210    }
1211
1212    #[test]
1213    fn module_template_registers_provider_and_controller() {
1214        let feature = FeatureName::parse("users").unwrap();
1215        let rendered = render_feature_module(&feature);
1216
1217        assert!(rendered.contains("pub struct UsersModule;"));
1218        assert!(rendered.contains(".provider::<UsersService>()"));
1219        assert!(rendered.contains(".controller::<UsersController>()"));
1220    }
1221
1222    #[test]
1223    fn run_command_passes_app_args_after_separator() {
1224        let output = run_from(
1225            [
1226                "caelix",
1227                "run",
1228                "--watch",
1229                "--",
1230                "--rubbish-tes",
1231                "true",
1232                "--hshsh",
1233                "jsj",
1234                "--shshs",
1235                "q",
1236                "-h",
1237                "nnsjs",
1238                "dyg?",
1239            ],
1240            ".",
1241        )
1242        .unwrap();
1243
1244        assert_eq!(
1245            output,
1246            "cargo run -- --rubbish-tes true --hshsh jsj --shshs q -h nnsjs dyg?\n"
1247        );
1248    }
1249
1250    #[test]
1251    fn run_command_without_app_args_delegates_to_cargo_run() {
1252        let output = run_from(["caelix", "run"], ".").unwrap();
1253
1254        assert_eq!(output, "cargo run\n");
1255    }
1256
1257    #[test]
1258    fn update_caelix_version_preserves_cargo_toml_comments_and_other_dependencies() {
1259        let tmp = tempdir().unwrap();
1260        let path = tmp.path().join("Cargo.toml");
1261        fs::write(
1262            &path,
1263            r#"[package]
1264name = "demo"
1265version = "0.0.1"
1266
1267# Keep this dependency comment.
1268[dependencies]
1269actix-web = "4.14.0"
1270caelix = "0.0.2" # framework
1271serde = { version = "1.0", features = ["derive"] }
1272"#,
1273        )
1274        .unwrap();
1275
1276        let outcome = update_caelix_version(&path, "0.0.3").unwrap();
1277        let updated = fs::read_to_string(path).unwrap();
1278
1279        assert_eq!(
1280            outcome,
1281            UpdateOutcome::Updated {
1282                previous: "0.0.2".into(),
1283                latest: "0.0.3".into()
1284            }
1285        );
1286        assert!(updated.contains("# Keep this dependency comment."));
1287        assert!(updated.contains(r#"caelix = "0.0.3" # framework"#));
1288        assert!(updated.contains(r#"serde = { version = "1.0", features = ["derive"] }"#));
1289    }
1290
1291    #[test]
1292    fn update_caelix_version_preserves_inline_table_features() {
1293        let tmp = tempdir().unwrap();
1294        let path = tmp.path().join("Cargo.toml");
1295        fs::write(
1296            &path,
1297            r#"[package]
1298name = "demo"
1299version = "0.0.1"
1300
1301[dependencies]
1302caelix = { version = "0.0.2", default-features = false, features = ["actix"] }
1303"#,
1304        )
1305        .unwrap();
1306
1307        update_caelix_version(&path, "0.0.3").unwrap();
1308        let updated = fs::read_to_string(path).unwrap();
1309
1310        assert!(updated.contains(
1311            r#"caelix = { version = "0.0.3", default-features = false, features = ["actix"] }"#
1312        ));
1313    }
1314
1315    #[test]
1316    fn update_caelix_version_reports_already_latest_without_writing() {
1317        let tmp = tempdir().unwrap();
1318        let path = tmp.path().join("Cargo.toml");
1319        let content = r#"[dependencies]
1320caelix = "0.0.3"
1321"#;
1322        fs::write(&path, content).unwrap();
1323
1324        let outcome = update_caelix_version(&path, "0.0.3").unwrap();
1325
1326        assert_eq!(
1327            outcome,
1328            UpdateOutcome::AlreadyLatest {
1329                current: "0.0.3".into()
1330            }
1331        );
1332        assert_eq!(fs::read_to_string(path).unwrap(), content);
1333    }
1334}