use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
type Result<T> = std::result::Result<T, String>;
fn names(input: &str) -> Result<(String, String)> {
if input.is_empty()
|| !input
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
|| !input.as_bytes()[0].is_ascii_lowercase()
{
return Err(
"use a lowercase name starting with a letter, such as user or user-profile".into(),
);
}
let snake = input.replace('-', "_");
if snake.split('_').any(str::is_empty)
|| matches!(
snake.as_str(),
"self"
| "super"
| "crate"
| "mod"
| "type"
| "fn"
| "pub"
| "use"
| "impl"
| "struct"
| "enum"
| "trait"
| "async"
| "await"
| "loop"
| "match"
| "move"
| "ref"
| "let"
| "const"
| "static"
| "where"
| "in"
| "for"
| "if"
| "else"
| "return"
| "break"
| "continue"
| "true"
| "false"
| "unsafe"
| "extern"
| "dyn"
| "as"
| "mut"
| "while"
| "union"
| "abstract"
| "become"
| "box"
| "do"
| "final"
| "macro"
| "override"
| "priv"
| "typeof"
| "unsized"
| "virtual"
| "yield"
| "try"
| "gen"
)
{
return Err("name must be a valid non-keyword Rust module name".into());
}
let pascal = snake
.split('_')
.map(|s| format!("{}{}", s[..1].to_uppercase(), &s[1..]))
.collect();
Ok((snake, pascal))
}
fn write(path: &Path, content: &str) -> Result<()> {
fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
}
fn format_files(root: &Path, paths: &[String]) -> Result<()> {
let status = Command::new("rustfmt")
.current_dir(root)
.args(["--edition", "2024", "--config", "skip_children=true"])
.args(paths)
.status()
.map_err(|error| {
format!("files generated but formatting failed: {error}; run cargo fmt")
})?;
if !status.success() {
return Err("files generated but formatting failed; run cargo fmt".into());
}
Ok(())
}
pub fn new_project(name: &str, runtime: Option<&Path>) -> Result<()> {
names(name)?;
let root = PathBuf::from(name);
if root.exists() {
return Err(format!("{} already exists", root.display()));
}
let dependency = if let Some(runtime) = runtime {
let runtime = runtime
.canonicalize()
.map_err(|e| format!("runtime path: {e}; supply --runtime-path"))?;
if !runtime.join("Cargo.toml").is_file() {
return Err("runtime path must contain xecute/Cargo.toml".into());
}
let runtime = runtime
.to_str()
.ok_or("runtime path must be UTF-8")?
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!("xecute = {{ path = \"{runtime}\" }}")
} else {
"xecute = \"0.1.1\"".to_owned()
};
fs::create_dir_all(root.join("src")).map_err(|e| e.to_string())?;
write(
&root.join("Cargo.toml"),
&include_str!("../templates/Cargo.toml.tmpl")
.replace("__PROJECT_NAME__", name)
.replace("__RUNTIME_DEPENDENCY__", &dependency),
)?;
write(&root.join(".gitignore"), "/target/\n.env\n")?;
let mut formatted = Vec::new();
for (path, template) in [
("src/main.rs", include_str!("../templates/main.rs")),
(
"src/configs/mod.rs",
include_str!("../templates/configs/mod.rs"),
),
(
"src/configs/server.rs",
include_str!("../templates/configs/server.rs"),
),
(
"src/server/mod.rs",
include_str!("../templates/server/mod.rs"),
),
(
"src/server/bootstrap.rs",
include_str!("../templates/server/bootstrap.rs"),
),
(
"src/server/error.rs",
include_str!("../templates/server/error.rs"),
),
(
"src/server/health.rs",
include_str!("../templates/server/health.rs"),
),
(
"src/server/rate_limit.rs",
include_str!("../templates/server/rate_limit.rs"),
),
(
"src/server/router.rs",
include_str!("../templates/server/router.rs"),
),
(
"src/server/shutdown.rs",
include_str!("../templates/server/shutdown.rs"),
),
(
"src/server/logging.rs",
include_str!("../templates/server/logging.rs"),
),
(
"src/server/middleware/mod.rs",
include_str!("../templates/server/middleware/mod.rs"),
),
(
"src/server/middleware/guard.rs",
include_str!("../templates/server/middleware/guard.rs"),
),
(
"src/server/middleware/request_log.rs",
include_str!("../templates/server/middleware/request_log.rs"),
),
(
"src/server/middleware/response.rs",
include_str!("../templates/server/middleware/response.rs"),
),
(
"src/tests/mod.rs",
include_str!("../templates/tests/mod.rs"),
),
(
"src/tests/config.rs",
include_str!("../templates/tests/config.rs"),
),
(
"src/tests/logging.rs",
include_str!("../templates/tests/logging.rs"),
),
(
"src/tests/rate_limit.rs",
include_str!("../templates/tests/rate_limit.rs"),
),
(
"src/tests/server.rs",
include_str!("../templates/tests/server.rs"),
),
(
"src/tests/support.rs",
include_str!("../templates/tests/support.rs"),
),
(
"src/configs/logging.rs",
include_str!("../templates/configs/logging.rs"),
),
(
"src/configs/cors.rs",
include_str!("../templates/configs/cors.rs"),
),
(".env.example", include_str!("../templates/env.example")),
(
"rust-toolchain.toml",
include_str!("../templates/rust-toolchain.toml"),
),
("rustfmt.toml", include_str!("../templates/rustfmt.toml")),
("clippy.toml", include_str!("../templates/clippy.toml")),
(".editorconfig", include_str!("../templates/editorconfig")),
(".dockerignore", include_str!("../templates/dockerignore")),
("Dockerfile", include_str!("../templates/Dockerfile")),
(
".github/workflows/ci.yml",
include_str!("../templates/ci.yml"),
),
("deny.toml", include_str!("../templates/deny.toml")),
(".gitattributes", include_str!("../templates/gitattributes")),
(
".github/dependabot.yml",
include_str!("../templates/dependabot.yml"),
),
] {
if path.ends_with(".rs") {
formatted.push(path.to_owned());
}
let path = root.join(path);
fs::create_dir_all(path.parent().expect("template parent")).map_err(|e| e.to_string())?;
write(&path, &template.replace("__PROJECT_NAME__", name))?;
}
write(
&root.join("src/app_module.rs"),
include_str!("../templates/app_module.rs"),
)?;
write(
&root.join("README.md"),
&include_str!("../templates/readme.md").replace("__PROJECT_NAME__", name),
)?;
if runtime.is_none() {
write(
&root.join("Cargo.lock"),
&include_str!("../templates/project.lock").replace("__PROJECT_NAME__", name),
)?;
}
formatted.push("src/app_module.rs".into());
format_files(&root, &formatted)?;
println!("Created {name}\n cd {name}\n xecute g resource user\n cargo run");
Ok(())
}
pub fn component(root: &Path, kind: &str, name: &str) -> Result<()> {
let kind = match kind {
"resource" | "res" => "resource",
"module" | "mo" => "module",
"controller" | "co" => "controller",
"service" | "s" => "service",
_ => return Err("generator must be resource, module, controller, or service".into()),
};
let (snake, pascal) = names(name)?;
if matches!(snake.as_str(), "app_module" | "main") {
return Err("name conflicts with application files".into());
}
let main_path = root.join("src/main.rs");
let app_path = root.join("src/app_module.rs");
let mut main =
fs::read_to_string(&main_path).map_err(|_| "run generate from a Xecute project root")?;
let mut app = fs::read_to_string(&app_path).map_err(|_| "missing src/app_module.rs")?;
let folder = root.join("src").join(&snake);
if folder.exists() {
return Err(format!(
"{} already exists; no files changed",
folder.display()
));
}
if kind == "resource" && root.join(format!("src/tests/{snake}.rs")).exists() {
return Err("resource test already exists; no files changed".into());
}
let markers = [
"// xecute:modules",
"// xecute:imports",
"// xecute:definitions",
"// xecute:routes",
];
if markers.iter().any(|m| app.matches(m).count() != 1) {
return Err(
"app_module.rs must have the registration markers from xecute new; no files changed"
.into(),
);
}
if main.contains(&format!("mod {snake};")) {
return Err("module already declared; no files changed".into());
}
let service = format!(
"//! Injectable business logic for the {snake} feature.\n\nuse xecute::injectable;\n\n/// Singleton service constructed by the dependency-injection container.\n#[injectable]\npub struct {pascal}Service {{}}\n\nimpl {pascal}Service {{\n /// Collection skeleton; replace with validated application or repository logic.\n pub fn list(&self) -> Vec<String> {{ Vec::new() }}\n}}\n"
);
let controller = if kind == "resource" {
format!(
"//! HTTP endpoints for the {snake} feature; business logic lives in the injected service.\n\nuse std::sync::Arc;\nuse axum::{{extract::State, Json}};\nuse super::service::{pascal}Service;\nuse xecute::{{controller, injectable}};\n\n/// Injectable HTTP controller.\n#[injectable]\npub struct {pascal}Controller {{ service: Arc<{pascal}Service> }}\n\n// The macro combines this prefix with method paths and generates router().\n#[controller(\"/{snake}\")]\nimpl {pascal}Controller {{\n /// GET collection endpoint; its generated router injects controller state.\n #[get(\"\")]\n async fn list(State(controller): State<Arc<Self>>) -> Json<Vec<String>> {{\n Json(controller.service.list())\n }}\n}}\n"
)
} else {
format!(
"//! Standalone {snake} HTTP endpoints; register the controller in a module to expose them.\n\nuse axum::Json;\nuse xecute::{{controller, injectable}};\n\n/// Injectable HTTP controller.\n#[injectable]\npub struct {pascal}Controller {{}}\n\n// The macro combines this prefix with method paths and generates router().\n#[controller(\"/{snake}\")]\nimpl {pascal}Controller {{\n /// GET collection endpoint; its generated router injects controller state.\n #[get(\"\")]\n async fn list() -> Json<Vec<String>> {{ Json(Vec::new()) }}\n}}\n"
)
};
let mut files: Vec<(String, String)> = Vec::new();
let mut mods = format!("//! Public building blocks for the {snake} feature.\n\n");
if matches!(kind, "resource" | "service") {
files.push(("service.rs".into(), service));
mods.push_str(&format!(
"mod service;\npub use service::{pascal}Service;\n"
));
}
if matches!(kind, "resource" | "controller") {
files.push(("controller.rs".into(), controller));
mods.push_str(&format!(
"mod controller;\npub use controller::{pascal}Controller;\n"
));
}
if matches!(kind, "resource" | "module") {
let module = if kind == "resource" {
format!(
"//! Provider registrations and exports for the {snake} feature.\n//! Controllers are also providers so their service dependencies can be resolved.\n\nuse super::{{{pascal}Controller, {pascal}Service}};\nuse xecute::module;\n\n/// Resource dependency-injection module.\n#[module(providers = [{pascal}Service, {pascal}Controller], controllers = [{pascal}Controller], exports = [{pascal}Service])]\npub struct {pascal}Module;\n"
)
} else {
format!(
"//! Dependency-injection boundary for the {snake} feature.\n//! Add injectable services and controllers to providers as the feature grows.\n\nuse xecute::module;\n\n/// Dependency-injection module.\n#[module(providers = [])]\npub struct {pascal}Module;\n"
)
};
files.push(("module.rs".into(), module));
mods.push_str(&format!("mod module;\npub use module::{pascal}Module;\n"));
app = app
.replace(
markers[0],
&format!("use crate::{snake}::{pascal}Module;\n{}", markers[0]),
)
.replace(markers[1], &format!("{pascal}Module,\n {}", markers[1]))
.replace(
markers[2],
&format!("{pascal}Module::definition(),\n {}", markers[2]),
);
if kind == "resource" {
app = app.replace(markers[3], &format!("let router = router.merge(crate::{snake}::{pascal}Controller::router(container.resolve::<crate::{snake}::{pascal}Controller>()?));\n {}", markers[3]));
}
}
if kind == "resource" {
files.retain(|(name, _)| !matches!(name.as_str(), "service.rs" | "controller.rs"));
for (name, template) in [
(
"service.rs",
include_str!("../templates/resource/service.rs.tmpl"),
),
(
"controller.rs",
include_str!("../templates/resource/controller.rs.tmpl"),
),
(
"dto/mod.rs",
include_str!("../templates/resource/dto.rs.tmpl"),
),
(
"entity/mod.rs",
include_str!("../templates/resource/entity.rs.tmpl"),
),
(
"repository/store.rs",
include_str!("../templates/resource/repository.rs.tmpl"),
),
] {
files.push((
name.into(),
template
.replace("__TYPE__", &pascal)
.replace("__FEATURE__", &snake),
));
}
files.push(("repository/mod.rs".into(), format!("//! Persistence adapters for this feature.\n\nmod store;\npub use store::{{{pascal}Repository, CapacityExceeded}};\n")));
mods.push_str("\npub mod dto;\npub mod entity;\npub mod repository;\n");
for (name, content) in &mut files {
if name == "module.rs" {
*content = content
.replace(
"use super::{",
&format!("use super::repository::{pascal}Repository;\nuse super::{{"),
)
.replace(
"providers = [",
&format!("providers = [{pascal}Repository, "),
);
}
}
}
files.push(("mod.rs".into(), mods));
let declaration = format!("pub mod {snake};\n");
if let Some(index) = main.find("mod app_module;") {
main.insert_str(index, &declaration);
} else {
main.push_str(&format!("\n{declaration}"));
}
if kind == "resource" {
let manifest_path = root.join("Cargo.toml");
if manifest_path.exists() {
let mut manifest = fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?;
if !manifest.lines().any(|line| {
line.trim_start().starts_with("garde ") || line.trim_start().starts_with("garde=")
}) {
if !manifest.contains("[dependencies]") {
return Err("Cargo.toml is missing [dependencies]".into());
}
manifest = manifest.replacen(
"[dependencies]",
"[dependencies]\ngarde = { version = \"0.23\", features = [\"derive\"] }",
1,
);
write(&manifest_path, &manifest)?;
println!("Added garde validation dependency; cargo build will refresh Cargo.lock.");
}
}
}
fs::create_dir(&folder).map_err(|e| e.to_string())?;
let mut formatted = vec!["src/main.rs".into(), "src/app_module.rs".into()];
for (name, content) in files {
formatted.push(format!("src/{snake}/{name}"));
let path = folder.join(name);
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
write(&path, &content)?;
}
write(&main_path, &main)?;
write(&app_path, &app)?;
if kind == "resource" && root.join("src/tests/mod.rs").exists() {
let tests = root.join("src/tests/mod.rs");
let mut declarations = fs::read_to_string(&tests).map_err(|e| e.to_string())?;
declarations.push_str(&format!("\nmod {snake};\n"));
let path = root.join(format!("src/tests/{snake}.rs"));
write(
&path,
&include_str!("../templates/resource/tests.rs.tmpl").replace("__FEATURE__", &snake),
)?;
write(&tests, &declarations)?;
formatted.extend(["src/tests/mod.rs".into(), format!("src/tests/{snake}.rs")]);
}
format_files(root, &formatted)?;
println!("Created {kind} {pascal} in src/{snake}");
if matches!(kind, "service" | "controller") {
println!("Add this building block to a module's providers when ready to use it.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_reject_paths_and_keywords() {
for name in ["../user", "user/name", "mod", "user__name", "1user"] {
assert!(names(name).is_err());
}
assert_eq!(
names("user-profile").unwrap(),
("user_profile".into(), "UserProfile".into())
);
}
#[test]
fn resource_registers_and_duplicate_preserves_files() {
let root = std::env::temp_dir().join(format!("xecute-cli-test-{}", std::process::id()));
fs::create_dir_all(root.join("src")).unwrap();
write(&root.join("src/main.rs"), "mod app_module;\n").unwrap();
write(
&root.join("src/app_module.rs"),
include_str!("../templates/app_module.rs"),
)
.unwrap();
component(&root, "res", "user").unwrap();
let before = fs::read_to_string(root.join("src/app_module.rs")).unwrap();
assert!(before.contains("UserController::router"));
assert!(component(&root, "res", "user").is_err());
assert_eq!(
before,
fs::read_to_string(root.join("src/app_module.rs")).unwrap()
);
fs::remove_dir_all(root).unwrap();
}
}