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