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.0\"".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/config.rs", include_str!("../templates/config.rs")),
("src/logging.rs", include_str!("../templates/logging.rs")),
("src/error.rs", include_str!("../templates/error.rs")),
(
"src/rate_limit.rs",
include_str!("../templates/rate_limit.rs"),
),
("src/server.rs", include_str!("../templates/server.rs")),
(
"src/server/tests.rs",
include_str!("../templates/server_tests.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")),
("compose.yaml", include_str!("../templates/compose.yaml")),
(
".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()
));
}
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!(
"#[xecute::injectable]\npub struct {pascal}Service {{}}\n\nimpl {pascal}Service {{\n pub fn list(&self) -> Vec<String> {{ Vec::new() }}\n}}\n"
);
let controller = if kind == "resource" {
format!(
"use std::sync::Arc;\nuse axum::{{extract::State, Json}};\nuse super::service::{pascal}Service;\n\n#[xecute::injectable]\npub struct {pascal}Controller {{ service: Arc<{pascal}Service> }}\n\n#[xecute::controller(\"/{snake}\")]\nimpl {pascal}Controller {{\n #[get(\"\")]\n async fn list(State(controller): State<Arc<Self>>) -> Json<Vec<String>> {{\n Json(controller.service.list())\n }}\n}}\n"
)
} else {
format!(
"use axum::Json;\n\n#[xecute::injectable]\npub struct {pascal}Controller {{}}\n\n#[xecute::controller(\"/{snake}\")]\nimpl {pascal}Controller {{\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 = String::new();
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!(
"use super::{{{pascal}Controller, {pascal}Service}};\n\n#[xecute::module(providers = [{pascal}Service, {pascal}Controller], controllers = [{pascal}Controller], exports = [{pascal}Service])]\npub struct {pascal}Module;\n"
)
} else {
format!("#[xecute::module(providers = [])]\npub struct {pascal}Module;\n")
};
files.push((format!("{snake}_module.rs"), module));
mods.push_str(&format!(
"mod {snake}_module;\npub use {snake}_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]));
}
}
files.push(("mod.rs".into(), mods));
main = format!("pub mod {snake};\n{main}");
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}"));
write(&folder.join(name), &content)?;
}
write(&main_path, &main)?;
write(&app_path, &app)?;
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();
}
}