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