use std::{
fs,
path::{Path, PathBuf},
};
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()))
}
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"),
&format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace]\n\n[dependencies]\n{dependency}\naxum = \"0.8\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\ntokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"net\", \"signal\"] }}\n"
),
)?;
write(&root.join(".gitignore"), "/target/\n.env\n")?;
write(
&root.join("src/main.rs"),
"mod app_module;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n let app = app_module::AppModule::router()?;\n let address = std::env::var(\"BIND_ADDRESS\").unwrap_or_else(|_| \"127.0.0.1:3000\".into());\n let listener = tokio::net::TcpListener::bind(address).await?;\n println!(\"Listening on http://{}\", listener.local_addr()?);\n axum::serve(listener, app).with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; }).await?;\n Ok(())\n}\n",
)?;
write(
&root.join("src/app_module.rs"),
"use axum::Router;\nuse xecute::{InjectError, ModuleGraph};\n// xecute:modules\n\n#[xecute::module(imports = [\n // xecute:imports\n])]\npub struct AppModule;\n\nimpl AppModule {\n pub fn router() -> Result<Router, InjectError> {\n let container = ModuleGraph::new(vec![\n Self::definition(),\n // xecute:definitions\n ]).build()?;\n let _ = &container;\n let router = Router::new();\n // xecute:routes\n Ok(router)\n }\n}\n",
)?;
write(
&root.join("README.md"),
&format!(
"# {name}\n\nGenerate an API resource: `xecute g resource user`.\nRun: `cargo run`. Request: `curl http://127.0.0.1:3000/user`.\nResources generate a GET collection skeleton with injectable services.\nThe runtime uses crates.io by default. Use --runtime-path when creating a project for local development.\n"
),
)?;
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!("mod {snake};\n{main}");
fs::create_dir(&folder).map_err(|e| e.to_string())?;
for (name, content) in files {
write(&folder.join(name), &content)?;
}
write(&main_path, &main)?;
write(&app_path, &app)?;
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"),
"// xecute:modules\n// xecute:imports\n// xecute:definitions\n// xecute:routes\n",
)
.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();
}
}