use std::fs;
use std::io;
use std::path::Path;
pub fn make_model(name: &str, fields: &[String]) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let table_name = pluralize_table_name(&file_stem);
let model_fields = render_model_fields(fields)?;
let model_body = if model_fields.is_empty() {
String::from(
r#" // TODO: Replace these placeholders with the columns your table needs.
// Example:
// pub email: String,
// pub created_at: i64,
// pub updated_at: i64,"#,
)
} else {
model_fields.join("\n")
};
let template = format!(
r#"use serde::{{Deserialize, Serialize}};
use oxidite::db::{{Model, sqlx}};
// Generated by `oxidite generate model`.
// Edit this struct to match the schema you want to persist.
// If you rename fields here, keep the migration SQL in sync.
#[derive(Debug, Clone, Serialize, Deserialize, Model, sqlx::FromRow)]
#[model(table = "{table_name}")]
pub struct {name} {{
// Keep this aligned with your database primary key type.
pub id: i64,
{model_body}
}}
"#,
);
write_generated_source("Model", "models", &file_stem, &template)
}
pub fn make_route(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_route_name(name)?;
let module_name = to_snake_case(name);
let route_segment = if module_name.ends_with('s') {
module_name.clone()
} else {
pluralize_table_name(&module_name)
};
let template = format!(
r#"use oxidite::prelude::*;
// Generated by `oxidite generate route`.
// This module is registered from `src/routes/mod.rs` automatically.
// Replace the placeholder responses with real handlers or service calls.
pub fn register(router: &mut Router) {{
router.get("/{route}", index);
router.get("/{route}/:id", show);
router.post("/{route}", create);
router.put("/{route}/:id", update);
router.delete("/{route}/:id", destroy);
}}
async fn index(_req: Request) -> Result<Response> {{
// TODO: List your resources here.
Ok(Response::json(serde_json::json!({{
"message": "List {route}"
}})))
}}
async fn show(_req: Request) -> Result<Response> {{
// TODO: Load and return one resource here.
Ok(Response::json(serde_json::json!({{
"message": "Show one {resource}"
}})))
}}
async fn create(_req: Request) -> Result<Response> {{
// TODO: Parse input, validate it, and persist a new resource here.
Ok(Response::json(serde_json::json!({{
"message": "Create {resource}"
}})))
}}
async fn update(_req: Request) -> Result<Response> {{
// TODO: Apply updates to an existing resource here.
Ok(Response::json(serde_json::json!({{
"message": "Update {resource}"
}})))
}}
async fn destroy(_req: Request) -> Result<Response> {{
// TODO: Delete the resource or mark it as archived here.
Ok(Response::json(serde_json::json!({{
"message": "Delete {resource}"
}})))
}}
"#,
route = route_segment,
resource = singularize_route_name(&module_name)
);
write_generated_route(&module_name, &template)
}
pub fn make_controller(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use oxidite::prelude::*;
use std::sync::Arc;
// Generated by `oxidite generate controller`.
// Handlers use extractor-based signatures for clean dependency injection.
// State<Arc<AppState>> provides access to shared application state (db pool, config, etc.).
// Path<serde_json::Value> extracts URL parameters.
// Json<T> parses and validates request bodies.
pub struct {name};
impl {name} {{
/// GET /resources
pub async fn index(
State(state): State<Arc<AppState>>,
) -> Result<OxiditeResponse> {{
// TODO: Fetch all resources from the database using state.db
Ok(response::json(serde_json::json!({{
"message": "List endpoint"
}})))
}}
/// GET /resources/:id
pub async fn show(
State(state): State<Arc<AppState>>,
Path(params): Path<serde_json::Value>,
) -> Result<OxiditeResponse> {{
let id = params["id"].as_i64().unwrap_or(0);
// TODO: Fetch one resource by id using state.db
Ok(response::json(serde_json::json!({{
"message": format!("Show resource {{}}", id)
}})))
}}
/// POST /resources
pub async fn create(
State(state): State<Arc<AppState>>,
Json(payload): Json<serde_json::Value>,
) -> Result<OxiditeResponse> {{
// TODO: Parse payload, validate, and insert using state.db
Ok(response::json(serde_json::json!({{
"message": "Resource created",
"data": payload
}})))
}}
/// PUT /resources/:id
pub async fn update(
State(state): State<Arc<AppState>>,
Path(params): Path<serde_json::Value>,
Json(payload): Json<serde_json::Value>,
) -> Result<OxiditeResponse> {{
let id = params["id"].as_i64().unwrap_or(0);
// TODO: Update the resource by id using state.db
Ok(response::json(serde_json::json!({{
"message": format!("Resource {{}} updated", id),
"data": payload
}})))
}}
/// DELETE /resources/:id
pub async fn destroy(
State(state): State<Arc<AppState>>,
Path(params): Path<serde_json::Value>,
) -> Result<OxiditeResponse> {{
let id = params["id"].as_i64().unwrap_or(0);
// TODO: Delete the resource by id using state.db
Ok(response::json(serde_json::json!({{
"message": format!("Resource {{}} deleted", id)
}})))
}}
}}
"#,
);
write_generated_source("Controller", "controllers", &file_stem, &template)
}
pub fn make_middleware(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use oxidite::oxidite_middleware::tower::{{Layer, Service}};
use oxidite::{{Error, OxiditeRequest, OxiditeResponse}};
use std::future::Future;
use std::pin::Pin;
use std::task::{{Context, Poll}};
// Generated by `oxidite generate middleware`.
// This template compiles against the default `oxidite` dependency.
// Replace the placeholder hooks with logging, authentication, headers, or tracing logic.
#[derive(Clone)]
pub struct {name}<S> {{
inner: S,
}}
impl<S> {name}<S> {{
pub fn new(inner: S) -> Self {{
Self {{ inner }}
}}
}}
impl<S> Service<OxiditeRequest> for {name}<S>
where
S: Service<OxiditeRequest, Response = OxiditeResponse, Error = Error> + Clone + Send + 'static,
S::Future: Send + 'static,
{{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {{
self.inner.poll_ready(cx)
}}
fn call(&mut self, req: OxiditeRequest) -> Self::Future {{
let mut inner = self.inner.clone();
Box::pin(async move {{
// TODO: Inspect or transform the incoming request here.
let response = inner.call(req).await?;
// TODO: Inspect or transform the outgoing response here.
Ok(response)
}})
}}
}}
#[derive(Clone, Default)]
pub struct {name}Layer;
impl {name}Layer {{
pub fn new() -> Self {{
Self
}}
}}
impl<S> Layer<S> for {name}Layer {{
type Service = {name}<S>;
fn layer(&self, inner: S) -> Self::Service {{
{name}::<S>::new(inner)
}}
}}
"#,
);
write_generated_source("Middleware", "middleware", &file_stem, &template)
}
pub fn make_service(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use serde::{{Deserialize, Serialize}};
// Generated by `oxidite generate service`.
// Keep business rules here so controllers stay focused on HTTP concerns.
// Expand the input and output types first, then replace the placeholder error path.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {name}Input {{
// TODO: Add the values this service needs to run.
}}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {name}Output {{
// TODO: Add the values this service should return.
}}
#[derive(Clone, Default)]
pub struct {name}Service;
impl {name}Service {{
pub fn new() -> Self {{
Self
}}
pub async fn execute(
&self,
_input: {name}Input,
) -> Result<{name}Output, Box<dyn std::error::Error>> {{
// TODO: Validate input, call repositories, and map domain errors here.
Err("Replace this placeholder service implementation".into())
}}
}}
"#,
);
write_generated_source("Service", "services", &file_stem, &template)
}
pub fn make_validator(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use serde_json::Value;
use std::collections::HashMap;
// Generated by `oxidite generate validator`.
// Add field-level checks here before a controller or service uses the payload.
pub struct {name}Validator;
impl {name}Validator {{
pub fn validate(data: &Value) -> Result<(), ValidationError> {{
let mut errors = HashMap::new();
if data.is_null() {{
errors.insert("body".to_string(), "Body must not be null".to_string());
}}
// TODO: Add more field checks and push messages into `errors`.
if !errors.is_empty() {{
return Err(ValidationError::new(errors));
}}
Ok(())
}}
}}
#[derive(Debug)]
pub struct ValidationError {{
pub errors: HashMap<String, String>,
}}
impl ValidationError {{
pub fn new(errors: HashMap<String, String>) -> Self {{
Self {{ errors }}
}}
}}
impl std::fmt::Display for ValidationError {{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
write!(f, "Validation failed: {{:?}}", self.errors)
}}
}}
impl std::error::Error for ValidationError {{}}
"#,
);
write_generated_source("Validator", "validators", &file_stem, &template)
}
pub fn make_job(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use serde::{{Deserialize, Serialize}};
// Generated by `oxidite generate job`.
// Keep retry-safe background work here.
// Add only the payload needed for the worker to finish independently.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {name}Job {{
pub id: String,
// TODO: Add the payload fields this job needs.
}}
impl {name}Job {{
pub async fn handle(&self) -> Result<(), Box<dyn std::error::Error>> {{
// TODO: Add background processing logic here.
Ok(())
}}
}}
"#,
);
write_generated_source("Job", "jobs", &file_stem, &template)
}
pub fn make_policy(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"// Generated by `oxidite generate policy`.
// Put authorization decisions here so controllers stay readable.
pub struct {name}Policy;
impl {name}Policy {{
pub fn can_view(_user_id: i64, _resource_owner_id: i64) -> bool {{
// TODO: Replace this placeholder with your read-access rules.
true
}}
pub fn can_update(_user_id: i64, _resource_owner_id: i64) -> bool {{
// TODO: Replace this placeholder with your write-access rules.
true
}}
}}
"#,
);
write_generated_source("Policy", "policies", &file_stem, &template)
}
pub fn make_event(name: &str) -> Result<(), Box<dyn std::error::Error>> {
validate_rust_type_name(name)?;
let file_stem = to_snake_case(name);
let template = format!(
r#"use serde::{{Deserialize, Serialize}};
// Generated by `oxidite generate event`.
// Capture the domain facts you want to publish or persist here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {name}Event {{
pub occurred_at: i64,
// TODO: Add the event payload fields you want consumers to receive.
}}
"#,
);
write_generated_source("Event", "events", &file_stem, &template)
}
fn write_generated_source(
kind: &str,
root_module: &str,
file_stem: &str,
contents: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let module_dir = Path::new("src").join(root_module);
fs::create_dir_all(&module_dir)?;
let file_path = module_dir.join(format!("{file_stem}.rs"));
ensure_file_does_not_exist(&file_path)?;
ensure_root_module_declaration(root_module)?;
ensure_module_export(&module_dir.join("mod.rs"), file_stem)?;
fs::write(&file_path, contents)?;
println!("✅ {kind} created: {}", file_path.display());
Ok(())
}
fn write_generated_route(
file_stem: &str,
contents: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let module_dir = Path::new("src/routes");
fs::create_dir_all(&module_dir)?;
let file_path = module_dir.join(format!("{file_stem}.rs"));
ensure_file_does_not_exist(&file_path)?;
ensure_root_module_declaration("routes")?;
ensure_route_registry(&module_dir.join("mod.rs"), file_stem)?;
fs::write(&file_path, contents)?;
println!("✅ Route created: {}", file_path.display());
Ok(())
}
fn ensure_file_does_not_exist(path: &Path) -> Result<(), io::Error> {
if path.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("file already exists: {}", path.display()),
));
}
Ok(())
}
fn ensure_root_module_declaration(module_name: &str) -> Result<(), io::Error> {
for path in [Path::new("src/main.rs"), Path::new("src/lib.rs")] {
if !path.exists() {
continue;
}
let content = fs::read_to_string(path)?;
let declaration = if path.ends_with("lib.rs") {
format!("pub mod {module_name};")
} else {
format!("mod {module_name};")
};
let updated = insert_module_declaration(&content, &declaration);
if updated != content {
fs::write(path, updated)?;
}
}
Ok(())
}
fn insert_module_declaration(content: &str, declaration: &str) -> String {
if content.contains(declaration) || content.contains(&format!("pub {declaration}")) {
return content.to_string();
}
let mut insert_at = 0usize;
let mut cursor = 0usize;
for line in content.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("use ")
|| trimmed.starts_with("mod ")
|| trimmed.starts_with("pub mod ")
{
insert_at = cursor + line.len();
}
cursor += line.len();
}
let mut updated = String::with_capacity(content.len() + declaration.len() + 1);
updated.push_str(&content[..insert_at]);
if insert_at > 0 && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(declaration);
updated.push('\n');
updated.push_str(&content[insert_at..]);
updated
}
fn ensure_module_export(path: &Path, module_name: &str) -> Result<(), io::Error> {
let declaration = format!("pub mod {module_name};");
let mut content = if path.exists() {
fs::read_to_string(path)?
} else {
String::from("// Generated modules in this directory are exported here.\n")
};
if content.contains(&declaration) {
return Ok(());
}
if !content.ends_with('\n') && !content.is_empty() {
content.push('\n');
}
content.push_str(&declaration);
content.push('\n');
fs::write(path, content)
}
fn ensure_route_registry(path: &Path, module_name: &str) -> Result<(), io::Error> {
let mut content = if path.exists() {
fs::read_to_string(path)?
} else {
route_registry_template()
};
content = insert_module_declaration(&content, &format!("pub mod {module_name};"));
if content.contains("pub fn register(_router: &mut Router)") {
content = content.replacen(
"pub fn register(_router: &mut Router)",
"pub fn register(router: &mut Router)",
1,
);
}
if !content.contains("pub fn register(router: &mut Router)") {
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(
"\npub fn register(router: &mut Router) {\n register_generated(router);\n}\n",
);
}
if !content.contains("fn register_generated(router: &mut Router)") {
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(
"\nfn register_generated(router: &mut Router) {\n // Generated route modules are registered here.\n}\n",
);
}
ensure_line_in_function(
&mut content,
"pub fn register(router: &mut Router)",
" register_generated(router);",
)?;
ensure_line_in_function(
&mut content,
"fn register_generated(router: &mut Router)",
&format!(" {module_name}::register(router);"),
)?;
fs::write(path, content)
}
fn route_registry_template() -> String {
String::from(
r#"use oxidite::prelude::*;
pub fn register(router: &mut Router) {
register_generated(router);
}
fn register_generated(router: &mut Router) {
// Generated route modules are registered here.
}
"#,
)
}
fn ensure_line_in_function(
content: &mut String,
signature: &str,
line: &str,
) -> Result<(), io::Error> {
let Some(signature_start) = content.find(signature) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("could not find function signature `{signature}`"),
));
};
let open_brace = content[signature_start..]
.find('{')
.map(|offset| signature_start + offset)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("could not find function body for `{signature}`"),
)
})?;
let close_brace = find_matching_brace(content, open_brace)?;
if content[open_brace + 1..close_brace].contains(line.trim()) {
return Ok(());
}
let mut insertion = String::new();
if !content[..close_brace].ends_with('\n') {
insertion.push('\n');
}
insertion.push_str(line);
insertion.push('\n');
content.insert_str(close_brace, &insertion);
Ok(())
}
fn find_matching_brace(content: &str, open_brace: usize) -> Result<usize, io::Error> {
let bytes = content.as_bytes();
let mut depth = 0usize;
for (idx, byte) in bytes.iter().enumerate().skip(open_brace) {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Ok(idx);
}
}
_ => {}
}
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
"could not find closing brace",
))
}
fn validate_rust_type_name(name: &str) -> Result<(), io::Error> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name cannot be empty",
));
};
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name must start with a letter or underscore",
));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name must contain only letters, numbers, and underscores",
));
}
Ok(())
}
fn validate_route_name(name: &str) -> Result<(), io::Error> {
if name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name cannot be empty",
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"route names may contain only letters, numbers, underscores, and hyphens",
));
}
Ok(())
}
fn render_model_fields(fields: &[String]) -> Result<Vec<String>, io::Error> {
fields
.iter()
.map(|field| {
let (name, ty) = parse_field_definition(field)?;
Ok(format!(" pub {name}: {ty},"))
})
.collect()
}
fn parse_field_definition(field: &str) -> Result<(String, String), io::Error> {
let mut parts = field.splitn(2, ':');
let name = parts.next().unwrap_or_default().trim();
let raw_type = parts.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid field definition `{field}`; expected name:type"),
)
})?;
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid field name `{name}`"),
));
}
let rust_type = match raw_type.trim().to_ascii_lowercase().as_str() {
"string" | "text" => "String",
"integer" | "int" | "bigint" => "i64",
"float" | "double" | "decimal" => "f64",
"boolean" | "bool" => "bool",
"uuid" => "String",
"json" => "serde_json::Value",
"timestamp" | "datetime" => "i64",
"optional_string" => "Option<String>",
"optional_integer" => "Option<i64>",
"optional_boolean" => "Option<bool>",
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported field type `{other}`"),
));
}
};
Ok((name.to_string(), rust_type.to_string()))
}
fn to_snake_case(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 4);
for (i, ch) in input.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch.to_ascii_lowercase());
}
}
out
}
fn pluralize_table_name(base: &str) -> String {
if base.ends_with('s') {
base.to_string()
} else {
format!("{base}s")
}
}
fn singularize_route_name(base: &str) -> String {
if let Some(stripped) = base.strip_suffix('s') {
stripped.to_string()
} else {
base.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
find_matching_brace, insert_module_declaration, parse_field_definition,
pluralize_table_name, singularize_route_name, to_snake_case, validate_route_name,
validate_rust_type_name,
};
#[test]
fn snake_case_conversion() {
assert_eq!(to_snake_case("UserProfile"), "user_profile");
assert_eq!(to_snake_case("user"), "user");
}
#[test]
fn pluralize_table_names() {
assert_eq!(pluralize_table_name("users"), "users");
assert_eq!(pluralize_table_name("post"), "posts");
}
#[test]
fn validates_type_names() {
assert!(validate_rust_type_name("User").is_ok());
assert!(validate_rust_type_name("_User1").is_ok());
assert!(validate_rust_type_name("1User").is_err());
assert!(validate_rust_type_name("user-name").is_err());
}
#[test]
fn validates_route_names() {
assert!(validate_route_name("users").is_ok());
assert!(validate_route_name("admin-users").is_ok());
assert!(validate_route_name("users/posts").is_err());
}
#[test]
fn parses_model_fields() {
assert_eq!(
parse_field_definition("email:string").unwrap(),
("email".to_string(), "String".to_string())
);
assert_eq!(
parse_field_definition("age:integer").unwrap(),
("age".to_string(), "i64".to_string())
);
assert!(parse_field_definition("broken").is_err());
}
#[test]
fn singularizes_route_names() {
assert_eq!(singularize_route_name("users"), "user");
assert_eq!(singularize_route_name("news"), "new");
assert_eq!(singularize_route_name("post"), "post");
}
#[test]
fn inserts_missing_module_declarations_once() {
let content = "use oxidite::prelude::*;\n\nmod routes;\n";
let updated = insert_module_declaration(content, "mod services;");
assert!(updated.contains("mod services;"));
let second = insert_module_declaration(&updated, "mod services;");
assert_eq!(updated, second);
}
#[test]
fn does_not_duplicate_pub_module_declarations() {
let content = "pub mod routes;\n";
let updated = insert_module_declaration(content, "pub mod routes;");
assert_eq!(content, updated);
}
#[test]
fn matches_function_braces() {
let source = "pub fn register(router: &mut Router) {\n register_generated(router);\n}\n";
let open = source.find('{').unwrap();
let close = find_matching_brace(source, open).unwrap();
assert_eq!(&source[close..=close], "}");
}
}