Skip to main content

caelix_cli/
lib.rs

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