use crate::cli::MakeOptions;
use crate::error::CommandError;
use crate::project;
use super::command;
use super::controller;
use super::event;
use super::job;
use super::listener;
use super::mail;
use super::middleware;
use super::module;
use super::plan::{OverwritePolicy, PlanError};
use super::policy;
use super::request;
use super::resource;
use super::service;
use super::test;
use super::write;
pub(crate) const KINDS: &[&str] = &[
"module",
"controller",
"request",
"service",
"policy",
"middleware",
"event",
"listener",
"job",
"command",
"resource",
"test",
"mail",
];
const TRANSACTIONAL_KINDS: &[&str] = &["mail", "test"];
pub(crate) fn execute(options: MakeOptions) -> Result<(), CommandError> {
let project = project::discover()?;
let root = project.root().to_path_buf();
let backend_src_dir = project.backend_src_dir.to_string_lossy().into_owned();
dispatch(&root, &backend_src_dir, &options).map_err(CommandError::Metadata)
}
fn dispatch(
root: &std::path::Path,
backend_src_dir: &str,
options: &MakeOptions,
) -> Result<(), String> {
if !KINDS.contains(&options.kind.as_str()) {
return Err(format!(
"unknown generator kind `{}`; expected one of: {}",
options.kind,
KINDS.join(", ")
));
}
let src_root = root.join(backend_src_dir);
let is_transactional = TRANSACTIONAL_KINDS.contains(&options.kind.as_str());
if !is_transactional && (options.dry_run || options.force) {
return Err(format!(
"the `{}` generator does not support --dry-run / --force yet; \
only the transactional kinds ({}) do. Per-file generators always \
refuse to overwrite an existing file.",
options.kind,
TRANSACTIONAL_KINDS.join(", ")
));
}
let module = options.module.as_deref();
match options.kind.as_str() {
"module" => {
let file = module::generate(&src_root, &options.name)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!(
"next: add `pub mod {};` to {backend_src_dir}/lib.rs and register it in your `application!`",
options.name
);
}
"controller" => {
let module = require_module(module)?;
let (file, mod_msg) = controller::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"request" => {
let module = require_module(module)?;
let (file, mod_msg) = request::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"service" => {
let module = require_module(module)?;
let (file, mod_msg) = service::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"policy" => {
let module = require_module(module)?;
let (file, mod_msg) = policy::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"middleware" => {
let module = require_module(module)?;
let (file, mod_msg) = middleware::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"event" => {
let module = require_module(module)?;
let (file, mod_msg) = event::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"listener" => {
let module = require_module(module)?;
let (file, mod_msg) = listener::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"job" => {
let module = require_module(module)?;
let (file, mod_msg) = job::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"command" => {
let module = require_module(module)?;
let (file, mod_msg) = command::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"resource" => {
let module = require_module(module)?;
let (file, mod_msg) = resource::generate(&src_root, &options.name, module)?;
let msg = write::write_file(&file)?;
println!("{msg}");
println!("{mod_msg}");
}
"test" => run_plan(test::plan(root, &options.name)?, options)?,
"mail" => {
let module = require_module(module)?;
run_plan(mail::plan(&src_root, &options.name, module)?, options)?;
}
_ => {
return Err(format!(
"generator kind `{}` is not implemented in this phase",
options.kind
));
}
}
Ok(())
}
fn run_plan(plan_result: (super::plan::Plan, String), options: &MakeOptions) -> Result<(), String> {
let (mut plan, _stem) = plan_result;
let overwrite = if options.force {
OverwritePolicy::Overwrite
} else {
OverwritePolicy::Refuse
};
if options.dry_run {
let report = plan.dry_run_report(overwrite).map_err(|e| e.to_string())?;
println!("{report}");
return Ok(());
}
match plan.execute(overwrite, None) {
Ok(written) => {
for path in &written {
println!("created {}", path.display());
}
Ok(())
}
Err(PlanError::Conflict(path)) => Err(format!(
"refusing to overwrite existing file: {}; pass --force to overwrite",
path.display()
)),
Err(error) => Err(error.to_string()),
}
}
fn require_module(module: Option<&str>) -> Result<&str, String> {
module
.ok_or_else(|| "this generator requires --module <module> (e.g. --module links)".to_owned())
}
#[cfg(test)]
mod tests {
use super::super::naming;
use super::*;
use std::fs;
use std::path::PathBuf;
fn temp_root(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"arcature-cli-make-{label}-{}-{}",
std::process::id(),
unique_suffix()
));
fs::create_dir_all(&root).expect("temp root should be created");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
root
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
#[test]
fn module_generator_creates_mod_rs_with_module_macro() {
let root = temp_root("module");
let src_root = root.join("src");
let file = module::generate(&src_root, "links").expect("module should generate");
assert!(file.path.ends_with("src/links/mod.rs"));
assert!(file.content.contains("module!"));
assert!(file.content.contains("Links"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn module_generator_uses_pascal_case_for_macro_name() {
let root = temp_root("pascal");
let src_root = root.join("src");
let file = module::generate(&src_root, "user_accounts").expect("should generate");
assert!(file.content.contains("UserAccounts"));
assert!(!file.content.contains("user_accounts {"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn module_generator_rejects_invalid_name() {
let root = temp_root("bad-module");
let src_root = root.join("src");
assert!(
module::generate(&src_root, "Links").is_err(),
"uppercase should fail"
);
assert!(
module::generate(&src_root, "").is_err(),
"empty should fail"
);
assert!(
module::generate(&src_root, "../escape").is_err(),
"path should fail"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn controller_generator_creates_file_and_mod_declaration() {
let root = temp_root("controller");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("links")).expect("module dir");
let (file, mod_msg) =
controller::generate(&src_root, "Links", "links").expect("controller should generate");
assert!(file.path.ends_with("src/links/links_controller.rs"));
assert!(
file.content
.contains("#[arcature::arcature_dx::controller]")
);
assert!(file.content.contains("LinksController"));
assert!(mod_msg.contains("links_controller"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn controller_generator_appends_suffix_if_missing() {
let root = temp_root("controller-suffix");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("accounts")).expect("module dir");
let (file, _) =
controller::generate(&src_root, "Sessions", "accounts").expect("should generate");
assert!(file.content.contains("SessionsController"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn request_generator_creates_request_struct() {
let root = temp_root("request");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("accounts")).expect("module dir");
let (file, _) =
request::generate(&src_root, "Login", "accounts").expect("request should generate");
assert!(file.path.ends_with("src//accounts/login_request.rs"));
assert!(file.content.contains("#[arcature::arcature_dx::request]"));
assert!(file.content.contains("LoginRequest"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn service_generator_creates_service_struct() {
let root = temp_root("service");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("links")).expect("module dir");
let (file, _) =
service::generate(&src_root, "Link", "links").expect("service should generate");
assert!(file.path.ends_with("src/links/link_service.rs"));
assert!(file.content.contains("#[arcature::service]"));
assert!(file.content.contains("LinkService"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn policy_generator_creates_policy_with_model() {
let root = temp_root("policy");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("links")).expect("module dir");
let (file, _) =
policy::generate(&src_root, "Link", "links").expect("policy should generate");
assert!(file.content.contains("#[arcature::policy(Link)]"));
assert!(file.content.contains("LinkPolicy"));
assert!(file.content.contains("impl Policy<Link>"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn middleware_generator_creates_middleware_fn() {
let root = temp_root("middleware");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("auth")).expect("module dir");
let (file, _) = middleware::generate(&src_root, "require_token", "auth")
.expect("middleware should generate");
assert!(file.path.ends_with("src/auth/require_token.rs"));
assert!(file.content.contains("#[arcature::middleware]"));
assert!(file.content.contains("require_token"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn event_generator_creates_event_struct() {
let root = temp_root("event");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("accounts")).expect("module dir");
let (file, _) = event::generate(&src_root, "UserRegistered", "accounts")
.expect("event should generate");
assert!(file.content.contains(
"#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, arcature::Event)]"
));
assert!(file.content.contains("UserRegistered"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn listener_generator_creates_listener_fn() {
let root = temp_root("listener");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("accounts")).expect("module dir");
let (file, _) = listener::generate(&src_root, "send_welcome", "accounts")
.expect("listener should generate");
assert!(file.path.ends_with("src/accounts/send_welcome.rs"));
assert!(file.content.contains("#[arcature::listener(YourEvent)]"));
assert!(file.content.contains("send_welcome"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn job_generator_creates_job_and_handler() {
let root = temp_root("job");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("mail")).expect("module dir");
let (file, _) = job::generate(&src_root, "SendEmail", "mail").expect("job should generate");
assert!(file.content.contains("#[derive(Job"));
assert!(file.content.contains("SendEmail"));
assert!(file.content.contains("handle_send_email"));
assert!(file.content.contains("#[arcature::job_handler]"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn command_generator_creates_command_fn() {
let root = temp_root("command");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("users")).expect("module dir");
let (file, _) =
command::generate(&src_root, "prune_users", "users").expect("command should generate");
assert!(file.content.contains("#[arcature::command("));
assert!(file.content.contains("prune_users"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn resource_generator_creates_routes() {
let root = temp_root("resource");
let src_root = root.join("src");
fs::create_dir_all(src_root.join("links")).expect("module dir");
let (file, _) =
resource::generate(&src_root, "Links", "links").expect("resource should generate");
assert!(file.content.contains("resource"));
assert!(file.content.contains("LinksController"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn test_generator_plan_targets_links_test() {
let root = temp_root("test");
let (plan, stem) = test::plan(&root, "Links").expect("test plan should build");
assert_eq!(stem, "links_test");
let ops = plan.ops();
assert_eq!(ops.len(), 1);
match &ops[0] {
super::super::plan::PlannedOp::Create { path, content } => {
assert!(path.ends_with("tests/links_test.rs"));
assert!(content.contains("links_smoke"));
}
_ => panic!("expected a Create op"),
}
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn write_file_refuses_overwrite() {
let root = temp_root("overwrite");
let src_root = root.join("src");
let dir = src_root.join("links");
fs::create_dir_all(&dir).expect("dir");
fs::write(dir.join("mod.rs"), "existing").expect("seed existing file");
let result = module::generate(&src_root, "links");
assert!(result.is_err(), "should refuse to overwrite");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_rejects_unknown_kind() {
let root = temp_root("unknown-kind");
let options = MakeOptions {
kind: "migration".to_owned(),
name: "foo".to_owned(),
module: None,
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unknown generator kind"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_requires_module_for_module_scoped_generators() {
let root = temp_root("no-module");
let options = MakeOptions {
kind: "controller".to_owned(),
name: "Links".to_owned(),
module: None,
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_err());
assert!(result.unwrap_err().contains("--module"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_module_kind_does_not_require_module_flag() {
let root = temp_root("module-no-flag");
let options = MakeOptions {
kind: "module".to_owned(),
name: "accounts".to_owned(),
module: None,
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_ok(), "module kind should not need --module");
assert!(root.join("src/accounts/mod.rs").is_file());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_test_kind_does_not_require_module_flag() {
let root = temp_root("test-no-flag");
let options = MakeOptions {
kind: "test".to_owned(),
name: "Links".to_owned(),
module: None,
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_ok(), "test kind should not need --module");
assert!(root.join("tests/links_test.rs").is_file());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_test_kind_dry_run_creates_nothing() {
let root = temp_root("test-dry-run");
let options = MakeOptions {
kind: "test".to_owned(),
name: "Links".to_owned(),
module: None,
dry_run: true,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_ok(), "dry-run should succeed");
assert!(
!root.join("tests").exists(),
"dry-run must not create directories"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_mail_kind_requires_module_and_writes_file() {
let root = temp_root("mail-commit");
let options = MakeOptions {
kind: "mail".to_owned(),
name: "Welcome".to_owned(),
module: Some("accounts".to_owned()),
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_ok(), "mail kind should succeed with --module");
assert!(
root.join("src")
.join("accounts")
.join("welcome_mail.rs")
.is_file()
);
assert!(root.join("src").join("accounts").join("mod.rs").is_file());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_mail_kind_requires_module_flag() {
let root = temp_root("mail-no-module");
let options = MakeOptions {
kind: "mail".to_owned(),
name: "Welcome".to_owned(),
module: None,
dry_run: false,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_err());
assert!(result.unwrap_err().contains("--module"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dispatch_per_file_kind_rejects_dry_run_and_force() {
let root = temp_root("per-file-flags");
let options = MakeOptions {
kind: "controller".to_owned(),
name: "Links".to_owned(),
module: Some("links".to_owned()),
dry_run: true,
force: false,
};
let result = dispatch(&root, "src", &options);
assert!(result.is_err(), "per-file kind should reject --dry-run");
assert!(result.unwrap_err().contains("does not support --dry-run"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn append_mod_declaration_is_idempotent() {
let root = temp_root("idempotent");
let mod_rs = root.join("src").join("links").join("mod.rs");
fs::create_dir_all(mod_rs.parent().unwrap()).expect("dir");
fs::write(&mod_rs, "pub mod existing;\n").expect("seed");
let msg1 =
naming::append_mod_declaration(&mod_rs, "pub mod new_thing;").expect("first append");
assert!(msg1.contains("declared"));
let msg2 =
naming::append_mod_declaration(&mod_rs, "pub mod new_thing;").expect("second append");
assert_eq!(msg2, "already declared");
let content = fs::read_to_string(&mod_rs).expect("read");
assert_eq!(
content.matches("pub mod new_thing;").count(),
1,
"declaration should appear exactly once"
);
fs::remove_dir_all(root).expect("cleanup");
}
}