use std::fs;
use std::path::{Path, PathBuf};
use chrono::Utc;
use console::style;
use crate::validate::{to_pascal_case, to_snake_case};
pub fn generate_controller(name: &str) -> Result<(), String> {
let base = current_dir()?;
generate_controller_in(&base, name)
}
pub fn generate_model(name: &str) -> Result<(), String> {
let base = current_dir()?;
generate_model_in(&base, name)
}
pub fn generate_scaffold(name: &str) -> Result<(), String> {
let base = current_dir()?;
generate_scaffold_in(&base, name)
}
fn generate_controller_in(base: &Path, name: &str) -> Result<(), String> {
let snake = to_snake_case(name);
let pascal = to_pascal_case(name);
write_controller_file(base, &snake, &pascal)?;
write_index_template(base, &snake, &pascal)?;
write_show_template(base, &snake, &pascal)?;
print_controller_route_hint(&snake);
Ok(())
}
fn generate_model_in(base: &Path, name: &str) -> Result<(), String> {
let snake = to_snake_case(name);
let pascal = to_pascal_case(name);
let plural = format!("{snake}s");
write_model_file(base, &snake, &pascal)?;
write_migration_file(base, &snake, &plural)?;
println!(
" {} model {} and migration for {plural}",
style("created").green().bold(),
snake
);
Ok(())
}
fn generate_scaffold_in(base: &Path, name: &str) -> Result<(), String> {
let snake = to_snake_case(name);
generate_controller_in(base, name)?;
generate_model_in(base, name)?;
write_list_fragment(base, &snake)?;
print_scaffold_route_hints(&snake);
Ok(())
}
fn write_controller_file(base: &Path, snake: &str, pascal: &str) -> Result<(), String> {
let dir = base.join("src/controllers");
let path = dir.join(format!("{snake}.rs"));
let content = format!(
r#"use blixt::prelude::*;
#[derive(Template)]
#[template(path = "pages/{snake}/index.html")]
pub struct {pascal}Index {{
pub items: Vec<String>,
}}
pub async fn index() -> Result<{pascal}Index> {{
Ok({pascal}Index {{
items: vec![],
}})
}}
#[derive(Template)]
#[template(path = "pages/{snake}/show.html")]
pub struct {pascal}Show {{
pub id: String,
}}
pub async fn show(Path(id): Path<String>) -> Result<{pascal}Show> {{
Ok({pascal}Show {{ id }})
}}
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn write_index_template(base: &Path, snake: &str, pascal: &str) -> Result<(), String> {
let dir = base.join(format!("templates/pages/{snake}"));
let path = dir.join("index.html");
let content = format!(
r#"{{% extends "layouts/app.html" %}}
{{% block title %}}{pascal} List{{% endblock %}}
{{% block content %}}
<h1>{pascal} List</h1>
{{% endblock %}}
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn write_show_template(base: &Path, snake: &str, pascal: &str) -> Result<(), String> {
let dir = base.join(format!("templates/pages/{snake}"));
let path = dir.join("show.html");
let content = format!(
r#"{{% extends "layouts/app.html" %}}
{{% block title %}}{pascal} Detail{{% endblock %}}
{{% block content %}}
<h1>{pascal} #{{{{ id }}}}</h1>
{{% endblock %}}
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn write_model_file(base: &Path, snake: &str, pascal: &str) -> Result<(), String> {
let dir = base.join("src/models");
let path = dir.join(format!("{snake}.rs"));
let content = format!(
r#"use blixt::prelude::*;
use sqlx::types::chrono::{{DateTime, Utc}};
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct {pascal} {{
pub id: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}}
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn write_migration_file(base: &Path, snake: &str, plural: &str) -> Result<(), String> {
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
let dir = base.join("migrations");
let path = dir.join(format!("{timestamp}_create_{snake}s.sql"));
let content = format!(
r#"CREATE TABLE IF NOT EXISTS {plural} (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn write_list_fragment(base: &Path, snake: &str) -> Result<(), String> {
let dir = base.join(format!("templates/fragments/{snake}"));
let path = dir.join("list.html");
let content = format!(
r#"<div id="{snake}-list">
{{% for item in items %}}
<div>{{{{ item.id }}}}</div>
{{% endfor %}}
</div>
"#
);
ensure_dir_exists(&dir)?;
write_file(&path, &content)
}
fn print_controller_route_hint(snake: &str) {
println!(" {} controller {snake}", style("created").green().bold());
println!(
"\n {} Add to src/main.rs routes:",
style("next:").cyan().bold()
);
println!(" .route(\"/{snake}\", get(controllers::{snake}::index))");
println!(" .route(\"/{snake}/{{id}}\", get(controllers::{snake}::show))");
}
fn print_scaffold_route_hints(snake: &str) {
println!(
"\n {} Add CRUD routes to src/main.rs:",
style("next:").cyan().bold()
);
println!(" .route(\"/{snake}\", get(controllers::{snake}::index))");
println!(" .route(\"/{snake}\", post(controllers::{snake}::create))");
println!(" .route(\"/{snake}/{{id}}\", get(controllers::{snake}::show))");
println!(" .route(\"/{snake}/{{id}}\", put(controllers::{snake}::update))");
println!(" .route(\"/{snake}/{{id}}\", delete(controllers::{snake}::destroy))");
}
fn current_dir() -> Result<PathBuf, String> {
std::env::current_dir().map_err(|err| format!("Failed to determine current directory: {err}"))
}
fn ensure_dir_exists(dir: &Path) -> Result<(), String> {
fs::create_dir_all(dir)
.map_err(|err| format!("Failed to create directory '{}': {err}", dir.display()))
}
fn write_file(path: &Path, content: &str) -> Result<(), String> {
if path.exists() {
return Err(format!("File already exists: {}", path.display()));
}
fs::write(path, content).map_err(|err| format!("Failed to write '{}': {err}", path.display()))
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn controller_creates_files_with_expected_content() {
let tmp = TempDir::new().expect("failed to create temp dir");
let base = tmp.path();
generate_controller_in(base, "blog_post").expect("generate_controller_in failed");
let controller = fs::read_to_string(base.join("src/controllers/blog_post.rs"))
.expect("controller file missing");
assert!(controller.contains("pub struct BlogPostIndex"));
assert!(controller.contains("pub struct BlogPostShow"));
assert!(controller.contains("pub async fn index()"));
assert!(controller.contains("pub async fn show("));
let index = fs::read_to_string(base.join("templates/pages/blog_post/index.html"))
.expect("index template missing");
assert!(index.contains("BlogPost List"));
assert!(index.contains("extends \"layouts/app.html\""));
let show = fs::read_to_string(base.join("templates/pages/blog_post/show.html"))
.expect("show template missing");
assert!(show.contains("BlogPost Detail"));
assert!(show.contains("{{ id }}"));
}
#[test]
fn model_creates_files_with_valid_structure() {
let tmp = TempDir::new().expect("failed to create temp dir");
let base = tmp.path();
generate_model_in(base, "User").expect("generate_model_in failed");
let model =
fs::read_to_string(base.join("src/models/user.rs")).expect("model file missing");
assert!(model.contains("pub struct User"));
assert!(model.contains("pub id: i64"));
assert!(model.contains("DateTime<Utc>"));
assert!(model.contains("FromRow"));
let entries: Vec<_> = fs::read_dir(base.join("migrations"))
.expect("migrations dir missing")
.filter_map(|entry| entry.ok())
.collect();
assert_eq!(entries.len(), 1);
let migration_path = entries[0].path();
let filename = migration_path
.file_name()
.expect("no filename")
.to_string_lossy();
assert!(filename.ends_with("_create_users.sql"));
let sql = fs::read_to_string(&migration_path).expect("migration file missing");
assert!(sql.contains("CREATE TABLE IF NOT EXISTS users"));
assert!(sql.contains("BIGSERIAL PRIMARY KEY"));
assert!(sql.contains("created_at TIMESTAMPTZ"));
assert!(sql.contains("updated_at TIMESTAMPTZ"));
}
#[test]
fn scaffold_creates_controller_model_and_fragment() {
let tmp = TempDir::new().expect("failed to create temp dir");
let base = tmp.path();
generate_scaffold_in(base, "Product").expect("generate_scaffold_in failed");
assert!(base.join("src/controllers/product.rs").exists());
assert!(base.join("src/models/product.rs").exists());
assert!(base.join("templates/pages/product/index.html").exists());
assert!(base.join("templates/pages/product/show.html").exists());
let fragment = fs::read_to_string(base.join("templates/fragments/product/list.html"))
.expect("list fragment missing");
assert!(fragment.contains("product-list"));
assert!(fragment.contains("item.id"));
let entries: Vec<_> = fs::read_dir(base.join("migrations"))
.expect("migrations dir missing")
.filter_map(|entry| entry.ok())
.collect();
assert_eq!(entries.len(), 1);
}
#[test]
fn duplicate_generation_returns_error() {
let tmp = TempDir::new().expect("failed to create temp dir");
let base = tmp.path();
generate_controller_in(base, "Item").expect("first generation failed");
let result = generate_controller_in(base, "Item");
assert!(result.is_err());
assert!(result.unwrap_err().contains("already exists"));
}
}